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

This commit is contained in:
2026-08-31 14:33:30 +10:00
parent ed77493817
commit b23ad422d0
13 changed files with 843 additions and 119 deletions
+108 -30
View File
@@ -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.
---