Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA

This commit is contained in:
2026-09-04 14:55:59 +10:00
commit 3278890491
41 changed files with 8920 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import config from './config.js';
/**
* Certificates for an internal-only kiosk.
*
* Two certificates, not one. A long lived CA that you install on each kiosk tablet
* once, and a short lived server certificate signed by it. Renewing the server
* certificate then never means touching the tablets again — which matters, because
* Apple and Chrome reject server certificates valid for much more than a year, so a
* single self-signed certificate would have to be reinstalled everywhere annually.
*/
const CA_DAYS = 3650;
const SERVER_DAYS = 398;
const RENEW_WITHIN_DAYS = 30;
function certDir() {
return path.dirname(config.https.certPath);
}
function paths() {
const dir = certDir();
return {
dir,
caKey: path.join(dir, 'ca.key'),
caCert: path.join(dir, 'ca.crt'),
key: config.https.keyPath,
cert: config.https.certPath,
// Records which configured names the current certificate was issued for.
names: path.join(dir, '.hostnames.json'),
};
}
function openssl(args, options = {}) {
return execFileSync('openssl', args, { stdio: ['ignore', 'pipe', 'pipe'], ...options });
}
export function opensslAvailable() {
try {
openssl(['version']);
return true;
} catch {
return false;
}
}
/** Every name and address a browser might use to reach this kiosk. */
export function subjectAltNames() {
const dns = new Set(['localhost']);
const ips = new Set(['127.0.0.1']);
for (const entry of config.https.hostnames) {
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(entry)) ips.add(entry);
else dns.add(entry.toLowerCase());
}
// The container's own addresses, so hitting it directly still validates.
for (const list of Object.values(os.networkInterfaces())) {
for (const iface of list || []) {
if (iface.family === 'IPv4' && !iface.internal) ips.add(iface.address);
}
}
return [
...[...dns].map((d) => `DNS:${d}`),
...[...ips].map((i) => `IP:${i}`),
];
}
function readCert(file) {
try {
return new crypto.X509Certificate(fs.readFileSync(file));
} catch {
return null;
}
}
function daysUntil(date) {
return Math.floor((new Date(date).getTime() - Date.now()) / 86400000);
}
/** The SANs actually baked into a certificate, normalised for comparison. */
function certSans(cert) {
if (!cert?.subjectAltName) return [];
return cert.subjectAltName
.split(',')
.map((s) => s.trim().replace(/^IP Address:/, 'IP:'))
.sort();
}
function createCa(p) {
fs.mkdirSync(p.dir, { recursive: true });
openssl([
'req', '-x509', '-nodes', '-newkey', 'rsa:2048',
'-days', String(CA_DAYS),
'-keyout', p.caKey,
'-out', p.caCert,
'-subj', `/C=AU/O=${config.siteName}/CN=${config.siteName} Local CA`,
'-addext', 'basicConstraints=critical,CA:TRUE,pathlen:0',
'-addext', 'keyUsage=critical,keyCertSign,cRLSign',
]);
fs.chmodSync(p.caKey, 0o600);
console.log(`[tls] created a local certificate authority at ${p.caCert}`);
}
function createServerCert(p, sans) {
const primary = config.https.hostnames[0] || os.hostname() || 'visitors.local';
const csr = path.join(p.dir, 'server.csr');
const ext = path.join(p.dir, 'server.ext');
fs.writeFileSync(
ext,
[
`subjectAltName=${sans.join(',')}`,
'basicConstraints=CA:FALSE',
'keyUsage=critical,digitalSignature,keyEncipherment',
'extendedKeyUsage=serverAuth',
].join('\n')
);
openssl([
'req', '-nodes', '-newkey', 'rsa:2048',
'-keyout', p.key,
'-out', csr,
'-subj', `/C=AU/O=${config.siteName}/CN=${primary}`,
]);
openssl([
'x509', '-req',
'-in', csr,
'-CA', p.caCert,
'-CAkey', p.caKey,
'-CAcreateserial',
'-out', p.cert,
'-days', String(SERVER_DAYS),
'-sha256',
'-extfile', ext,
]);
fs.chmodSync(p.key, 0o600);
fs.rmSync(csr, { force: true });
fs.rmSync(ext, { force: true });
console.log(`[tls] issued a server certificate for ${sans.join(', ')}`);
}
/**
* Makes sure a usable certificate is on disk, creating or renewing as needed.
* Returns the material for https.createServer plus a summary for the admin console.
*/
export function ensureCertificates({ force = false } = {}) {
const p = paths();
if (!opensslAvailable()) {
throw new Error(
'openssl is not available, so a certificate cannot be generated. Supply your own ' +
'certificate at HTTPS_CERT and HTTPS_KEY, or terminate TLS at a reverse proxy.'
);
}
fs.mkdirSync(p.dir, { recursive: true });
if (force || !fs.existsSync(p.caCert) || !fs.existsSync(p.caKey)) {
createCa(p);
}
const wanted = subjectAltNames().sort();
const existing = readCert(p.cert);
// Compare against the configured names only. The container's own IP is in the
// certificate too, and Docker hands out a different one on most restarts, so
// comparing the full SAN list would reissue the certificate on every boot.
const configuredNow = [...config.https.hostnames].sort().join(',');
let configuredBefore = null;
try {
configuredBefore = JSON.parse(fs.readFileSync(p.names, 'utf8')).sort().join(',');
} catch {
configuredBefore = null;
}
let reason = null;
if (force) reason = 'asked to regenerate';
else if (!existing || !fs.existsSync(p.key)) reason = 'no certificate on disk';
else if (daysUntil(existing.validTo) < RENEW_WITHIN_DAYS) reason = 'certificate is close to expiry';
else if (configuredBefore !== configuredNow) reason = 'HTTPS_HOSTNAMES changed';
if (reason) {
console.log(`[tls] renewing the server certificate: ${reason}`);
createServerCert(p, wanted);
fs.writeFileSync(p.names, JSON.stringify(config.https.hostnames));
}
return {
key: fs.readFileSync(p.key),
cert: fs.readFileSync(p.cert),
caPath: p.caCert,
info: describe(),
};
}
export function describe() {
const p = paths();
const server = readCert(p.cert);
const ca = readCert(p.caCert);
return {
enabled: config.https.enabled,
server: server && {
validFrom: server.validFrom,
validTo: server.validTo,
daysLeft: daysUntil(server.validTo),
names: certSans(server),
fingerprint: server.fingerprint256,
},
ca: ca && {
validTo: ca.validTo,
daysLeft: daysUntil(ca.validTo),
fingerprint: ca.fingerprint256,
subject: ca.subject,
},
caPath: fs.existsSync(p.caCert) ? p.caCert : null,
};
}
export function caCertificate() {
const p = paths();
return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null;
}
/** Renewal is cheap, so check daily rather than only at boot. */
export function scheduleRenewal(onRenewed) {
setInterval(() => {
try {
const before = describe().server?.validTo;
ensureCertificates();
const after = describe().server?.validTo;
if (before !== after) onRenewed?.();
} catch (err) {
console.error('[tls] renewal check failed:', err.message);
}
}, 24 * 60 * 60 * 1000).unref();
}