Files
band-lineup-display/propresenter-app/config.js
T

280 lines
9.4 KiB
JavaScript
Raw Normal View History

'use strict';
/**
* Config storage for the standalone Now/Next display.
*
* The file format matches the Band Lineup app's credential files (an `enc:` prefix
* for OS-encrypted secrets, `plain:` for base64) so the two programs stay readable
* to each other. Under Electron we use safeStorage; under plain `node server.js`
* there is no OS keychain hook, so the password is base64-obfuscated at rest —
* treat the config file as a secret either way.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const dgram = require('dgram');
const DEFAULT_CONFIG = {
enabled: false,
host: '',
port: 0,
password: '',
};
/** Electron's safeStorage when available (kiosk mode); null under plain Node. */
function safeStorage() {
try {
// eslint-disable-next-line global-require
const electron = require('electron');
if (electron?.safeStorage?.isEncryptionAvailable?.()) return electron.safeStorage;
} catch {
/* not running under Electron */
}
return null;
}
function encodeSecret(value) {
const text = String(value ?? '');
if (!text) return '';
const store = safeStorage();
if (store) return `enc:${store.encryptString(text).toString('base64')}`;
return `plain:${Buffer.from(text, 'utf8').toString('base64')}`;
}
function decodeSecret(value) {
const raw = String(value ?? '');
if (!raw) return '';
if (raw.startsWith('enc:')) {
const store = safeStorage();
if (!store) return ''; // written under Electron, now read by plain Node — can't decrypt
try {
return store.decryptString(Buffer.from(raw.slice(4), 'base64'));
} catch {
return '';
}
}
if (raw.startsWith('plain:')) return Buffer.from(raw.slice(6), 'base64').toString('utf8');
return raw;
}
/**
* Where config.json lives. Override with PP_DISPLAY_DATA_DIR (handy on a NAS or
* when several displays share one host). Electron callers pass app.getPath('userData').
*/
function resolveDataDir(preferred) {
const fromEnv = process.env.PP_DISPLAY_DATA_DIR;
if (fromEnv) return path.resolve(fromEnv);
if (preferred) return path.resolve(preferred);
return path.join(__dirname, 'data');
}
function configPath(dataDir) {
return path.join(dataDir, 'config.json');
}
function normalize(entry) {
const host = String(entry?.host || '').trim();
const portNum = Number.parseInt(String(entry?.port ?? ''), 10);
const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0;
return {
enabled: Boolean(entry?.enabled) && Boolean(host) && Boolean(port),
host,
port,
password: String(entry?.password || ''),
};
}
function loadConfig(dataDir) {
try {
const raw = JSON.parse(fs.readFileSync(configPath(dataDir), 'utf8'));
return normalize({
enabled: raw.enabled,
host: raw.host,
port: raw.port,
password: decodeSecret(raw.password),
});
} catch {
return { ...DEFAULT_CONFIG };
}
}
function saveConfig(dataDir, config) {
const clean = normalize(config);
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(
configPath(dataDir),
JSON.stringify(
{
enabled: clean.enabled,
host: clean.host,
port: clean.port,
password: encodeSecret(clean.password),
},
null,
2,
),
'utf8',
);
return clean;
}
/* ------------------------------------------------------------------ *
* Which address should someone actually bookmark? *
* *
* An A/V machine backstage is the worst case: alongside the house LAN *
* LAN it often has a camera/Dante network, a VPN tunnel, and on Windows *
* Hyper-V/WSL switches that hand out plausible-looking 192.168.x *
* addresses. Listing every non-internal IPv4 unranked (what we used to *
* do) gives a volunteer five equal-looking URLs and no way to choose. *
* So: hard-filter the impossible ones, score the rest, and let two *
* stronger signals override the score — the OS's own default route, *
* and (best of all) the local address a real viewer actually reached *
* us on. *
* ------------------------------------------------------------------ */
/** Adapter names that are never the church LAN. Windows keys are friendly names. */
const VIRTUAL_IFACE = [
/vethernet/i, /hyper-?v/i, /\bwsl\b/i, /virtualbox/i, /vmware/i, /vmnet/i, /docker/i,
/tailscale/i, /zerotier/i, /wintun/i, /wireguard/i, /nordlynx/i, /openvpn/i, /^tap-/i,
/radmin/i, /hamachi/i, /npcap/i, /bluetooth/i, /teredo/i, /isatap/i, /wan miniport/i,
/loopback pseudo/i, /^utun/i, /^awdl/i, /^llw/i, /^bridge/i, /^ipsec/i, /^gif/i, /^stf/i,
];
/** Addresses that can never carry a bookmark, whatever adapter they came from. */
function isImpossibleAddress(address) {
if (/^169\.254\./.test(address)) return true; // DHCP failed on that NIC
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(address)) return true; // CGNAT / Tailscale
if (/^25\./.test(address)) return true; // Hamachi
if (/^192\.168\.56\./.test(address)) return true; // VirtualBox host-only default
return false;
}
function isPrivate(address) {
return /^10\./.test(address)
|| /^192\.168\./.test(address)
|| /^172\.(1[6-9]|2\d|3[01])\./.test(address);
}
/** Locally-administered MAC bit — set on virtual/bridged adapters. */
function isLocallyAdministeredMac(mac) {
const firstOctet = Number.parseInt(String(mac || '').split(':')[0], 16);
return Number.isFinite(firstOctet) && (firstOctet & 0x02) !== 0;
}
function scoreAddress(entry, ifaceName) {
let score = 0;
if (/^192\.168\./.test(entry.address)) score += 40; // overwhelmingly the church-LAN shape
else if (/^10\./.test(entry.address)) score += 30;
else if (/^172\./.test(entry.address)) score += 20;
else score -= 60; // public or unrecognised
if (entry.netmask === '255.255.255.0') score += 10;
if (/^ethernet/i.test(ifaceName)) score += 8; // prefer wired on an A/V machine
else if (/wi-?fi|wlan|^en0$/i.test(ifaceName)) score += 4;
if (isLocallyAdministeredMac(entry.mac)) score -= 25;
return score;
}
/**
* Ranked candidate addresses for viewers, best first.
* @param {object} [hints]
* @param {string} [hints.defaultRoute] Address of the interface holding the default route.
* @param {string[]} [hints.observed] Local addresses real viewers have already connected to.
*/
function viewerAddresses(hints = {}) {
const defaultRoute = String(hints.defaultRoute || '');
const observed = new Set((hints.observed || []).filter(Boolean));
const out = [];
const interfaces = os.networkInterfaces();
for (const [ifaceName, entries] of Object.entries(interfaces)) {
if (VIRTUAL_IFACE.some((pattern) => pattern.test(ifaceName))) continue;
for (const entry of entries || []) {
// Node <18 reported family as a string, newer as the number 4 — accept both.
const isIPv4 = entry.family === 'IPv4' || entry.family === 4;
if (!isIPv4 || entry.internal) continue;
if (isImpossibleAddress(entry.address)) continue;
if (entry.mac === '00:00:00:00:00:00') continue; // tunnel / pseudo adapter
let score = scoreAddress(entry, ifaceName);
if (entry.address === defaultRoute) score += 100; // the OS's own answer beats any heuristic
const confirmed = observed.has(entry.address);
if (confirmed) score += 1000; // a real viewer reached us here
out.push({
address: entry.address,
iface: ifaceName,
netmask: entry.netmask,
private: isPrivate(entry.address),
confirmed,
score,
});
}
}
out.sort((a, b) => b.score - a.score || a.address.localeCompare(b.address));
return out;
}
/**
* The address on the interface that holds the default route, per the OS routing table.
* A UDP "connect" sends no packets — it only asks the kernel which source address it
* would use — so this is free and side-effect-less.
*/
function defaultRouteAddress() {
return new Promise((resolve) => {
let settled = false;
const done = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
let socket;
try {
socket = dgram.createSocket('udp4');
} catch {
done('');
return;
}
socket.once('error', () => {
try { socket.close(); } catch { /* already closed */ }
done('');
});
const timer = setTimeout(() => {
try { socket.close(); } catch { /* already closed */ }
done('');
}, 400);
timer.unref?.();
try {
socket.connect(53, '203.0.113.1', () => { // RFC 5737 documentation address; nothing is sent
let address = '';
try { address = socket.address().address || ''; } catch { /* ignore */ }
clearTimeout(timer);
try { socket.close(); } catch { /* already closed */ }
done(address);
});
} catch {
clearTimeout(timer);
try { socket.close(); } catch { /* already closed */ }
done('');
}
});
}
/** Legacy helper: flat list of plausible addresses, best first. */
function lanAddresses() {
return viewerAddresses().map((entry) => entry.address);
}
module.exports = {
DEFAULT_CONFIG,
resolveDataDir,
configPath,
loadConfig,
saveConfig,
normalize,
lanAddresses,
viewerAddresses,
defaultRouteAddress,
};