Public Access
434 lines
20 KiB
Markdown
434 lines
20 KiB
Markdown
# 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.
|
||
|
||
### Removing someone
|
||
|
||
**Remove** on the recurring visitors list deletes the saved record for good — the PIN stops
|
||
working, the stored photo is deleted, and their mobile number, email and PIN become available
|
||
for someone else.
|
||
|
||
Their **visit history is kept**. Visits store the name, contact details and host as their own
|
||
columns, so the log remains a complete record of who was in the building regardless of whether
|
||
the person is still on file. Removing someone who is currently signed in does not sign them out;
|
||
the confirmation says so, and their visit can still be closed with their last name and mobile
|
||
number at the kiosk.
|
||
|
||
If they might come back, untick **Active** in Edit instead. That keeps the record, the PIN and
|
||
the history, but stops the PIN working at the kiosk.
|
||
|
||
### 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)
|