Public Access
346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
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();
|
|
}
|