Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
+27
-9
@@ -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/<THIS PART>/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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -39,8 +39,9 @@ $EDITOR .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The kiosk is then on `http://<docker-host>:8088` and the admin console on
|
||||
`http://<docker-host>:8088/admin`.
|
||||
The kiosk is then on `https://<docker-host>:8443` and the admin console on
|
||||
`https://<docker-host>: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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+92
-2
@@ -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.'
|
||||
}</dd>
|
||||
<dt>Last sheet write</dt><dd>${stamp(s.sheets.lastOk)}</dd>
|
||||
<dt>History tab</dt><dd>${esc(s.sheets.logTab)} — last written ${stamp(s.sheets.lastOk)}</dd>
|
||||
<dt>Live tab</dt><dd>${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 ? ` <span class="pill bad">${esc(s.sheets.onSiteError)}</span>` : ''
|
||||
}</dd>
|
||||
</dl>
|
||||
<div class="sys-actions">
|
||||
<button class="ghost" id="sheet-test">Test the sheet connection</button>
|
||||
<button class="ghost" id="sheet-flush">Send queued rows now</button>
|
||||
<button class="ghost" id="sheet-resync">Rebuild the live list</button>
|
||||
<button class="ghost danger" id="photo-purge">Purge photos past retention</button>
|
||||
</div>`;
|
||||
</div>
|
||||
<h3 class="section-gap">Certificate</h3>
|
||||
${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 `<p class="notice">HTTPS is off, so the kiosk camera will only work on localhost.
|
||||
Set <code>HTTPS_ENABLED=true</code> in the environment file and restart.</p>`;
|
||||
}
|
||||
if (!tls.server) {
|
||||
return '<p class="notice">HTTPS is on but no certificate could be read.</p>';
|
||||
}
|
||||
const soon = tls.server.daysLeft < 30;
|
||||
return `
|
||||
<dl>
|
||||
<dt>Server certificate</dt>
|
||||
<dd>Valid until ${new Date(tls.server.validTo).toLocaleDateString('en-AU')}
|
||||
<span class="pill ${soon ? 'warn' : ''}">${tls.server.daysLeft} days</span></dd>
|
||||
<dt>Valid for</dt><dd>${esc(tls.server.names.join(', '))}</dd>
|
||||
<dt>Authority expires</dt>
|
||||
<dd>${tls.ca ? new Date(tls.ca.validTo).toLocaleDateString('en-AU') : '—'}
|
||||
${tls.ca ? `<span class="pill">${tls.ca.daysLeft} days</span>` : ''}</dd>
|
||||
<dt>CA fingerprint</dt><dd class="fingerprint">${esc(tls.ca?.fingerprint || '—')}</dd>
|
||||
</dl>
|
||||
<p class="hint">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.</p>
|
||||
<div class="sys-actions">
|
||||
<a class="ghost" href="/admin/api/tls/ca.crt" download>Download the CA certificate</a>
|
||||
<button class="ghost owner-only" id="renew-cert">Renew the server certificate</button>
|
||||
<button class="ghost danger owner-only" id="new-ca">Start a new authority</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderAccount(status) {
|
||||
|
||||
+26
-34
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+14
-1
@@ -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 || '',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user