Files
visitor-signin/README.md
T

419 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Visitor sign in
A self-hosted visitor kiosk for sites that need a WWCC/VIT record and a photo at the door.
Runs in one Docker container, stores everything locally in SQLite, and mirrors every sign in
and sign out to a Google Sheet so someone outside the building can see who is on site during
an evacuation.
- **Kiosk** at `/` — a stepped sign in, a PIN sign in for regulars, and sign out. No link to
the admin console: a kiosk is a public terminal and administration does not belong on it.
Reach the console from a staff machine at `/admin`.
- **Admin** at `/admin` — who's on site now, the visit log, recurring visitors, the people a
visitor can ask for, sites, admin accounts, and system status.
Handles several sites from one container, prints a badge after sign in if you want one,
warns admins before a WWCC or VIT lapses, and gives each admin their own account with
two factor.
## What it collects
| | Guest sign in | Recurring visitor |
|---|---|---|
| First and last name | typed each visit | on file |
| Person being visited | picked from the list | picked each visit |
| Photo | taken at the kiosk | on file if saved, otherwise taken at the kiosk |
| WWCC / VIT / none | typed each visit | on file |
| Mobile and/or email | at least one required | on file |
Sign out only needs a **last name** plus a **mobile number or email**, which works for both.
## Quick start
```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git
cd visitor-signin
cp .env.example .env
# Generate a secret and set a real admin password before you start.
openssl rand -hex 32 # paste into APP_SECRET
$EDITOR .env
docker compose up -d --build
```
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
password at first sign in.
Then, in the admin console:
1. **Sites** → rename the first site, add more if you have them, and turn badge printing on.
2. **People to visit** → pick a site, then paste or upload your staff CSV
(see `docs/hosts-sample.csv`).
3. **Recurring visitors** → add anyone who comes regularly. A PIN is generated and a printable
card opens straight away.
4. **Admins** → invite the rest of the front office.
## Working from Windows, deploying to Ubuntu
Develop on Windows, run the container on the Ubuntu host. Two scripts are included:
```powershell
# in PowerShell, inside the visitor-signin folder
.\push-to-gitea.ps1
```
Or double-click `push-to-gitea.bat`. If PowerShell blocks the script, run
`powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1`. On the Ubuntu host,
`./push-to-gitea.sh` does the same thing.
`.gitattributes` forces LF endings for everything except the `.ps1` and `.bat` helpers, so the
shell scripts and the Dockerfile still work after a round trip through a Windows checkout —
otherwise `gen-cert.sh` fails on the host with a confusing `\r: command not found`.
Deploy on the Ubuntu host with:
```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git
cd visitor-signin && cp .env.example .env && nano .env
docker compose up -d --build
```
`docker compose` on Windows works too if you have Docker Desktop, but the camera, printing and
the `data/` permissions all behave more predictably on the Ubuntu host.
## Several sites, one container
Each site has its own name, its own list of people to visit, and its own badge settings.
Recurring visitors are either tied to one site or welcome at all of them.
Point a kiosk at a site once, during setup, with `http://host:8088/?site=front-gate`. The tablet
remembers the choice, so the address bar no longer matters. If you skip that, the kiosk asks
which site it is on first use — and with only one site set up, it never asks at all.
Sign in, sign out and the "already signed in" check are all scoped to the kiosk's site, so the
same person can be signed in at two sites at once without the system arguing about it.
An admin account can be limited to a single site. Those admins see only that site's visitors,
staff list and log, and cannot touch the others.
## Badge printing
Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge
into a hidden frame and prints it — one label, no dialog on most kiosk setups. The badge shows
the site, the visitor's name, who they are visiting, the time in, their WWCC/VIT number or a
boxed **No WWCC / VIT**, the photo if you want it, and an optional line of your own text.
The photo is square, matching the crop taken at the kiosk, and sits vertically centred.
Pick your stock from the **Label stock** list and the dimensions fill themselves in. A label
noticeably taller than it is wide gets a stacked layout — photo on top, name beneath — which is
what you want on a roll printer. Wider stock gets the photo alongside the text instead. Type
scales with the constraining dimension, so small labels stay readable.
Admins can reprint from the **On site** list, and the visitor gets a "Print the badge again"
button on the confirmation screen if the first one jams.
### Brother QL-820NWB
The default for a new site is 62 × 100 mm, which matches the DK-11202 die-cut label. For a
continuous roll, 62 × 90 mm is a good visitor badge.
It takes media 12 to 62 mm wide and prints up to 60.96 mm across at 300 × 300 dpi, so 62 mm is
the widest roll it will accept — the console warns you if you enter anything wider. Useful rolls:
| Roll | Size | Good for |
|---|---|---|
| DK-22205 | 62 mm continuous | The default. Cut to any length; 90 mm suits a visitor badge |
| DK-11202 | 62 × 100 mm die-cut | Pre-cut, no length to choose |
| DK-22251 | 62 mm continuous, black/red | Same as DK-22205 but supports the red option below |
| DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off |
| DK-11209 | 29 × 62 mm die-cut | Name and host only |
**The red option.** Tick *Print the heading and the no-check warning in red* and the site name
and the **No WWCC / VIT** box print red instead of black, which makes a visitor without a check
obvious across a room. It only works on a DK-22251 roll — on any other roll the printer renders
it as grey. Two-colour printing is also far slower than black alone (Brother rate it at roughly
15 labels a minute against 110), which is irrelevant for one badge at a time but worth knowing.
**Driver setup on the kiosk.** Set the QL-820NWB as the default printer for the browser running
the kiosk, choose the matching paper size in the driver, and set margins to none and scaling to
100%. Then use **Preview badge** in the console and print one before committing a roll — the
preview renders at the exact millimetre size the printer will receive.
The QL-820NWB has Ethernet and Wi-Fi, so it does not need to hang off the kiosk tablet. Install
it as a network printer on whichever device drives the kiosk browser.
## WWCC and VIT expiry warnings
Give a recurring visitor an expiry date and the console watches it. Inside
`EXPIRY_WARNING_DAYS` (28 by default) their row turns amber; past the date it turns red, a count
appears on the **Recurring visitors** tab, and a banner sits across the top of every screen.
Nothing is blocked automatically — an expired check is a conversation to have at the desk, not
a door the software should slam. Site-scoped admins only see warnings for their own site.
## Admin accounts and two factor
Each admin signs in with their own email address and password.
- **Roles.** *Owner* manages admins and sites. *Admin* handles day to day work, optionally
limited to one site.
- **Two factor.** With `ADMIN_REQUIRE_2FA=true` (the default) every admin enrols an
authenticator app at first sign in — a QR code appears, they scan it, and eight one-shot
recovery codes are issued. Standard TOTP, so Google Authenticator, Authy, 1Password,
Bitwarden and the rest all work.
- **Domain limits.** Set `ADMIN_ALLOWED_DOMAINS=yourschool.vic.edu.au` and both invitations and
sign in refuse anything else. Subdomains of a listed domain are accepted.
- **Recovery.** An owner can reset another admin's password (a temporary one is shown on
screen, and they must change it at next sign in) or clear their two factor so they can
re-enrol on a new phone.
- **Lockout.** Six wrong passwords locks that email address for 15 minutes.
If every owner loses access, stop the container, clear the `admin_users` table with
`sqlite3 data/visitors.db "DELETE FROM admin_users;"`, and start it again — the bootstrap
account is recreated from `.env`.
## HTTPS and the certificate
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.
With `HTTPS_ENABLED=true` (the default) the container creates two things at first start:
- **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.
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
```
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.
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
Every sign in and sign out appends a row. If Google is unreachable the row is queued in the
database and retried every minute, so a dropped internet connection never blocks the front desk.
1. In [Google Cloud Console](https://console.cloud.google.com/), create a project and enable
the **Google Sheets API**.
2. Create a **service account**, then create a **JSON key** for it and download the file.
3. Create the spreadsheet you want to use. **Share it with the service account's email address**
(it ends in `.iam.gserviceaccount.com`) with **Editor** access. This step is the one people
forget — without it every write returns a permission error.
4. Copy the spreadsheet id out of the URL:
`docs.google.com/spreadsheets/d/`**`THIS_PART`**`/edit`.
5. Put the key file at `./secrets/google-service-account.json` (compose mounts `./secrets`
read-only), then in `.env`:
```
SHEETS_ENABLED=true
SHEETS_SPREADSHEET_ID=THIS_PART
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**. Both tabs and their headers
are created the first time.
### 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
Each saved person is one record: **mobile number, email address and PIN are all unique**, checked
when a record is added or edited and enforced by the database. If an existing database already
contains duplicates, the startup log names who collides and the checks stay at the application
level until you fix them.
### A photo on file
Give a recurring visitor a photo in the admin console — from the machine's camera or an uploaded
file — and the kiosk stops asking them to pose. They enter their PIN, pick who they are visiting,
and the sign in completes with their pass printing immediately.
The stored photo is *copied* onto each visit rather than referenced, so the visit log stays a
true snapshot: replacing someone's photo later does not change what past visits show, and photo
retention cleaning up old visits can never delete a live profile photo.
Leave the photo blank and they are asked at the kiosk as before.
### PINs
The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with
`APP_SECRET` rather than hashed, so an admin can reprint a lost card without resetting it. Four
digits is only 10,000 combinations, so hashing would add nothing against anyone holding a copy
of the database — the real protection is the lockout: five wrong PINs on a number locks it for
15 minutes.
Four digits gives 10,000 combinations and each must be unique, so that is the ceiling on
simultaneous recurring visitors. Deactivating someone frees theirs.
**Changing `APP_SECRET` makes every stored PIN unreadable.** If you have to change it, reissue
PINs from the admin console afterwards.
## Where the data lives
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
```
Photos never leave the host. The sheet records only whether a photo exists. They are deleted
automatically after `PHOTO_RETENTION_DAYS` (90 by default), and only the admin console can view
them.
To back up: `docker compose stop && tar czf visitor-backup-$(date +%F).tar.gz data/ && docker compose start`.
## Settings worth knowing
| Variable | Does what |
|---|---|
| `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` | 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 |
The kiosk returns to the home screen after two minutes of inactivity so the next visitor never
sees the last one's details.
## Troubleshooting
**`pull access denied for visitor-signin`** — something ran `docker compose pull`. The image is
built here, not fetched from a registry. Use `docker compose up -d --build`. The compose file
sets `pull_policy: build` so this should not come back.
**`EACCES: permission denied, mkdir '/data/photos'`** — the bind-mounted `./data` on the host is
owned by root, and the app runs as an unprivileged user. The container's entrypoint fixes this
itself on start; if you are on an older build, do it by hand:
```bash
sudo chown -R 1000:1000 data secrets
docker compose up -d --build
```
**Changes to the code do nothing** — Compose reuses the existing image. Always
`docker compose up -d --build` after a `git pull`.
**Browser still warns about the certificate** — the authority is installed but not trusted. On
iOS that is a second, separate step under Settings → General → About → Certificate Trust
Settings. On Android, use a hostname rather than a bare IP.
## Running without Docker
```bash
npm install
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, and `openssl` on PATH if you want the container to issue its own certificate.
## A note on evacuation use
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.
---
Created by: Jess Rogerson (yelling commands at Claude.AI)