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'; import { createZip } from './zip.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)); } // A very common mistake is editing .env and then using `docker compose restart`, // which reuses the old environment. The symptom is a certificate covering only // the defaults, so say so rather than letting it fail later in a browser. const configured = config.https.hostnames; if (configured.length === 1 && configured[0] === 'visitors.local') { console.warn( '[tls] HTTPS_HOSTNAMES is at its default. If you set it in .env, bring the container\n' + ' back with "docker compose up -d" rather than "docker compose restart" — restart\n' + ' keeps the environment the container started with.' ); } 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, }; } /** * The CA in DER form. * * The .crt on disk is PEM: base64 text between BEGIN/END lines. Apple's tooling, * and therefore Jamf Pro's certificate payload, wants the raw binary DER instead * and rejects the file on its extension. Same certificate, different wrapper. */ export function caCertificateDer() { const p = paths(); if (!fs.existsSync(p.caCert)) return null; return openssl(['x509', '-in', p.caCert, '-outform', 'der']); } /** * Every form of the authority certificate in one archive, with instructions. * * Browsers increasingly refuse to download a bare .crt or .cer as a dangerous * file type, which leaves no way to get the certificate onto a device. A zip is * accepted, and carrying all the encodings means whichever tool is being fed — * Jamf, Windows, Android — has the one it wants. */ export function caBundleZip() { const p = paths(); if (!fs.existsSync(p.caCert)) return null; const pem = fs.readFileSync(p.caCert); const der = caCertificateDer(); const info = describe(); const readme = [ `${config.siteName} — certificate authority`, '='.repeat(60), '', 'Install ONE of these on each device. They are the same certificate in', 'different encodings; which one you need depends on the tool.', '', ' visitor-signin-ca.cer binary DER. Jamf Pro, Apple Configurator, iOS, macOS.', ' visitor-signin-ca.crt PEM text. Windows, Android, Chromebook, Linux.', ' visitor-signin-ca.pem identical to the .crt, for tools expecting .pem.', '', 'Fingerprint (SHA-256)', ` ${info.ca?.fingerprint || 'unknown'}`, '', 'Check this matches what the device shows before trusting it.', '', 'Valid until', ` ${info.ca?.validTo || 'unknown'}`, '', 'The server certificate currently covers', ` ${(info.server?.names || ['unknown']).join('\n ')}`, '', 'Installing', '----------', 'Jamf Pro Devices > Configuration Profiles > New > Certificate payload.', ' Upload the .cer, scope to the kiosk devices, save. A root', ' certificate delivered by MDM is trusted for TLS automatically.', '', 'Windows Double-click the .crt > Install Certificate > Local Machine >', ' Place all certificates in the following store > Trusted Root', ' Certification Authorities.', '', 'Android Settings > Security > Encryption & credentials > Install a', ' certificate > CA certificate, then pick the .crt. Chrome on', ' Android will not accept a certificate for a bare IP address,', ' so reach the kiosk by hostname.', '', 'Chromebook Settings > Privacy and security > Security > Manage', ' certificates > Authorities > Import, then pick the .crt.', '', 'iOS by hand Open the .crt in Safari, allow the profile, install it under', ' Settings > General > VPN & Device Management, THEN turn it on', ' under Settings > General > About > Certificate Trust Settings.', ' Both steps are needed when installing by hand.', '', 'Renewing the server certificate does not change this authority, so devices', 'only need this done once.', '', ].join('\n'); return createZip([ { name: 'visitor-signin-ca.cer', data: der }, { name: 'visitor-signin-ca.crt', data: pem }, { name: 'visitor-signin-ca.pem', data: pem }, { name: 'README.txt', data: readme }, ]); } 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(); }