280 lines
9.4 KiB
JavaScript
280 lines
9.4 KiB
JavaScript
'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,
|
|
};
|