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

This commit is contained in:
2026-08-31 14:33:30 +10:00
parent ed77493817
commit b23ad422d0
13 changed files with 843 additions and 119 deletions
+14 -1
View File
@@ -58,12 +58,25 @@ export const config = {
enabled: bool(process.env.HTTPS_ENABLED, false),
keyPath: process.env.HTTPS_KEY || path.join(dataDir, 'certs', 'server.key'),
certPath: process.env.HTTPS_CERT || path.join(dataDir, 'certs', 'server.crt'),
// Names and addresses staff will actually type. Baked into the certificate.
hostnames: (process.env.HTTPS_HOSTNAMES || 'visitors.local')
.split(',')
.map((h) => h.trim())
.filter(Boolean),
// A plain http listener that serves the CA certificate and redirects
// everything else to https. 0 turns it off.
redirectPort: int(process.env.HTTP_REDIRECT_PORT, 3001),
// The https port as published on the docker host, used when redirecting.
publicPort: int(process.env.HTTPS_PUBLIC_PORT, 8443),
},
sheets: {
enabled: bool(process.env.SHEETS_ENABLED, false),
spreadsheetId: process.env.SHEETS_SPREADSHEET_ID || '',
tabName: process.env.SHEETS_TAB_NAME || 'Visitor log',
// Append-only history of every sign in and sign out.
logTab: process.env.SHEETS_LOG_TAB || process.env.SHEETS_TAB_NAME || 'Visitor log',
// Rewritten on every change: just the people currently on site.
onSiteTab: process.env.SHEETS_ONSITE_TAB || 'On site now',
// Either a path to the service account JSON, or the JSON itself base64 encoded.
credentialsPath: process.env.GOOGLE_CREDENTIALS_PATH || '',
credentialsB64: process.env.GOOGLE_CREDENTIALS_B64 || '',
+44
View File
@@ -7,6 +7,7 @@ import config from '../config.js';
import { decryptPin, encryptPin, generatePin } from '../pins.js';
import { photoAbsolutePath, deletePhoto, purgeOldPhotos } from '../photos.js';
import * as sheets from '../sheets.js';
import * as tls from '../tls.js';
import * as users from '../users.js';
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
import {
@@ -915,10 +916,24 @@ router.get('/status', (req, res) => {
queued: sheets.queueDepth(),
lastOk: sheets.status.lastOk,
lastError: sheets.status.lastError,
logTab: config.sheets.logTab,
onSiteTab: config.sheets.onSiteTab,
lastOnSiteSync: sheets.status.lastOnSiteSync,
onSiteCount: sheets.status.onSiteCount,
onSiteError: sheets.status.onSiteError,
},
tls: tls.describe(),
});
});
router.post('/sheets/resync', async (req, res) => {
try {
res.json({ ok: true, ...(await sheets.syncOnSite()) });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.post('/sheets/test', async (req, res) => {
try {
res.json({ ok: true, ...(await sheets.testConnection()) });
@@ -935,6 +950,35 @@ router.post('/sheets/flush', async (req, res) => {
}
});
/* ---------------------------------------------------------------- tls */
router.get('/tls', (req, res) => {
res.json(tls.describe());
});
/** The CA certificate is public by design — it is what tablets need to trust. */
router.get('/tls/ca.crt', (req, res) => {
const ca = tls.caCertificate();
if (!ca) return res.status(404).send('No certificate authority has been generated.');
res.setHeader('Content-Type', 'application/x-x509-ca-cert');
res.setHeader('Content-Disposition', 'attachment; filename="visitor-signin-ca.crt"');
res.send(ca);
});
router.post('/tls/renew', requireOwner, (req, res) => {
try {
// A brand new CA means every kiosk device has to trust it again, so it is
// deliberately a separate, explicit choice.
const newCa = Boolean(req.body?.newCa);
tls.ensureCertificates({ force: newCa });
const reload = req.app.get('reloadTls');
const reloaded = reload ? reload() : false;
res.json({ ok: true, reloaded, newCa, info: tls.describe() });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.post('/photos/purge', (req, res) => {
res.json({ purged: purgeOldPhotos() });
});
+63 -17
View File
@@ -1,6 +1,5 @@
import express from 'express';
import session from 'express-session';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
@@ -11,6 +10,7 @@ import kioskRoutes from './routes/kiosk.js';
import adminRoutes from './routes/admin.js';
import * as sheets from './sheets.js';
import * as users from './users.js';
import * as tls from './tls.js';
import { purgeOldPhotos } from './photos.js';
import { localHm, nowIso } from './util.js';
@@ -84,28 +84,74 @@ if (config.autoSignOutTime) {
/* ------------------------------------------------------------- listen */
/**
* A plain http listener that does two jobs: hands out the CA certificate (so a new
* tablet can fetch it without first trusting the very certificate it is missing),
* and pushes everything else to https.
*/
function startRedirectServer() {
const port = config.https.redirectPort;
if (!port) return;
http
.createServer((req, res) => {
if (req.url === '/ca.crt' || req.url === '/ca.pem') {
const ca = tls.caCertificate();
if (!ca) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('No certificate authority has been generated yet.');
}
res.writeHead(200, {
'Content-Type': 'application/x-x509-ca-cert',
'Content-Disposition': 'attachment; filename="visitor-signin-ca.crt"',
});
return res.end(ca);
}
const host = String(req.headers.host || '').split(':')[0];
const target = `https://${host}:${config.https.publicPort}${req.url}`;
res.writeHead(302, { Location: target });
res.end(`Moved to ${target}`);
})
.listen(port, () => {
console.log(`[server] http helper on port ${port} — serves /ca.crt, redirects to https`);
});
}
function start() {
if (config.https.enabled) {
if (!fs.existsSync(config.https.keyPath) || !fs.existsSync(config.https.certPath)) {
console.error(
`[https] certificate not found at ${config.https.certPath}. Run scripts/gen-cert.sh first.`
);
process.exit(1);
}
https
.createServer(
{ key: fs.readFileSync(config.https.keyPath), cert: fs.readFileSync(config.https.certPath) },
app
)
.listen(config.port, () => {
console.log(`[server] ${config.siteName} listening on https://0.0.0.0:${config.port}`);
});
} else {
if (!config.https.enabled) {
http.createServer(app).listen(config.port, () => {
console.log(`[server] ${config.siteName} listening on http://0.0.0.0:${config.port}`);
console.log('[server] camera capture needs HTTPS or localhost — see README before rolling out');
});
return;
}
let material;
try {
material = tls.ensureCertificates();
} catch (err) {
console.error(`[tls] ${err.message}`);
process.exit(1);
}
let server = https.createServer({ key: material.key, cert: material.cert }, app);
server.listen(config.port, () => {
const names = material.info.server?.names?.join(', ') || 'this host';
console.log(`[server] ${config.siteName} listening on https://0.0.0.0:${config.port}`);
console.log(`[tls] certificate valid for ${names}`);
console.log(`[tls] expires ${material.info.server?.validTo} (${material.info.server?.daysLeft} days)`);
console.log('[tls] install the CA on each kiosk device — see README');
});
// Swap the certificate in without dropping the listener when it renews.
tls.scheduleRenewal(() => {
const fresh = tls.ensureCertificates();
server.setSecureContext({ key: fresh.key, cert: fresh.cert });
console.log('[tls] certificate renewed and reloaded without a restart');
});
startRedirectServer();
}
start();
+188 -23
View File
@@ -4,7 +4,16 @@ import config from './config.js';
import db from './db.js';
import { localStamp } from './util.js';
const HEADER = [
/**
* Two tabs, doing different jobs.
*
* "On site now" — rewritten whenever someone signs in or out. Only the people
* currently in the building. This is the evacuation list.
* "Visitor log" — appended to, never rewritten. Every sign in and sign out event,
* kept for the record.
*/
const LOG_HEADER = [
'Timestamp',
'Site',
'Action',
@@ -22,9 +31,33 @@ const HEADER = [
'Visit ID',
];
const ONSITE_HEADER = [
'Site',
'First name',
'Last name',
'Visiting',
'Phone',
'Email',
'Check',
'Signed in',
'On site for',
'Visit ID',
];
let client = null;
let headerChecked = false;
export const status = { configured: false, lastOk: null, lastError: null };
let tabsPromise = null;
let onSiteDirty = false;
let syncing = false;
export const status = {
lastOk: null,
lastError: null,
lastOnSiteSync: null,
onSiteCount: null,
onSiteError: null,
};
/* ----------------------------------------------------------- connection */
function loadCredentials() {
if (config.sheets.credentialsB64) {
@@ -52,25 +85,57 @@ export function isEnabled() {
return Boolean(config.sheets.enabled && config.sheets.spreadsheetId);
}
async function ensureHeader(sheets) {
if (headerChecked) return;
const range = `${config.sheets.tabName}!A1:O1`;
const res = await sheets.spreadsheets.values.get({
/**
* Creates either tab if it is missing, and writes the header row once.
* Memoised as a promise, not a boolean: a sign in kicks off the log append and the
* on-site rewrite at the same moment, and two concurrent checks would each decide
* the tabs were missing and try to create them twice.
*/
function ensureTabs(sheets, { force = false } = {}) {
if (force) tabsPromise = null;
if (!tabsPromise) {
tabsPromise = doEnsureTabs(sheets).catch((err) => {
tabsPromise = null; // let the next attempt retry rather than caching the failure
throw err;
});
}
return tabsPromise;
}
async function doEnsureTabs(sheets) {
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
const existing = meta.data.sheets.map((s) => s.properties.title);
const wanted = [config.sheets.logTab, config.sheets.onSiteTab];
const missing = wanted.filter((t) => !existing.includes(t));
if (missing.length) {
await sheets.spreadsheets.batchUpdate({
spreadsheetId: config.sheets.spreadsheetId,
requestBody: {
requests: missing.map((title) => ({ addSheet: { properties: { title } } })),
},
});
console.log(`[sheets] created tab(s): ${missing.join(', ')}`);
}
// Header on the log tab only. The on-site tab gets its header on every rewrite.
const range = `${config.sheets.logTab}!A1:O1`;
const head = await sheets.spreadsheets.values.get({
spreadsheetId: config.sheets.spreadsheetId,
range,
});
if (!res.data.values || res.data.values.length === 0) {
if (!head.data.values?.length) {
await sheets.spreadsheets.values.update({
spreadsheetId: config.sheets.spreadsheetId,
range,
valueInputOption: 'RAW',
requestBody: { values: [HEADER] },
requestBody: { values: [LOG_HEADER] },
});
}
headerChecked = true;
}
/** Builds the row that gets mirrored to the sheet for one sign in or sign out event. */
/* -------------------------------------------------------------- the log */
export function rowForVisit(visit, action) {
return [
localStamp(new Date().toISOString()),
@@ -91,12 +156,12 @@ export function rowForVisit(visit, action) {
];
}
async function append(row) {
async function appendLog(row) {
const sheets = getClient();
await ensureHeader(sheets);
await ensureTabs(sheets);
await sheets.spreadsheets.values.append({
spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.tabName}!A:O`,
range: `${config.sheets.logTab}!A:O`,
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
requestBody: { values: [row] },
@@ -107,20 +172,94 @@ function enqueue(row) {
db.prepare('INSERT INTO sheet_queue (payload) VALUES (?)').run(JSON.stringify(row));
}
/* --------------------------------------------------------- who's on site */
function humanDuration(fromIso) {
const minutes = Math.max(0, Math.round((Date.now() - new Date(fromIso).getTime()) / 60000));
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`;
}
function onSiteRows() {
return db
.prepare('SELECT * FROM visits WHERE signed_out_at IS NULL ORDER BY site_name, signed_in_at')
.all()
.map((v) => [
v.site_name || '',
v.first_name,
v.last_name,
v.host_name,
v.phone || '',
v.email || '',
v.check_type === 'NONE' ? 'None' : `${v.check_type} ${v.check_number || ''}`.trim(),
localStamp(v.signed_in_at),
humanDuration(v.signed_in_at),
String(v.id),
]);
}
/**
* Replaces the whole on-site tab with the current state. Rewriting rather than
* patching means a missed update never leaves a stale name on the evacuation list.
*/
export async function syncOnSite() {
if (!isEnabled()) return { skipped: true };
if (syncing) return { skipped: true };
syncing = true;
try {
const sheets = getClient();
await ensureTabs(sheets);
const rows = onSiteRows();
const banner = `On site now — ${rows.length} ${rows.length === 1 ? 'person' : 'people'} — updated ${localStamp(new Date().toISOString())}`;
await sheets.spreadsheets.values.clear({
spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.onSiteTab}!A1:J1000`,
});
await sheets.spreadsheets.values.update({
spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.onSiteTab}!A1`,
valueInputOption: 'RAW',
requestBody: { values: [[banner], ONSITE_HEADER, ...rows] },
});
onSiteDirty = false;
status.lastOnSiteSync = new Date().toISOString();
status.onSiteCount = rows.length;
status.lastOk = status.lastOnSiteSync;
status.lastError = null;
status.onSiteError = null;
return { rows: rows.length };
} catch (err) {
onSiteDirty = true;
status.lastError = err.message;
status.onSiteError = err.message;
throw err;
} finally {
syncing = false;
}
}
/* -------------------------------------------------------------- mirroring */
/** Fire and forget: never let a Sheets outage block someone at the front desk. */
export function mirror(visit, action) {
if (!isEnabled()) return;
const row = rowForVisit(visit, action);
append(row)
appendLog(rowForVisit(visit, action))
.then(() => {
status.lastOk = new Date().toISOString();
status.lastError = null;
})
.catch((err) => {
status.lastError = err.message;
console.error('[sheets] append failed, queued for retry:', err.message);
enqueue(row);
console.error('[sheets] log append failed, queued for retry:', err.message);
enqueue(rowForVisit(visit, action));
});
onSiteDirty = true;
syncOnSite().catch((err) => console.error('[sheets] on-site sync failed:', err.message));
}
export async function flushQueue() {
@@ -129,7 +268,7 @@ export async function flushQueue() {
let sent = 0;
for (const item of rows) {
try {
await append(JSON.parse(item.payload));
await appendLog(JSON.parse(item.payload));
db.prepare('DELETE FROM sheet_queue WHERE id = ?').run(item.id);
sent += 1;
status.lastOk = new Date().toISOString();
@@ -151,24 +290,50 @@ export async function testConnection() {
if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.');
const sheets = getClient();
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
await ensureHeader(sheets);
await ensureTabs(sheets, { force: true });
await syncOnSite();
status.lastOk = new Date().toISOString();
status.lastError = null;
return { title: meta.data.properties.title };
return {
title: meta.data.properties.title,
logTab: config.sheets.logTab,
onSiteTab: config.sheets.onSiteTab,
};
}
export function queueDepth() {
return db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n;
}
export function tabNames() {
return { log: config.sheets.logTab, onSite: config.sheets.onSiteTab };
}
export function startWorker() {
if (!isEnabled()) {
console.log('[sheets] mirroring disabled');
return;
}
status.configured = true;
console.log(
`[sheets] mirroring to ${config.sheets.spreadsheetId} ` +
`(log: "${config.sheets.logTab}", live: "${config.sheets.onSiteTab}")`
);
setInterval(() => {
flushQueue().catch((err) => console.error('[sheets] flush error:', err.message));
if (onSiteDirty) {
syncOnSite().catch((err) => console.error('[sheets] on-site retry failed:', err.message));
}
}, config.sheets.retryIntervalMs).unref();
console.log('[sheets] mirroring enabled ->', config.sheets.spreadsheetId);
// Refresh the "on site for" column so the live tab does not go stale while
// someone sits in a meeting all afternoon.
setInterval(() => {
syncOnSite().catch(() => {
/* the retry above will pick it up */
});
}, 15 * 60 * 1000).unref();
// Bring the live tab in line with the database at boot.
syncOnSite().catch((err) => console.error('[sheets] initial on-site sync failed:', err.message));
}
+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();
}