From b23ad422d06a1efc45f5374d52d0a019c5fd9ed5 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 31 Aug 2026 14:33:30 +1000 Subject: [PATCH] Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA --- .env.example | 36 +++++-- Dockerfile | 2 +- README.md | 138 ++++++++++++++++++------ docker-compose.yml | 8 +- public/css/admin.css | 7 ++ public/js/admin.js | 94 +++++++++++++++- scripts/gen-cert.sh | 60 +++++------ scripts/make-cert.mjs | 22 ++++ src/config.js | 15 ++- src/routes/admin.js | 44 ++++++++ src/server.js | 80 +++++++++++--- src/sheets.js | 211 ++++++++++++++++++++++++++++++++---- src/tls.js | 245 ++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 843 insertions(+), 119 deletions(-) create mode 100644 scripts/make-cert.mjs create mode 100644 src/tls.js diff --git a/.env.example b/.env.example index 719c73d..1f50f58 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,6 @@ SITE_NAME=Hideaway Visitor Sign In TZ=Australia/Melbourne PORT=3000 -# Port published on the docker host. -HOST_PORT=8088 # Long random string. Generate one with: openssl rand -hex 32 # Changing this invalidates admin sessions AND makes stored visitor PINs unreadable. @@ -37,22 +35,42 @@ AUTO_SIGNOUT_TIME=18:30 EXPIRY_WARNING_DAYS=28 # ---------------------------------------------------------------- https -# The browser will not allow camera access over plain http unless the address is -# localhost. Either terminate TLS at a reverse proxy, or turn this on and run -# scripts/gen-cert.sh to create a self-signed certificate. -HTTPS_ENABLED=false +# Browsers block the camera on plain http unless the address is localhost, so the +# kiosk needs https. Leave this on and the container creates its own certificate +# authority and server certificate at first start, then renews the server +# certificate on its own before it lapses. +HTTPS_ENABLED=true +SECURE_COOKIES=true + +# Every name and address staff might type. These go into the certificate, so a +# missing one means a browser warning. Re-issues automatically when this changes. +HTTPS_HOSTNAMES=visitors.local,192.168.1.50 + +# Ports published on the docker host. +HOST_PORT=8443 +HOST_HTTP_PORT=8080 +# Must match HOST_PORT: used to build the http -> https redirect. +HTTPS_PUBLIC_PORT=8443 +# The in-container http helper. 0 turns it off. +HTTP_REDIRECT_PORT=3001 + +# Where the certificates live. Leave these alone unless you are supplying your own. HTTPS_KEY=/data/certs/server.key HTTPS_CERT=/data/certs/server.crt -# Set both of these to true when running behind an HTTPS reverse proxy. +# Set TRUST_PROXY=true instead if you terminate TLS at a reverse proxy and turn +# HTTPS_ENABLED off. TRUST_PROXY=false -SECURE_COOKIES=false # --------------------------------------------------------- google sheets SHEETS_ENABLED=false # The long id from the sheet URL: docs.google.com/spreadsheets/d//edit SHEETS_SPREADSHEET_ID= -SHEETS_TAB_NAME=Visitor log +# Append-only history of every sign in and sign out. +SHEETS_LOG_TAB=Visitor log +# Rewritten on every change: only the people currently on site. Open this one +# during an evacuation. Both tabs are created automatically if missing. +SHEETS_ONSITE_TAB=On site now # Point at the mounted service account json... GOOGLE_CREDENTIALS_PATH=/secrets/google-service-account.json # ...or paste it base64 encoded instead (base64 -w0 key.json). One or the other. diff --git a/Dockerfile b/Dockerfile index 156dc1e..2f751e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,7 @@ COPY scripts ./scripts RUN mkdir -p /data/photos && chown -R node:node /data /app USER node VOLUME ["/data"] -EXPOSE 3000 +EXPOSE 3000 3001 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD node scripts/healthcheck.mjs diff --git a/README.md b/README.md index 506d4b6..2238eee 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ $EDITOR .env docker compose up -d --build ``` -The kiosk is then on `http://:8088` and the admin console on -`http://:8088/admin`. +The kiosk is then on `https://:8443` and the admin console on +`https://:8443/admin`. Expect a browser warning until you install the +authority certificate — see below. Set `ADMIN_BOOTSTRAP_EMAIL` and `ADMIN_BOOTSTRAP_PASSWORD` in `.env` before the first start — they create the first admin account, once. You'll be asked to enrol two factor and set a real @@ -151,28 +152,74 @@ If every owner loses access, stop the container, clear the `admin_users` table w `sqlite3 data/visitors.db "DELETE FROM admin_users;"`, and start it again — the bootstrap account is recreated from `.env`. -## The camera needs HTTPS +## HTTPS and the certificate -Browsers refuse camera access on a plain `http://` address unless it is `localhost`. On an -internal IP the kiosk will show a message telling the visitor the camera is blocked. Pick one: +The kiosk needs HTTPS: browsers block camera access on plain http unless the address is +`localhost`. Since this never faces the internet, it runs its own certificate authority. -**Option A — reverse proxy (best if you already run one).** Terminate TLS at Nginx Proxy -Manager, Traefik, or Caddy and point it at the container. Then set `TRUST_PROXY=true` and -`SECURE_COOKIES=true` in `.env`. +With `HTTPS_ENABLED=true` (the default) the container creates two things at first start: -**Option B — self-signed certificate in the container.** +- **A certificate authority**, valid for ten years. Install this on each kiosk device, once. +- **A server certificate**, valid for about 13 months, signed by that authority. -```bash -./scripts/gen-cert.sh visitors.local 192.168.1.50 # your hostname, then any IPs -# set HTTPS_ENABLED=true in .env -docker compose restart +The server certificate renews itself before it lapses and reloads without a restart. Because +the authority is what the devices trust, renewal never means touching the tablets again. This is +why it isn't one plain self-signed certificate: Apple and Chrome reject server certificates +valid for much more than a year, so a single self-signed file would have to be reinstalled +everywhere every year. + +### Set it up + +List every name and address staff might type, in `.env`: + +``` +HTTPS_ENABLED=true +SECURE_COOKIES=true +HTTPS_HOSTNAMES=visitors.local,visitors.hideaway.lan,192.168.1.50 +HOST_PORT=8443 +HTTPS_PUBLIC_PORT=8443 ``` -Then install `data/certs/server.crt` as a trusted root certificate on each kiosk tablet, -otherwise the browser warning appears every morning. +An address that isn't listed produces a browser warning. Change the list and restart; the +certificate reissues itself automatically, and devices that already trust the authority accept +it without any further work. -**Option C — run the browser on the same machine as the container** and point it at -`http://localhost:8088`. No certificate needed. +The kiosk is then at `https://visitors.local:8443`, admin at `https://visitors.local:8443/admin`. + +### Trusting the authority on each device + +Port 8080 runs a small plain-http helper that does exactly two things: hands out the authority +certificate, and redirects everything else to https. It exists to solve the chicken-and-egg +problem of fetching the certificate you don't yet trust. + +On each tablet, browse to `http://visitors.local:8080/ca.crt` and install the downloaded file: + +| Device | Where | +|---|---| +| Windows | Double-click → Install Certificate → Local Machine → *Trusted Root Certification Authorities* | +| Android | Settings → Security → Encryption & credentials → Install a certificate → **CA certificate** | +| iPad / iPhone | Open in Safari → install the profile → then Settings → General → About → **Certificate Trust Settings** and switch it on. The second step is separate and easy to miss | +| macOS | Double-click → Keychain Access → System → set to *Always Trust* | +| Ubuntu | `sudo cp ca.crt /usr/local/share/ca-certificates/visitor-signin.crt && sudo update-ca-certificates` | + +Admins can also download it from **Admin → System → Certificate**, which shows the expiry dates +and the authority's fingerprint — check that fingerprint matches what the tablet shows during +installation. + +### Managing it later + +**Admin → System → Certificate** has *Renew the server certificate* (safe, no device changes) +and *Start a new authority* (every device must trust the new one, so only for a suspected key +leak). From a shell on the docker host, `./scripts/gen-cert.sh` and +`./scripts/gen-cert.sh --force` do the same two jobs. + +The private keys live in `data/certs/` with `0600` permissions. They are in `.gitignore` and +must never be committed. + +### If you'd rather use a reverse proxy + +Set `HTTPS_ENABLED=false` and `TRUST_PROXY=true`, terminate TLS at Nginx Proxy Manager, Traefik +or Caddy, and drop the port 8080 mapping from `docker-compose.yml`. ## Google Sheet mirroring @@ -193,18 +240,38 @@ database and retried every minute, so a dropped internet connection never blocks ``` SHEETS_ENABLED=true SHEETS_SPREADSHEET_ID=THIS_PART - SHEETS_TAB_NAME=Visitor log + SHEETS_LOG_TAB=Visitor log + SHEETS_ONSITE_TAB=On site now GOOGLE_CREDENTIALS_PATH=/secrets/google-service-account.json ``` If you would rather not mount a file, base64 the key instead — `base64 -w0 key.json` — and put the result in `GOOGLE_CREDENTIALS_B64`. -6. Restart, then **Admin → System → Test the sheet connection**. The header row is written - automatically the first time. +6. Restart, then **Admin → System → Test the sheet connection**. Both tabs and their headers + are created the first time. -A tab name with spaces is fine. The sheet is a mirror, not the source of truth — nothing reads -back from it. Every row carries the site name, so one sheet covers all sites; filter by the -**Site** column during an evacuation. +### Two tabs, two jobs + +The spreadsheet gets two tabs, both created automatically: + +**On site now** — rewritten every time anyone signs in or out, so it only ever lists the people +currently in the building. No filtering, no scrolling to the bottom. The top row shows a head +count and the time it was last updated, so you can tell at a glance whether it is live. This is +the tab to bookmark on the phones that matter and to open at the assembly point. + +**Visitor log** — append only. Every sign in and sign out, forever, with times in and out. +This is the record you go back through weeks later. + +Rename them with `SHEETS_LOG_TAB` and `SHEETS_ONSITE_TAB`. Names with spaces are fine. + +The live tab is rebuilt from the database rather than edited row by row, so it is self-healing: +if a write fails, the next one puts everything right. It also refreshes every 15 minutes on its +own to keep the *On site for* column honest, and rebuilds at startup in case anyone signed out +while the container was down. **Admin → System → Rebuild the live list** forces it. + +Anything you type into these tabs by hand will be overwritten. The sheet is a mirror, not the +source of truth — nothing is ever read back from it. Every row carries the site name, so one +spreadsheet covers every site. ## Recurring visitors and PINs @@ -225,6 +292,7 @@ Everything is under `./data` on the docker host: data/ ├── visitors.db SQLite: sites, visits, recurring visitors, hosts, admins, retry queue ├── visitors.db-wal write-ahead log — back this up alongside the .db +├── certs/ the local authority and the server certificate (keys are 0600) └── photos/2026/08/ JPEGs, foldered by year and month ``` @@ -241,7 +309,9 @@ To back up: `docker compose stop && tar czf visitor-backup-$(date +%F).tar.gz da | `REQUIRE_PHOTO` | `false` lets a visitor continue if the camera fails | | `AUTO_SIGNOUT_TIME` | e.g. `18:30` — closes off anyone still shown as on site. Blank to disable | | `PHOTO_RETENTION_DAYS` | `0` keeps photos forever | -| `HOST_PORT` | port published on the docker host, default `8088` | +| `HOST_PORT` | https port on the docker host, default `8443` | +| `HOST_HTTP_PORT` | http helper port, default `8080` — serves the CA and redirects | +| `HTTPS_HOSTNAMES` | every name and IP the certificate should cover | | `EXPIRY_WARNING_DAYS` | how far ahead to warn about a WWCC or VIT, default `28` | | `ADMIN_REQUIRE_2FA` | `false` makes two factor optional per admin | | `ADMIN_ALLOWED_DOMAINS` | comma separated; blank allows any email domain | @@ -253,17 +323,25 @@ sees the last one's details. ```bash npm install -DATA_DIR=./data APP_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=secret npm start +DATA_DIR=./data APP_SECRET=$(openssl rand -hex 32) \ + ADMIN_BOOTSTRAP_EMAIL=you@example.com ADMIN_BOOTSTRAP_PASSWORD=ChangeMe12345 npm start ``` -Node 20 or newer. +Node 20 or newer, and `openssl` on PATH if you want the container to issue its own certificate. ## A note on evacuation use -The Google Sheet is the offsite copy, but it only helps if someone can open it on a phone during -an evacuation. Bookmark it on the relevant phones, check it after setup, and check it again -occasionally — a service account key that has been revoked will queue rows silently until -someone looks at **Admin → System**. +The **On site now** tab is the offsite copy, and it only helps if someone can open it on a phone +while standing in a car park. Bookmark it on the relevant phones and check it actually loads for +them, not just for you. + +Check it again occasionally. A revoked service account key, or a sheet whose sharing was changed, +will queue rows silently — the head count and *Updated* time in the first row are the giveaway, +and **Admin → System** shows the last successful write and any error. + +Worth deciding now: the kiosk is on your internal network, so if the network or the container is +down, the sheet stops updating while people keep walking in. `AUTO_SIGNOUT_TIME` limits how stale +the list can get overnight, but a printed fallback at the front desk is still worth having. --- diff --git a/docker-compose.yml b/docker-compose.yml index 6c580a9..90957f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,9 +7,13 @@ services: env_file: - .env ports: - - "${HOST_PORT:-8088}:3000" + # The kiosk and admin console, over https. + - "${HOST_PORT:-8443}:3000" + # Plain http helper: serves /ca.crt and redirects everything else to https. + # Drop this line if you are not using the built-in certificate. + - "${HOST_HTTP_PORT:-8080}:3001" volumes: - # Database, visitor photos and (optionally) TLS certs live here. + # Database, visitor photos and the certificates live here. - ./data:/data # Google service account key, if you mount it as a file rather than base64 in .env. - ./secrets:/secrets:ro diff --git a/public/css/admin.css b/public/css/admin.css index 6d671f0..f02e5db 100644 --- a/public/css/admin.css +++ b/public/css/admin.css @@ -373,3 +373,10 @@ tr.row-bad td { background: #fdf0f2; } .modal-section { margin: 20px 0 12px; font-size: 15px; } .pin-reveal.small { font-size: 24px; letter-spacing: 0.06em; word-break: break-all; } #modal img { display: block; margin: 0 auto 12px; border: 1px solid var(--rule); } + +.fingerprint { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 12px; + word-break: break-all; +} +#system-body h3 { margin-bottom: 14px; } diff --git a/public/js/admin.js b/public/js/admin.js index fa0dd73..224e5ba 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -833,13 +833,21 @@ async function loadSystem() { ? `Connected. ${s.sheets.queued} row(s) waiting to send.${s.sheets.lastError ? ` Last error: ${esc(s.sheets.lastError)}` : ''}` : 'Turned off in the environment file.' } -
Last sheet write
${stamp(s.sheets.lastOk)}
+
History tab
${esc(s.sheets.logTab)} — last written ${stamp(s.sheets.lastOk)}
+
Live tab
${esc(s.sheets.onSiteTab)} — ${ + s.sheets.onSiteCount === null ? 'not synced yet' : `${s.sheets.onSiteCount} on site` + }, last synced ${stamp(s.sheets.lastOnSiteSync)}${ + s.sheets.onSiteError ? ` ${esc(s.sheets.onSiteError)}` : '' + }
+ -
`; + +

Certificate

+ ${renderTls(s.tls)}`; $('#sheet-test').addEventListener('click', async () => { try { @@ -850,6 +858,15 @@ async function loadSystem() { toast(err.message, true); } }); + $('#sheet-resync').addEventListener('click', async () => { + try { + const r = await api('/sheets/sync', { method: 'POST' }); + toast(r.skipped ? 'Sheet mirroring is off.' : `Live tab rewritten with ${r.rows} on site.`); + loadSystem(); + } catch (err) { + toast(err.message, true); + } + }); $('#sheet-flush').addEventListener('click', async () => { try { const r = await api('/sheets/flush', { method: 'POST' }); @@ -859,6 +876,46 @@ async function loadSystem() { toast(err.message, true); } }); + $('#sheet-resync').addEventListener('click', async () => { + try { + const r = await api('/sheets/resync', { method: 'POST' }); + toast(`Live list rebuilt with ${r.rows} ${r.rows === 1 ? 'person' : 'people'}.`); + loadSystem(); + } catch (err) { + toast(err.message, true); + } + }); + + $('#renew-cert')?.addEventListener('click', async () => { + try { + const r = await api('/tls/renew', { method: 'POST', body: { newCa: false } }); + toast( + r.info.server + ? `Certificate good until ${new Date(r.info.server.validTo).toLocaleDateString('en-AU')}.` + : 'Certificate checked.' + ); + loadSystem(); + } catch (err) { + toast(err.message, true); + } + }); + + $('#new-ca')?.addEventListener('click', async () => { + const warning = + 'Create a brand new certificate authority?' + + '\n\n' + + 'Every kiosk device will show a warning until you install the new CA file on it. ' + + 'Only do this if the old key may have leaked.'; + if (!confirm(warning)) return; + try { + await api('/tls/renew', { method: 'POST', body: { newCa: true } }); + toast('New authority created. Install it on every kiosk device.'); + loadSystem(); + } catch (err) { + toast(err.message, true); + } + }); + $('#photo-purge').addEventListener('click', async () => { if (!confirm('Delete photos older than the retention window? This cannot be undone.')) return; const r = await api('/photos/purge', { method: 'POST' }); @@ -866,6 +923,39 @@ async function loadSystem() { }); renderAccount(s); + $$('.owner-only').forEach((el) => { + el.hidden = me.role !== 'owner'; + }); +} + +function renderTls(tls) { + if (!tls?.enabled) { + return `

HTTPS is off, so the kiosk camera will only work on localhost. + Set HTTPS_ENABLED=true in the environment file and restart.

`; + } + if (!tls.server) { + return '

HTTPS is on but no certificate could be read.

'; + } + const soon = tls.server.daysLeft < 30; + return ` +
+
Server certificate
+
Valid until ${new Date(tls.server.validTo).toLocaleDateString('en-AU')} + ${tls.server.daysLeft} days
+
Valid for
${esc(tls.server.names.join(', '))}
+
Authority expires
+
${tls.ca ? new Date(tls.ca.validTo).toLocaleDateString('en-AU') : '—'} + ${tls.ca ? `${tls.ca.daysLeft} days` : ''}
+
CA fingerprint
${esc(tls.ca?.fingerprint || '—')}
+
+

Install the CA file on each kiosk device once. The server certificate renews + itself before it lapses, and devices that trust the authority keep working without being + touched again.

+
+ Download the CA certificate + + +
`; } function renderAccount(status) { diff --git a/scripts/gen-cert.sh b/scripts/gen-cert.sh index 9dfdc5e..778dc01 100644 --- a/scripts/gen-cert.sh +++ b/scripts/gen-cert.sh @@ -1,40 +1,32 @@ #!/usr/bin/env bash -# Creates a self-signed certificate so the kiosk can use the camera over https. -# Give it the address staff will actually type, e.g. ./gen-cert.sh visitors.local 192.168.1.50 +# Creates the kiosk certificates: a long lived local authority, and a server +# certificate signed by it. Install the authority on each kiosk device once. +# +# ./scripts/gen-cert.sh use HTTPS_HOSTNAMES from .env +# ./scripts/gen-cert.sh visitors.local 10.0.0.5 override the names +# ./scripts/gen-cert.sh --force replace the authority too +# +# The server normally does this by itself on start, so you only need this to +# change the address list or to inspect the result before going live. set -euo pipefail +cd "$(dirname "$0")/.." -OUT_DIR="${OUT_DIR:-./data/certs}" -PRIMARY="${1:-visitors.local}" -shift || true - -mkdir -p "$OUT_DIR" - -ALT="DNS:${PRIMARY}" -INDEX=1 -for extra in "$@"; do - if [[ "$extra" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - ALT="${ALT},IP:${extra}" - else - ALT="${ALT},DNS:${extra}" - fi - INDEX=$((INDEX + 1)) +FORCE="" +NAMES=() +for arg in "$@"; do + if [ "$arg" = "--force" ]; then FORCE="--force"; else NAMES+=("$arg"); fi done -ALT="${ALT},DNS:localhost,IP:127.0.0.1" -openssl req -x509 -nodes -newkey rsa:2048 -days 1095 \ - -keyout "${OUT_DIR}/server.key" \ - -out "${OUT_DIR}/server.crt" \ - -subj "/C=AU/ST=Victoria/L=Melbourne/O=Visitor Sign In/CN=${PRIMARY}" \ - -addext "subjectAltName=${ALT}" \ - -addext "basicConstraints=CA:FALSE" \ - -addext "keyUsage=digitalSignature,keyEncipherment" \ - -addext "extendedKeyUsage=serverAuth" +if [ ${#NAMES[@]} -gt 0 ]; then + HTTPS_HOSTNAMES="$(IFS=,; echo "${NAMES[*]}")" + export HTTPS_HOSTNAMES + echo "Using names: ${HTTPS_HOSTNAMES}" +fi -chmod 600 "${OUT_DIR}/server.key" - -echo -echo "Certificate written to ${OUT_DIR}" -echo "Names covered: ${ALT}" -echo -echo "Next: set HTTPS_ENABLED=true in .env, then restart the container." -echo "Install ${OUT_DIR}/server.crt as a trusted root on each kiosk device to stop the warning." +if docker compose ps --status running 2>/dev/null | grep -q visitor-signin; then + docker compose exec -T visitor-signin node scripts/make-cert.mjs $FORCE + echo "Restarting so the new certificate is served..." + docker compose restart visitor-signin +else + node scripts/make-cert.mjs $FORCE +fi diff --git a/scripts/make-cert.mjs b/scripts/make-cert.mjs new file mode 100644 index 0000000..8455624 --- /dev/null +++ b/scripts/make-cert.mjs @@ -0,0 +1,22 @@ +// Creates or renews the kiosk certificates without starting the server. +// node scripts/make-cert.mjs renew the server certificate if needed +// node scripts/make-cert.mjs --force new certificate authority as well +import { ensureCertificates, describe } from '../src/tls.js'; + +const force = process.argv.includes('--force'); + +try { + ensureCertificates({ force }); + const info = describe(); + console.log(''); + console.log('Certificate authority :', info.caPath); + console.log(' fingerprint :', info.ca?.fingerprint); + console.log(' expires :', info.ca?.validTo, `(${info.ca?.daysLeft} days)`); + console.log('Server certificate :', info.server?.validTo, `(${info.server?.daysLeft} days)`); + console.log(' valid for :', (info.server?.names || []).join(', ')); + console.log(''); + console.log('Install the authority certificate on each kiosk device, then restart the container.'); +} catch (err) { + console.error('Could not create certificates:', err.message); + process.exit(1); +} diff --git a/src/config.js b/src/config.js index 48fb91a..f029dd2 100644 --- a/src/config.js +++ b/src/config.js @@ -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 || '', diff --git a/src/routes/admin.js b/src/routes/admin.js index bb05305..308b5ad 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -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() }); }); diff --git a/src/server.js b/src/server.js index aa0a2f1..b9a85ec 100644 --- a/src/server.js +++ b/src/server.js @@ -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(); diff --git a/src/sheets.js b/src/sheets.js index 5217224..3ef5ac3 100644 --- a/src/sheets.js +++ b/src/sheets.js @@ -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)); } diff --git a/src/tls.js b/src/tls.js new file mode 100644 index 0000000..4d93e4c --- /dev/null +++ b/src/tls.js @@ -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(); +}