BackupService tracked last-success/last-error as plain in-process fields and scheduled the daily backup via setInterval measured from process start — so any server restart (deploy/crash/OOM/reboot, routine under `restart: always`) silently reset the admin UI to "last successful backup: Never" and drifted the actual cadence, independent of whether backups were writing correctly to disk (they were — a real field incident at park-buzi showed 7 valid rotating backups on disk with the status stuck on "Never"). Persist last-success/error to new site_config columns (migration 0025) and add BackupService.isDue(), computed from the persisted timestamp instead of process uptime; server.ts now polls every 15 min and lets isDue() gate the actual run. No API/UI contract change. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
19 KiB
type, tags, sources, updated
| type | tags | sources | updated | ||||||
|---|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-08-30 |
Backup & Disaster Recovery
The appliance's sqlite DB is the signed append-only-event-chain — the whole revenue/audit history. A disk failure or a stolen/destroyed PC currently means total loss (this is open-questions #5). This page is the settled design for an on-site, admin-driven backup that survives total hardware loss and restores to a fresh appliance with the signed chain still verifying. (Designed 2026-06-29.)
The recovery scenario it must satisfy
The driving scenario (the one that forces every decision below): the PC is gone — stolen or destroyed. Its SSD is LUKS-encrypted and TPM-sealed, so the disk is unrecoverable by design (a stolen disk won't unlock off its own TPM — see disk-os-hardening, tpm). We do not want the dead disk; we want to stand up a new PC, restore the backup, and continue signing the same chain. For that to work, recovery must depend on (a) the backup file and (b) two keys held out-of-band — never on the dead machine.
Key custody — the load-bearing decision
This is the part the whole plan rests on, and it interacts with the secure-element question (open-questions #6). Three independent keys, three custodians:
| Key | Lives | Recoverable after PC loss? | Job |
|---|---|---|---|
EVENT_SIGNING_KEY |
fleet-deployment-komodo secret (park_buzi_event_signing_key), escrowed offsite |
Yes — by design | Signs + verifies the ledger chain |
park_buzi_backup_key (new) |
Komodo secret, escrowed offsite, separate from the signing key | Yes | Encrypts/decrypts the backup file |
| LUKS / TPM disk key | The appliance's TPM only | No — deliberately | At-rest protection of the powered-off SSD |
-
The signing key is decoupled from the TPM — kept an extractable software HMAC secret (append-only-event-chain,
signer.ts), held in Komodo and escrowed by the operator. This is a conscious trade: a truly non-extractable TPM-sealed signing key (the #6 upgrade) would make the ledger unforgeable even against a host-root attacker — but it would also make the old ledger permanently unverifiable after total hardware loss (the sealed key dies with the machine;buildVerifier(keyId)would returnundefinedforever). You cannot have both "key can never be extracted" and "I can rescue the key after the machine dies" — they are the same property from two sides. Against the threat-model (the booth operator, who has a UI login, not host root) an escrowed software key is already tamper-evident, so the recoverable design is chosen today; revisiting #6 means re-accepting the unverifiable-after-loss cost. See tpm "TPM vs. ATECC608", fleet-deployment-komodo (the "EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius" caveat is the same trade). -
Backup key is separate from the signing key even though Komodo holds both — so they can be managed independently. Rationale: (1) the signing key must almost never rotate (every rotation fractures the chain into a new
keyIdsegment — old events stay pinned to the old key forever), whereas the backup key may want routine rotation (a USB went home, a target was decommissioned); coupling them drags the cheap op into the expensive one. (2) The backup key travels to every backup destination (USB, NAS, SFTP); the signing key should travel nowhere but Komodo → process memory — sharing one key means every backup target conceptually exposes the signing key. (3) Keeping them separate keeps the #6 TPM-migration door open without re-wiring backups. Decided 2026-06-29 (the "one fewer secret to escrow" simplicity of a shared key is real, but weakest here because Komodo already holds both).
The keys are never inside the backup they unlock. A key can't decrypt the file it's locked in. Recovery = backup file + both escrowed keys, supplied out-of-band. The runbook must say this plainly so nobody "helpfully" stores the keys next to the backups.
What a backup contains
Full SQLite DB, snapshots included — one self-contained, restore-to-identical-appliance file (ledger + sessions + config + subscriptions + the entry-exit-points BLOBs). Chosen for completeness over size.
Size caveat (interacts with open-questions #10). Snapshot BLOBs dominate DB size and bloat every backup. They are unsigned, advisory, and already disk-pressure-pruned (entry-exit-points). A future "exclude snapshots" toggle (ledger/sessions/config only — much smaller, signed chain still fully preserved) is the obvious knob if backup size becomes a problem; the default is the complete picture.
The backup is produced via SQLite online-backup / VACUUM INTO (a consistent snapshot of the
live WAL-mode DB — never a raw file copy, which can capture a torn WAL), then encrypted with
park_buzi_backup_key. Acceptance test: a restored copy must still pass verifyChain — the
signed chain is the thing being protected, so an unverifiable restore is a failed backup.
Triggers
- Manual — an admin-only "Back up now" button runs immediately to the configured target.
- Periodic — an in-process daily timer (same pattern as the snapshot-retention prune,
entry-exit-points /
snapshot-retention.ts): runs only if the configured target is reachable/mounted; surfaces last-success / last-error in the UI. No OS cron — it lives inside the Fastify process, works inside the container-deployment, and is configured in one place. (offline-first: the periodic path must tolerate a missing/unmounted target without failing the app.)
Destinations (admin-configurable)
All three supported in the first cut; the manual button and the periodic timer share them:
- Local / USB / SATA disk — a mounted path on an attached disk. Simplest, fully offline, matches the air-gapped appliance. The strong first target.
- Network drive (SMB/NFS) — a mounted share on the isolated LAN (a site NAS). Still local-network, no internet (network-isolation).
- SFTP — push to an SFTP endpoint, useful for an offsite copy. FTP is excluded (plaintext credentials + data); SFTP is the safe equivalent.
The target must be a bind-mounted host path — NOT a casually-plugged USB (2026-06-29)
The server runs inside the parking-server container, so it can only stat()/write paths that
are bind-mounted into that container. A USB stick the operator plugs in lands at a desktop
auto-mount path on the host (/run/media/<user>/<UUID>), which does not exist inside the
container — so the in-UI Test target correctly reports "location does not exist." This bit on
the first booth deploy (2026-06-29): BACKUP_KEY was finally injected, then the target test failed
because the USB path wasn't visible to the process.
So a backup destination is provisioned by the ADMIN at the host level, not chosen ad hoc by the operator. The procedure:
- Attach the disk (external HDD/SSD/USB) and mount it at a stable host path (e.g.
/mnt/backup) via/etc/fstabby UUID — not the desktop automounter, whose UUID-named path changes per drive and vanishes on unplug. - Bind-mount that host path into the container in the prod compose (e.g.
/mnt/backup:/mnt/backupon theserverservice — same pattern as the/dev/usbprinter passthrough in container-deployment). - In the UI (Setup → Backup), set the target directory to the in-container path (
/mnt/backup) and Test target — now writable.
This is partly a feature, not just a limitation (threat-model): because the destination is a host-provisioned bind-mount, the booth operator cannot redirect backups to a removable stick they walk off with — real destinations are an admin/host decision, on the trusted side of the trust-boundary. A network share (SMB/NFS) is the same shape: mount on the host, bind-mount in.
Limitation acknowledged: the backup target is therefore not operator-flexible — you cannot just plug in a USB and back up from the UI. Adding a new destination = a host
fstab+ compose bind-mount change + redeploy. For the appliance model (single-purpose, admin-provisioned) this is the right trade; a future "back up to a freshly-plugged removable drive" flow would need host-level automount detection wired to the container, which is deferred / not built.
Retention at the destination
Keep last N + thinned dailies (e.g. last 7 daily / last 4 weekly) — bounded disk use, and it survives the "a bad/partial run clobbered the only good copy" failure. (A single rolling overwrite-latest file was rejected for exactly that reason.)
Threat-model fit — restore is the dangerous half
Writing a backup is benign; restore is operator-adversary surface (threat-model). A restored DB replaces the live signed chain — so a malicious restore is a way to swap in a doctored history. Therefore:
- Restore is NOT a booth button. It is an admin-only, out-of-band runbook action (new appliance, deliberate provisioning step), not something reachable from the operator console.
- The backup target configuration and the "Back up now" action are admin-gated.
- Backups do not weaken the chain's tamper-evidence: a restored chain is re-verified with the
escrowed
EVENT_SIGNING_KEY; a tampered restore failsverifyChainjust as a tampered live DB would. The backup is a durability control, not an integrity one — integrity stays with the signed chain + reconciliation.
As-built (2026-06-29) — engine + local/mounted target
The first slice is built and tested: the backup engine + a local/mounted target + the daily timer + the manual route. What landed:
apps/server/src/backup.ts— the engine. Consistent online copy via better-sqlite3's native.backup()(a transactionally-consistent snapshot of the live WAL DB — not a raw file copy), then AES-256-GCM encryption with a scrypt-derived key fromBACKUP_KEY. Self-describing header (magic | version | salt | iv | … | authTag) so a restore tool needs only the key + the file — zero new dependencies (Nodecrypto). The plaintext intermediate is written to scratch (not the removable/network target) and wiped in afinally, success or fail. Retention = keep-last-N + one-per-day-within-N-days (pruneOldBackups). Tested: round-trip decrypts to a byte-identical, queryable DB; a flipped byte or wrong key fails GCM auth; short key rejected; scratch plaintext always removed.backup-service.ts— the target directory AND retention are admin-chosen in the UI (site_config.backup_target_dir, migration 0016;backup_keep_last+backup_keep_daily_days, migration 0017) and read fresh each run, so changing them takes effect with no restart. Retention columns are nullable → fall back to the code default (keep-last 7, keep-daily 30) per field. The encryption key is the ONLY backup env/Komodo secret (BACKUP_KEY) — a key must never live in the DB it backs up; target+retention are operational policy, not secrets. The service serializes concurrent runs (single in-flight guard) and records last-success / last-error;status()exposestargetDir,keepLast,keepDailyDays+keyPresentso the UI distinguishes "no target" from "no key".routes/backup.ts—GET /api/backup/status(backup:read);PUT /api/backup/configto set/ clear the target (backup:update);POST /api/backup/testto probe a candidate path server-side — exists / is-a-dir / writable (backup:update);POST /api/backup/run(backup:create), a clean 409backup_not_configuredwhen target+key aren't both set. Newbackuppermission resource (backup:read/update/create) in@parking/shared. No restore route — out-of-band by design.apps/web/src/BackupSettings.tsx— a Setup → Backup tab (gatedbackup:read): an editable target-path field with a Test target probe (localized ok/missing/not-a-dir/not-writable), retention fields (keep-last / keep-daily-days), one Save, the status panel (config state, last-run size/pruned/error, a distinct amber missing BACKUP_KEY warning), a Back up now button, and the restore-is-out-of-band note. Full i18n (sq + en).- Komodo wiring.
BACKUP_KEYis a per-booth Komodo secret ([[park_buzi_backup_key]]inkomodo/resources.toml; documented inkomodo/.env.komodo.example), escrowed offsite alongsideEVENT_SIGNING_KEY. It is the only backup env var — target + retention are in the DB.
Gotcha — compose
environment:is an ALLOWLIST (cost a full booth-deploy session, 2026-06-29). WiringBACKUP_KEYas a Komodo secret + Stack-env line is necessary but not sufficient:docker-compose.yml'sserver.environment:block only forwards the variables it names. The key was wired everywhere (secret store, Stack env,.env.example, schema) but never added to that compose block, so the container came up without it —docker inspect ...Config.EnvshowedJWT_SECRET/EVENT_SIGNING_KEYpresent andBACKUP_KEYabsent (not empty), while the Backup screen correctly reported "BACKUP_KEY missing". Diagnosis was muddied by chasing Komodo (secret name, re-sync, destroy/redeploy, env-only-change-doesn't-recreate) before checking the compose allowlist. Lesson: a new server env var needs a line indocker-compose.ymlserver.environment:too — that's the only place env reaches the container. Fixed:BACKUP_KEY: ${BACKUP_KEY:-}next toEVENT_SIGNING_KEY. Quick check on a booth:docker inspect <server> --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i backup.
server.ts— an unref'd daily timer (backupService.runScheduled), a no-op until configured, and deliberately NOT run at startup (a just-power-cut booth shouldn't write to a possibly-unmounted disk; the daily cadence + the manual button cover it).- Env documented in
apps/server/.env.example(with the escrow + separate-key notes).
SMB/NFS already work — they're just a mounted path the admin enters as the target. Deferred to follow-up slices: an SFTP target and a restore runbook / CLI.
Field bug — "last successful backup: Never" despite valid, rotating backups on disk (found + fixed 2026-08-30)
Symptom (park-buzi): the admin noticed the backup directory held 7 real, correctly-sized,
correctly-rotating encrypted backups (parking-backup-*.sqlite.enc, retention working exactly as
designed) — yet the Backup screen's "Kopja e fundit e suksesshme" (last successful backup) showed
"Asnjëherë" (Never). Separately, the most recent file was 2 days old rather than ~1.
Root cause — two independent, disconnected code paths, both traced to setInterval-since-
process-start:
- Status was never persisted.
BackupServicetrackedlastSuccessAt/lastResult/lastErrorAt/lastErroras plain in-process private fields — set only insiderun(), read only bystatus()on the same running instance. Nothing wrote them tosite_configor anywhere else durable. The actual backup-writing engine (backup.ts: consistent copy → encrypt →pruneOldBackups) is a completely separate code path that only touches the filesystem and has no notion of this status object. So "7 valid files on disk" and "status says Never" were never contradictory — they were two unrelated signals, and any server restart (deploy, crash, OOM, host reboot — all routine underrestart: alwaysindocker-compose.prod.yml) silently reset the in-memory fields tonullregardless of what had actually happened on disk. - The schedule was measured from process start, not from the last real backup. The daily
timer was
setInterval(() => backupService.runScheduled(), 24h)— a fixed 24h period counted from whenever the process last started, not from wall-clock time or from when a backup last actually succeeded. The exact same restart that wiped the in-memory status also reset this countdown, which is why the cadence can silently drift or skip past a day with no error ever surfacing anywhere.
Both symptoms are one cause: the server process restarted after the Aug 28 backup, and nothing about this design was built to survive that.
Fix (2026-08-30)
packages/db/src/schema.ts/ migration0025_backup_last_status.sql— four new nullablesite_configcolumns:backup_last_success_at,backup_last_result_json,backup_last_error_at,backup_last_error. Same table, same upsert pattern asbackup_target_dir/backup_keep_last/backup_keep_daily_days(migrations 0016/0017).backup-service.ts—run()now writes success/error outcomes to these columns (via a#persistupsert helper) instead of private fields;status()reads them fresh from the DB on every call. A brand-newBackupServiceinstance (i.e. a fresh process) now sees exactly what the previous instance last recorded — no more restart amnesia.- New
isDue(now, intervalMs = 24h)method: due iffnow - backupLastSuccessAt >= 24h(or immediately due if no success was ever recorded), computed from the persisted timestamp — never from process uptime. server.ts— the dailysetIntervalwas replaced with a 15-minute poll callingrunScheduled(), which now itself no-ops unlessisDue()is true. This makes the actual backup cadence immune to restart timing entirely: however often the process happens to restart, the next backup fires within 15 minutes of 24h having genuinely elapsed since the last real success — not 24h after whatever moment the process most recently came back up.- Covered by a new
backup-service.test.ts: a freshBackupServiceover the same DB handle (simulating a restart) sees the prior instance's last success/error and its cleared-on-success behavior;isDue()is exercised directly against injected timestamps rather than real sleeps.
No change to the BackupStatus shape returned by GET /api/backup/status or to
BackupSettings.tsx — this was purely a durability fix underneath the same contract.
Status
Design settled 2026-06-29; engine + admin-configured local/mounted target + admin UI BUILT
2026-06-29 (SFTP + restore tooling pending). The target directory is admin-chosen in the UI
(site_config, migration 0016), not an env var — the on-site admin picks where backups land; only
BACKUP_KEY stays a server secret. Last-success/last-error status + the scheduling cadence are
now restart-durable (migration 0025, 2026-08-30) — see field bug above. Resolves the design
half of open-questions #5 and the first build slices; records the key-custody stance that bears
on #6 (signing stays decoupled from the TPM) and #10 (snapshots bloat backups → future exclude
toggle). See append-only-event-chain, disk-os-hardening, tpm, fleet-deployment-komodo,
reconciliation.