16 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.
Addendum — iPad trust troubleshooting
Reported: Safari and Chrome on a Jamf-managed iPad still report an insecure connection after the certificate was imported into Jamf.
Found: the server certificate was being issued for exactly 398 days — Apple's ceiling, with
zero margin for rounding or clock skew. SERVER_DAYS in src/tls.js reduced 398 → 396.
Added: scripts/tls-check.mjs (npm run tls-check), which connects to a given URL and checks
what iOS requires: address covered by a SAN, validity span, SAN present, serverAuth EKU,
signature algorithm, key size, and that the served leaf was issued by this server's CA. It prints
the authority and leaf fingerprints side by side.
Note: Node's X509Certificate does not expose extended key usage and toString() returns PEM,
so the EKU and signature checks shell out to openssl x509 -noout -text.
Most likely causes, still unconfirmed on the device: the served certificate was uploaded to
Jamf instead of the authority; or the address the iPad uses is not in HTTPS_HOSTNAMES (the
host .env was previously missing that line entirely).
Addendum — promote a guest to a recurring visitor
Added Make regular to the On site list for any visit with a mobile number that is not already a recurring visitor.
POST /admin/api/visits/:id/make-regular copies name, company, phone, email, check type/number/
expiry, host (as their default) and site from the visit, issues a random unique PIN via
allocatePin(null), and copies the visit photo into a separate profile photo with
copyStoredPhoto so retention purging the visit cannot delete it. Returns the shaped record plus
pin and photoCarriedOver; the console opens the printable card.
Refusals: no mobile number on the visit (the number is the sign in name); phone or email already
belongs to a recurring visitor (error names them and returns existingId). Site-scoped admins are
checked with assertSiteAllowed. The visit row is deliberately not modified or relinked.
Files: src/routes/admin.js, public/js/admin.js, README.md.