14 KiB
Visitor sign-in kiosk: build, HTTPS, and server-side label printing
Date: 2026-09-23 Project: visitor-signin Status: ongoing Tags: node, express, sqlite, docker, kiosk, tls, jamf, brother-ql, ql-820nwb, label-printing, google-sheets, multi-site, 2fa, dithering
Context
Built an internally hosted visitor sign-in/out kiosk for school campuses from scratch: guest and recurring-visitor sign in with photo capture and WWCC/VIT recording, an admin console, and a Google Sheet mirror for evacuation use. The session then iterated through multi-site support, per-admin accounts with 2FA, self-signed HTTPS for iPad kiosks deployed via Jamf Pro, and moving badge printing off the iPad onto the server, driving a Brother QL-820NWB directly. It closed on getting two-colour (DK-22251) printing accepted by the printer and on photo halftoning.
Key Decisions
- Node/Express + SQLite + vanilla JS in one container, no front-end build step. Simple to run on a single docker host; no database server to manage. Rejected a React/SPA build as unnecessary weight for a kiosk.
- Recurring-visitor PINs are encrypted (AES-256-GCM keyed from
APP_SECRET), not hashed. A 4-digit PIN has 10,000 values, so hashing gives no protection against someone holding the DB file; encryption lets admins reprint a lost card. Real protection is the lockout (5 wrong PINs = 15 min). Consequence: changingAPP_SECRETmakes every PIN unreadable. - Phone, email and PIN must each be unique. PINs use a keyed HMAC column (
pin_lookup) because the ciphertext has a random IV and cannot be compared. Enforced in app code and by partial unique indexes; migration backfillspin_lookupand names existing duplicates rather than failing startup. - A stored recurring-visitor photo is copied onto each visit, not referenced. Keeps the visit log a true snapshot, and stops photo-retention purging from deleting a live profile photo.
- "Remove" on a recurring visitor is a hard delete that keeps visit history. Visits denormalise name/contact/host, so history survives; identifiers are freed for reuse. "Active" untick is offered as the reversible alternative.
- TLS: a 10-year local CA plus a 398-day server certificate, auto-renewed. Apple/Chrome reject
server certs over ~398 days, so a single self-signed cert would need reinstalling on every
tablet yearly. The server cert renews inside 30 days and hot-swaps via
setSecureContext; the CA never changes, so devices are configured once. Rejected: single long-lived self-signed cert; reverse proxy (still supported as an option). - Cert change detection tracks configured names via a sidecar file, not the full SAN list. The container's docker bridge IP is included automatically and changes on restart; comparing full SANs would reissue the cert on every boot.
- Plain-HTTP helper port serves the CA (
/ca.crt,/ca.cer,/ca.zip) and redirects the rest to HTTPS. Solves the chicken-and-egg of downloading a cert you don't yet trust. - CA offered as DER
.cerand as a zip bundle. Jamf's certificate payload rejects PEM.crton extension; browsers block bare.crt/.cerdownloads. The zip (all encodings + README with fingerprint and install steps) is written by an in-repo ZIP writer rather than a dependency, validated against Python'szipfile. - Google Sheet holds only who is currently on site. One tab, rewritten in full on every sign in/out (never patched, so it can't hold stale names). The earlier append-only history tab was dropped at the user's request; full history stays in the app (Visit log + CSV). Keeps visitor contact/movement history off the cloud.
- Server-side printing: render the badge to a bitmap with
@napi-rs/canvas, send viabrother_qlover TCP 9100. Takes the iPad out of the print path (it was mangling label format). Chosebrother_qlover hand-rolling Brother's raster protocol for hardware reliability. Sign-in awaits the print briefly; on failure the kiosk falls back to browser print. - Roll type is an explicit per-site setting (
printer_label), decoupled from the red styling option. Originally the media id was inferred from the "print in red" checkbox, which caused unexplainable "Wrong Roll Type" refusals. See Technical Details for the--redfinding. - Photo halftoning happens in the renderer (Floyd–Steinberg), not in the printer driver. The
printer is 1-bit;
brother_ql's default threshold turned faces into solid blocks. Doing it server-side makes the preview and the printed label the same image. - Admin login is a separate document at
/admin/login. Replaced an embedded panel that "unfolded" inside the console. Routes are declared beforeexpress.staticso the redirect guard can't be bypassed. - Branding: four settable colours; everything else derived. Text on coloured backgrounds is chosen by WCAG luminance; the muted label colour is mixed toward the background only as far as it can go while staying ≥4.5:1. A fixed grey was the cause of "text blending in". SVG banners rejected (can carry script, served to every kiosk).
- Kiosk has no admin link and no author credit. A kiosk is a public terminal. Credit line remains on staff-facing pages only.
- Company is recorded, not printed. Adding it to the badge overflowed 62×90 mm labels.
Technical Details
Ports / env: container 3000 (HTTPS app) and 3001 (HTTP helper); host HOST_PORT=8443,
HOST_HTTP_PORT=8080. Key env: HTTPS_ENABLED, HTTPS_HOSTNAMES (comma list of names/IPs
the kiosks use), HTTPS_PUBLIC_PORT (must equal HOST_PORT), APP_SECRET,
ADMIN_BOOTSTRAP_EMAIL/ADMIN_BOOTSTRAP_PASSWORD (first account only; ADMIN_PASSWORD is
dead), ADMIN_REQUIRE_2FA, ADMIN_ALLOWED_DOMAINS, EXPIRY_WARNING_DAYS,
SHEETS_ONSITE_TAB, PRINT_TIMEOUT_MS, PRINT_SIGNIN_WAIT_MS.
Operational gotchas (all hit during this session):
- After editing
.env, usedocker compose up -d, notdocker compose restart— restart keeps the old environment. Symptom: cert covers onlylocalhost,visitors.localand a172.xbridge IP. Server now warns at boot whenHTTPS_HOSTNAMESis at its default. - Host
.envpredates many settings; compare withdiff <(grep -oE '^[A-Z_]+=' .env | tr -d '=' | sort -u) <(grep -oE '^[A-Z_]+=' .env.example | tr -d '=' | sort -u). - Bind-mounted
./datais created root-owned;docker-entrypoint.shstarts as root, chowns/dataonly if ownership is wrong, then drops tonodeviasetpriv(util-linuxinstalled explicitly). pull_policy: buildin compose — the image is never pulled from a registry..gitattributesforces LF (CRLF only for.ps1/.bat) so shell scripts survive Windows.data/,.env,secrets/are gitignored and survivegit reset --hard(verified).
Deploy flow (host): branch is deploy.
git fetch origin && git reset --hard origin/deploy && docker compose up -d --build
then docker compose exec visitor-signin npm run version (reports which features are present).
Printer — QL-820NWB findings (hard-won):
- Head width on a 62 mm roll is 696 dots at 300 dpi; bitmap is always 696 wide. 90 mm = 1063 raster lines. Rotation is applied to the finished image, not the layout.
- DK-22251 (black/red) requires
brother_ql ... --label 62red --red— even for an all-black badge. Proven on the wire:--label 62redalone is byte-identical to--label 62(1063 monoglines,ESC i Kmode byte0x08); adding--redproduces 2126 two-planewlines and mode byte0x09. Printer message when missing: "Black/Red on White paper is installed now. Change it to Monochrome media." Mismatch fails in both directions. brother_ql's network backend only writes; it never reads, so it reports success even when the printer refuses the job.- The QL-820NWB does not answer status requests on port 9100 (write-only print server). Brother Status Monitor on a connected Windows PC gives the real refusal reason — use it first.
62= continuous roll;62x100etc. = die-cut. Sending one to the other is "Wrong Roll Type".
Schema (SQLite, src/db.js, idempotent addColumn migrations): tables sites, hosts,
frequent_visitors, visits, admin_users, login_attempts, pin_attempts, settings,
sheet_queue (now unused). Notable additions this session: sites.printer_{enabled,host,port, model,rotate,label}, sites.photo_{mode,threshold,contrast}, sites.badge_*,
sites.banner_{path,height,align}, sites.colour_{brand,signout,page,text},
frequent_visitors.{pin_lookup,photo_path,company,site_id}, visits.{company,site_id,site_name}.
Diagnostic scripts (run inside the container): scripts/version.mjs (npm run version),
scripts/print-test.mjs [--dry] [--label ID] [--rotate DEG], scripts/printer-status.mjs
(won't work on the QL-820NWB — see above), scripts/print-probe.mjs (interactive, needs
docker compose exec -it), scripts/make-cert.mjs [--force], scripts/gen-cert.sh.
Push scripts: push-to-gitea.ps1/.bat/.sh. Bugs fixed in-session: PowerShell functions
return their whole output stream (a wrapper made every git call look failed); branch was
hard-coded to main while work was on deploy; now detects unfinished rebases, checks git
identity, uses the checked-out branch, and verifies $LASTEXITCODE after each call.
Code Changes
Whole project created this session. Principal files:
src/server.js— Express app, HTTPS listener with cert hot-swap, HTTP helper (CA downloads + redirect), admin page routing before static,Cache-Control: no-cacheon html/js/css, jobs.src/db.js— schema, migrations, default site,pin_lookupbackfill, duplicate reporting.src/config.js— env parsing incl.https,sheets,printing,admingroups.src/auth.js— scrypt passwords, RFC 6238 TOTP (verified against RFC vectors), recovery codes.src/users.js— admin accounts, domain restriction, lockout, bootstrap.src/pins.js— PIN encrypt/decrypt, keyedpinLookup, collision-awaregeneratePin.src/photos.js— save/copy/purge visit and profile photos.src/sites.js— site helpers, HTML badge renderer (browser fallback).src/branding.js— theme derivation, WCAG contrast, banner storage.src/tls.js— CA + server cert lifecycle, DER export, zip bundle.src/zip.js— dependency-free ZIP writer.src/sheets.js— single-tab "On site now" rewrite, human-readable Google error mapping.src/printer.js— canvas badge render, Floyd–Steinberg halftone,brother_qlinvocation with--redfor62red, photo preview sheet, error translation.src/routes/kiosk.js— sign in/out, PIN auth, site scoping, server-print with fallback.src/routes/admin.js— auth/2FA, users, sites, hosts, recurring visitors, visits, printer diagnostics/test/preview, TLS endpoints, status.public/index.html,public/js/kiosk.js,public/css/kiosk.css— kiosk flow (filter input above host dropdown, square photo capture, badge fallback).public/login.html,public/js/login.js,public/css/login.css— standalone paged login.public/admin.html,public/js/admin.js,public/css/admin.css— console.Dockerfile,docker-compose.yml,docker-entrypoint.sh,.gitattributes,.env.example.scripts/*— diagnostics and cert tooling listed above.
Open Questions / Next Steps
- Confirm the
--redfix prints on real hardware. Pending: deploy, then Test print. The site's roll setting is already62redwith red on. - Latest changes exist only in the handed-over tarball, not in git: CA zip bundle, printer
diagnostics/probe scripts,
--redflag, photo halftoning. Next: extract over the Windows working copy → commit → pushdeploy→ hostreset --hard origin/deploy→ rebuild. - Tune photo halftoning from a printed label (defaults: error diffusion, threshold 50, contrast 20). 300 dpi dithering reads differently in the hand than on screen.
- Physical print output not yet verified: margins, cut position, rotation on the roll, red plane.
- Host
.envis stale; recommend rebuilding from.env.example, preservingAPP_SECRETexactly. - Google Sheet returned 403 "caller does not have permission" — fix is sharing the sheet with the service account (shown in Admin → System). Outcome not confirmed.
- Recommend a DNS name for the kiosk instead of a bare IP (IP-only certs are unreliable on Android; DHCP changes break everything).
- Consider making
deploythe default branch in Gitea / retiring the tangledmain. - Offered, not built: die-cut roll options in the roll dropdown; an admin field for
HTTPS_HOSTNAMES; banner/logo on the printed pass; theming the admin console. - Housekeeping:
sheet_queuetable is unused;brother_ql==0.9.4is unmaintained (forkbrother_ql_nextexists).
Artifacts
- Repository:
<redacted-gitea-host>/<redacted-owner>/visitor-signin, working branchdeploy. - Commits on
deploy:8bc7179Server-side printing, roll type setting, cache headers;eb98658Printing Debug;b1a7933Printer Debug 2. brother_ql0.9.4 (PyPI) —print --helpdocuments the--redrequirement.- Brother QL-820NWB specification: 12–62 mm media, 60.96 mm max print width, 300×300 dpi.
- RFC 6238 (TOTP) — test vectors used to verify
src/auth.js.
Addendum — 2026-09-23, PIN reset bug
Symptom: after changing a recurring visitor's PIN, phone + PIN was rejected at the kiosk.
Cause: the Edit form carried a "PIN" field whose value PATCH /admin/api/frequent/:id
never read. The record saved, the field was silently discarded, and the admin handed out a PIN
the system had never stored. (POST /admin/api/frequent/:id/pin, the "New PIN" button, was
always correct — verified by reproduction.)
Fix: PATCH now honours a supplied pin: 4 digits, checked for collisions via
allocatePin(pin, row.id), written to pin_enc and pin_lookup. Blank keeps the current PIN.
A malformed or already-used PIN is rejected with a message instead of ignored. The response
carries pinChanged, and the console shows the printable card when it is true. Saving a record
also clears pin_attempts for both the old and new phone number, so a changed number cannot
leave a stale lockout.
Files: src/routes/admin.js (PATCH handler), public/js/admin.js (edit modal shows the
current PIN, relabelled field, shows the card after a change), public/css/admin.css, README.md.