Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user