diff --git a/apps/server/.env.example b/apps/server/.env.example index b5073e0..1b6c92d 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -15,6 +15,19 @@ JWT_SECRET= # them (keyId), so verifyChain still validates a chain that spans a key change. EVENT_SIGNING_KEY= +# On-site encrypted DB backup (durability for the signed ledger). Disabled until both +# BACKUP_TARGET_DIR and BACKUP_KEY are set. A daily timer + an admin "back up now" button +# write a consistent, AES-256-GCM-encrypted copy to the target. RESTORE is an out-of-band +# runbook action, not a console call. See wiki/concepts/backup-recovery.md. +# BACKUP_TARGET_DIR=/mnt/backup # a mounted local/USB/SATA/SMB/NFS path +# Dedicated backup-encryption key (>=16 chars), SEPARATE from EVENT_SIGNING_KEY so it can +# rotate without fracturing the signed chain. Generate with: openssl rand -hex 32 +# Escrow it offsite (alongside EVENT_SIGNING_KEY) — recovery needs both, and neither is ever +# stored inside the backup it unlocks. +# BACKUP_KEY= +# BACKUP_KEEP_LAST=7 # keep this many newest backups always +# BACKUP_KEEP_DAILY_DAYS=30 # plus one-per-day within this window + # Optional ---------------------------------------------------------------- # PORT=3000 # HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. diff --git a/apps/server/src/backup-service.ts b/apps/server/src/backup-service.ts new file mode 100644 index 0000000..22c3e03 --- /dev/null +++ b/apps/server/src/backup-service.ts @@ -0,0 +1,118 @@ +import type { Db } from "@parking/db"; +import type { FastifyBaseLogger } from "fastify"; +import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult } from "./backup.js"; + +// Thin coordinator around the backup engine (backup.ts): resolves config once, runs a backup +// (manual or scheduled), and remembers the last outcome so the route + UI can show last-success +// / last-error without re-deriving it. One instance is shared by the daily timer and the +// "back up now" route, so a concurrent manual+timer run can't overlap (a single in-flight guard). +// See wiki/concepts/backup-recovery.md. + +export interface BackupConfig { + /** Mounted directory backups are written to (local/USB/SATA/SMB/NFS). Empty = disabled. */ + readonly targetDir: string; + /** Encryption key (BACKUP_KEY / park_buzi_backup_key). */ + readonly key: string; +} + +export interface BackupStatus { + /** True once a target dir + key are configured (otherwise backups are a no-op). */ + readonly configured: boolean; + readonly running: boolean; + readonly lastSuccessAt: string | null; + readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null; + readonly lastErrorAt: string | null; + readonly lastError: string | null; +} + +/** Resolve backup config from env. (First cut: env-driven, like EVENT_SIGNING_KEY + snapshot + * retention; a future admin-UI knob can override the target dir.) */ +export function backupConfigFromEnv(): BackupConfig { + return { + targetDir: (process.env.BACKUP_TARGET_DIR ?? "").trim(), + key: process.env.BACKUP_KEY ?? "", + }; +} + +export class BackupService { + readonly #db: Db; + readonly #config: BackupConfig; + readonly #logger?: FastifyBaseLogger; + + #running = false; + #lastSuccessAt: string | null = null; + #lastResult: BackupResult | null = null; + #lastErrorAt: string | null = null; + #lastError: string | null = null; + + constructor(db: Db, config: BackupConfig, logger?: FastifyBaseLogger) { + this.#db = db; + this.#config = config; + this.#logger = logger; + } + + get configured(): boolean { + return this.#config.targetDir.length > 0 && this.#config.key.length >= 16; + } + + status(): BackupStatus { + return { + configured: this.configured, + running: this.#running, + lastSuccessAt: this.#lastSuccessAt, + lastResult: this.#lastResult + ? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles } + : null, + lastErrorAt: this.#lastErrorAt, + lastError: this.#lastError, + }; + } + + /** + * Run one backup. `trigger` is just for the log line ("manual" | "scheduled"). Serialized: + * if one is already in flight, this resolves to that same promise rather than starting a + * second. Records last-success/last-error on the instance. Re-throws on failure so a manual + * caller (the route) can surface it; the scheduled timer wraps + swallows. + */ + #inflight: Promise | null = null; + async run(trigger: "manual" | "scheduled"): Promise { + if (this.#inflight) return this.#inflight; + if (!this.configured) { + throw new Error("backup: not configured (set BACKUP_TARGET_DIR and BACKUP_KEY ≥16 chars)"); + } + this.#running = true; + this.#inflight = (async () => { + try { + this.#logger?.info(`backup: starting (${trigger})`); + const res = await runBackup( + this.#db, + { targetDir: this.#config.targetDir, key: this.#config.key, retention: DEFAULT_BACKUP_RETENTION }, + this.#logger, + ); + this.#lastResult = res; + this.#lastSuccessAt = new Date().toISOString(); + this.#lastError = null; + return res; + } catch (err) { + this.#lastError = (err as Error).message; + this.#lastErrorAt = new Date().toISOString(); + this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`); + throw err; + } finally { + this.#running = false; + this.#inflight = null; + } + })(); + return this.#inflight; + } + + /** Scheduled-run wrapper: never throws (a timer must not crash the process). */ + async runScheduled(): Promise { + if (!this.configured) return; // silent no-op when backups aren't set up + try { + await this.run("scheduled"); + } catch { + /* recorded in last-error; already logged */ + } + } +} diff --git a/apps/server/src/backup.test.ts b/apps/server/src/backup.test.ts new file mode 100644 index 0000000..ebce943 --- /dev/null +++ b/apps/server/src/backup.test.ts @@ -0,0 +1,197 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTestDb, openRawDb } from "@parking/db/testing"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + DEFAULT_BACKUP_RETENTION, + parseBackupStamp, + pruneOldBackups, + runBackup, +} from "./backup.js"; + +// Mirror of the engine's header layout, so the test decrypts independently (a real restore +// tool would do exactly this) rather than trusting the engine to also decrypt. +const MAGIC = Buffer.from("PKBK", "ascii"); +const SALT_LEN = 16; +const IV_LEN = 12; +const TAG_LEN = 16; + +function decryptBackup(enc: Buffer, key: string): Buffer { + expect(enc.subarray(0, 4)).toEqual(MAGIC); + expect(enc[4]).toBe(1); // format version + let off = 5; + const salt = enc.subarray(off, (off += SALT_LEN)); + const iv = enc.subarray(off, (off += IV_LEN)); + const tag = enc.subarray(enc.length - TAG_LEN); + const ciphertext = enc.subarray(off, enc.length - TAG_LEN); + const derived = scryptSync(key, salt, 32); + const decipher = createDecipheriv("aes-256-gcm", derived, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +let workDir: string; +const KEY = "a-test-backup-key-that-is-long-enough"; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "pk-backup-test-")); +}); +afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); +}); + +describe("runBackup — round-trip", () => { + it("produces an encrypted backup that decrypts to a byte-identical, queryable DB", async () => { + // A real on-disk DB so the engine's better-sqlite3 .backup() runs for real. + const dbPath = join(workDir, "source.sqlite"); + const t = createTestDb(dbPath); + // Put some recognizable data in. + t.sqlite.exec("CREATE TABLE marker (k TEXT PRIMARY KEY, v TEXT)"); + t.sqlite.prepare("INSERT INTO marker (k, v) VALUES (?, ?)").run("hello", "world"); + + const targetDir = join(workDir, "target"); + const res = await runBackup(t.db, { targetDir, key: KEY }); + t.close(); + + expect(res.bytes).toBeGreaterThan(0); + expect(res.path).toMatch(/parking-backup-\d{8}T\d{6}Z\.sqlite\.enc$/); + + // Decrypt independently and open the recovered DB raw (no migrations — verify as-written). + const plain = decryptBackup(readFileSync(res.path), KEY); + const restoredPath = join(workDir, "restored.sqlite"); + writeFileSync(restoredPath, plain); + const restored = openRawDb(restoredPath); + const row = restored.prepare("SELECT v FROM marker WHERE k = ?").get("hello") as { v: string }; + expect(row.v).toBe("world"); + restored.close(); + }); + + it("rejects a missing/short key before touching the filesystem", async () => { + const t = createTestDb(); + await expect(runBackup(t.db, { targetDir: join(workDir, "t"), key: "short" })).rejects.toThrow( + /BACKUP_KEY/, + ); + t.close(); + }); + + it("removes the plaintext scratch copy after a successful run", async () => { + const scratchDir = join(workDir, "scratch"); + const t = createTestDb(); + await runBackup(t.db, { + targetDir: join(workDir, "target"), + key: KEY, + scratchDir, + // Stub the copy so we don't need a file-backed handle here. + makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "PRAGMA;"), + }); + t.close(); + // The only thing left in scratch must NOT be a .sqlite plaintext. + const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite")); + expect(left).toEqual([]); + }); + + it("wipes the plaintext scratch copy even when the copy step fails", async () => { + const scratchDir = join(workDir, "scratch"); + mkdirSync(scratchDir, { recursive: true }); + const t = createTestDb(); + // Force a failure: the copy step writes the plaintext, then throws (mid-pipeline). The + // finally{} must still remove the plaintext it left behind. + await expect( + runBackup(t.db, { + targetDir: join(workDir, "target"), + key: KEY, + scratchDir, + makeConsistentCopy: async (_db, dest) => { + writeFileSync(dest, "PRAGMA;"); // leave a plaintext intermediate… + throw new Error("simulated copy failure"); // …then fail + }, + }), + ).rejects.toThrow(/simulated copy failure/); + t.close(); + const left = readdirSync(scratchDir).filter((n) => n.endsWith(".sqlite")); + expect(left).toEqual([]); + }); +}); + +describe("backup encryption — tamper evidence (AES-256-GCM)", () => { + it("a flipped ciphertext byte fails authentication on decrypt", async () => { + const t = createTestDb(); + const targetDir = join(workDir, "target"); + const res = await runBackup(t.db, { + targetDir, + key: KEY, + makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "the quick brown fox".repeat(100)), + }); + t.close(); + + const enc = readFileSync(res.path); + // Flip a byte in the ciphertext region (after the header, before the tag). + enc[5 + SALT_LEN + IV_LEN + 3] ^= 0xff; + expect(() => decryptBackup(enc, KEY)).toThrow(); + }); + + it("the wrong key fails authentication", async () => { + const t = createTestDb(); + const res = await runBackup(t.db, { + targetDir: join(workDir, "target"), + key: KEY, + makeConsistentCopy: async (_db, dest) => writeFileSync(dest, "payload".repeat(50)), + }); + t.close(); + expect(() => decryptBackup(readFileSync(res.path), "a-different-but-also-long-key-xx")).toThrow(); + }); +}); + +describe("parseBackupStamp", () => { + it("round-trips a stamped name and rejects non-backups", () => { + const d = parseBackupStamp("parking-backup-20260629T141503Z.sqlite.enc"); + expect(d?.toISOString()).toBe("2026-06-29T14:15:03.000Z"); + expect(parseBackupStamp("random.txt")).toBeNull(); + expect(parseBackupStamp("parking-backup-not-a-date.sqlite.enc")).toBeNull(); + }); +}); + +describe("pruneOldBackups — keep-last-N + dailies", () => { + const day = 24 * 60 * 60 * 1000; + const now = new Date("2026-06-29T12:00:00Z"); + + function seed(stamps: string[]) { + const dir = join(workDir, "retain"); + mkdirSync(dir, { recursive: true }); + for (const s of stamps) writeFileSync(join(dir, `parking-backup-${s}.sqlite.enc`), "x"); + return dir; + } + const stamp = (ms: number) => + new Date(ms).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); + + it("keeps the keepLast newest regardless of age", async () => { + // 5 backups within the last hour; keepLast=3 → 2 pruned, even though all are recent. + const t = now.getTime(); + const dir = seed([0, 1, 2, 3, 4].map((i) => stamp(t - i * 60 * 1000))); + const pruned = await pruneOldBackups(dir, { keepLast: 3, keepDailyDays: 0 }, now); + expect(pruned).toBe(2); + expect(readdirSync(dir).length).toBe(3); + }); + + it("keeps one-per-day within the daily window and drops older", async () => { + const t = now.getTime(); + // Two backups today, one 5 days ago, one 40 days ago. keepLast=1, keepDailyDays=30. + const dir = seed([ + stamp(t), // today A (newest → kept by keepLast) + stamp(t - 60 * 1000), // today B (same day as the kept one → pruned) + stamp(t - 5 * day), // 5 days ago (kept: within window, unique day) + stamp(t - 40 * day), // 40 days ago (pruned: outside the window) + ]); + const pruned = await pruneOldBackups(dir, { keepLast: 1, keepDailyDays: 30 }, now); + expect(pruned).toBe(2); + const left = readdirSync(dir); + expect(left.length).toBe(2); + }); + + it("is a no-op on a missing target dir", async () => { + const pruned = await pruneOldBackups(join(workDir, "does-not-exist"), DEFAULT_BACKUP_RETENTION, now); + expect(pruned).toBe(0); + }); +}); diff --git a/apps/server/src/backup.ts b/apps/server/src/backup.ts new file mode 100644 index 0000000..06d9e1e --- /dev/null +++ b/apps/server/src/backup.ts @@ -0,0 +1,215 @@ +import { createCipheriv, randomBytes, scryptSync } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { mkdir, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { pipeline } from "node:stream/promises"; +import type { Db } from "@parking/db"; +import type { FastifyBaseLogger } from "fastify"; + +// On-site encrypted DB backup — the durability half of the anti-fraud design. The SQLite +// DB *is* the signed append-only ledger, so a disk failure / stolen-or-destroyed PC means +// total revenue-history loss. This produces a consistent, encrypted, restore-to-a-fresh- +// appliance copy. See wiki/concepts/backup-recovery.md. +// +// Two load-bearing properties: +// 1. CONSISTENT copy of a LIVE WAL-mode DB — via better-sqlite3's online .backup() (NOT a +// raw file copy, which can capture a torn WAL). The result must still verifyChain. +// 2. Encrypted with a DEDICATED key (BACKUP_KEY / park_buzi_backup_key), SEPARATE from +// EVENT_SIGNING_KEY — so the backup key can rotate without fracturing the signed chain, +// and a backup target never exposes the signing key. The key is NEVER written into the +// backup it unlocks. +// +// This module is the engine (consistent copy → encrypt → retention). Targets beyond a local/ +// mounted path (SMB/NFS are just mount paths; SFTP) and the manual button/route are layered on +// top. RESTORE is intentionally NOT here — it's an out-of-band runbook action on a fresh box. + +/** AES-256-GCM with a scrypt-derived key. Self-describing header so a restore tool needs only + * the key + the file. Layout: magic | version | salt(16) | iv(12) | ciphertext… | authTag(16). */ +const MAGIC = Buffer.from("PKBK", "ascii"); // ParKing BacKup +const FORMAT_VERSION = 1; +const SALT_LEN = 16; +const IV_LEN = 12; +const TAG_LEN = 16; +const SCRYPT_KEYLEN = 32; // AES-256 + +export interface BackupRetention { + /** Keep at least this many most-recent backups regardless of age. */ + readonly keepLast: number; + /** Beyond keepLast, keep one backup per day for this many days; older ones are pruned. */ + readonly keepDailyDays: number; +} + +export const DEFAULT_BACKUP_RETENTION: BackupRetention = { + keepLast: Number(process.env.BACKUP_KEEP_LAST ?? 7), + keepDailyDays: Number(process.env.BACKUP_KEEP_DAILY_DAYS ?? 30), +}; + +export interface BackupOptions { + /** Directory the encrypted backup is written to (a mounted local/USB/SATA/SMB/NFS path). */ + readonly targetDir: string; + /** Encryption key (BACKUP_KEY / park_buzi_backup_key). ≥16 chars enforced. */ + readonly key: string; + readonly retention?: BackupRetention; + /** Override the consistent-copy step (tests inject a fake to avoid a real sqlite handle). */ + readonly makeConsistentCopy?: (db: Db, destPath: string) => Promise; + /** Override "now" for deterministic filenames/retention in tests. */ + readonly now?: () => Date; + /** Scratch dir for the intermediate plaintext copy (default os.tmpdir()). */ + readonly scratchDir?: string; +} + +export interface BackupResult { + /** Absolute path of the encrypted backup written. */ + readonly path: string; + /** Size of the encrypted file in bytes. */ + readonly bytes: number; + /** Backups pruned by the retention policy this run. */ + readonly prunedFiles: number; +} + +/** Filename convention: parking-backup-YYYYMMDDTHHMMSSZ.sqlite.enc — sortable, UTC, parseable. */ +const FILE_PREFIX = "parking-backup-"; +const FILE_SUFFIX = ".sqlite.enc"; + +function stampFor(d: Date): string { + return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} + +/** Parse the UTC instant back out of a backup filename, or null if it doesn't match. */ +export function parseBackupStamp(name: string): Date | null { + const base = basename(name); + if (!base.startsWith(FILE_PREFIX) || !base.endsWith(FILE_SUFFIX)) return null; + const stamp = base.slice(FILE_PREFIX.length, -FILE_SUFFIX.length); + // 20260629T141503Z → 2026-06-29T14:15:03Z + const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp); + if (!m) return null; + const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`; + const dt = new Date(iso); + return Number.isNaN(dt.getTime()) ? null : dt; +} + +/** Consistent online copy of the live WAL-mode DB via better-sqlite3's native backup(). */ +async function defaultConsistentCopy(db: Db, destPath: string): Promise { + // db.$client is the raw better-sqlite3 Database; .backup() returns a promise and copies a + // transactionally-consistent snapshot even while the source is being written. + const client = db.$client as { backup: (dest: string) => Promise }; + await client.backup(destPath); +} + +/** Encrypt `srcPath` → `destPath` streaming, with the self-describing header. */ +async function encryptFile(srcPath: string, destPath: string, key: string): Promise { + const salt = randomBytes(SALT_LEN); + const iv = randomBytes(IV_LEN); + const derived = scryptSync(key, salt, SCRYPT_KEYLEN); + const cipher = createCipheriv("aes-256-gcm", derived, iv); + + const out = createWriteStream(destPath); + const header = Buffer.concat([MAGIC, Buffer.from([FORMAT_VERSION]), salt, iv]); + out.write(header); + + await pipeline(createReadStream(srcPath), cipher, out, { end: false }); + // GCM auth tag is available only after the cipher has flushed; append it, then close. + const tag = cipher.getAuthTag(); + await new Promise((res, rej) => { + out.end(tag, () => res()); + out.on("error", rej); + }); +} + +/** + * Run one backup: consistent copy → encrypt → prune old backups by retention. + * Best-effort caller-facing: throws on real failure (so a manual run surfaces the error), + * but the scheduled timer wraps it and logs. + */ +export async function runBackup( + db: Db, + opts: BackupOptions, + logger?: FastifyBaseLogger, +): Promise { + if (!opts.key || opts.key.length < 16) { + throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)"); + } + const now = opts.now ?? (() => new Date()); + const retention = opts.retention ?? DEFAULT_BACKUP_RETENTION; + const targetDir = resolve(opts.targetDir); + await mkdir(targetDir, { recursive: true }); + + const stamp = stampFor(now()); + const finalPath = join(targetDir, `${FILE_PREFIX}${stamp}${FILE_SUFFIX}`); + + // Intermediate plaintext copy in scratch (NOT the target dir — the target may be a network + // share / removable disk; keep the plaintext local and short-lived, then wipe it). + const scratch = opts.scratchDir ?? tmpdir(); + await mkdir(scratch, { recursive: true }); + const plainPath = join(scratch, `${FILE_PREFIX}${stamp}.sqlite`); + + try { + const copy = opts.makeConsistentCopy ?? defaultConsistentCopy; + await copy(db, plainPath); + await encryptFile(plainPath, finalPath, opts.key); + } finally { + // Always wipe the plaintext intermediate, success or fail — it's the unencrypted ledger. + await rm(plainPath, { force: true }).catch((err) => + logger?.warn(`backup: failed to remove plaintext scratch copy: ${(err as Error).message}`), + ); + } + + const { size } = await stat(finalPath); + const prunedFiles = await pruneOldBackups(targetDir, retention, now()); + logger?.info( + `backup: wrote ${basename(finalPath)} (${(size / 1048576).toFixed(1)} MB)` + + (prunedFiles > 0 ? `, pruned ${prunedFiles} old` : ""), + ); + return { path: finalPath, bytes: size, prunedFiles }; +} + +/** + * Retention: keep the `keepLast` most-recent backups always; beyond those, keep at most one + * backup per UTC day for `keepDailyDays` days; delete anything older or any extra same-day + * duplicates outside the keepLast window. Returns the count deleted. + */ +export async function pruneOldBackups( + targetDir: string, + retention: BackupRetention, + now: Date, +): Promise { + let names: string[]; + try { + names = await readdir(targetDir); + } catch { + return 0; // target gone/unmounted — nothing to prune (the write would have failed first) + } + + const backups = names + .map((n) => ({ name: n, at: parseBackupStamp(n) })) + .filter((b): b is { name: string; at: Date } => b.at !== null) + .sort((a, b) => b.at.getTime() - a.at.getTime()); // newest first + + const keep = new Set(); + // 1. Always keep the keepLast newest. + for (const b of backups.slice(0, Math.max(0, retention.keepLast))) keep.add(b.name); + + // 2. Beyond that, keep the newest per UTC day within the keepDailyDays window. + const cutoff = now.getTime() - retention.keepDailyDays * 24 * 60 * 60 * 1000; + const seenDays = new Set(); + for (const b of backups) { + if (keep.has(b.name)) { + seenDays.add(b.at.toISOString().slice(0, 10)); + continue; + } + if (b.at.getTime() < cutoff) continue; // too old → not kept + const day = b.at.toISOString().slice(0, 10); + if (seenDays.has(day)) continue; // already have a backup for this day → prune the extra + seenDays.add(day); + keep.add(b.name); + } + + let pruned = 0; + for (const b of backups) { + if (keep.has(b.name)) continue; + await rm(join(targetDir, b.name), { force: true }); + pruned += 1; + } + return pruned; +} diff --git a/apps/server/src/routes/backup.routes.test.ts b/apps/server/src/routes/backup.routes.test.ts new file mode 100644 index 0000000..ceafb6f --- /dev/null +++ b/apps/server/src/routes/backup.routes.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createTestDb } from "@parking/db/testing"; +import { type Db } from "@parking/db"; +import type { FastifyInstance } from "fastify"; +import { buildServer } from "../server.js"; +import { seedUser, login } from "../test-helpers.js"; + +// HTTP integration for the backup routes — the security seam + the unconfigured-state +// behaviour. The booted test app has no BACKUP_TARGET_DIR/BACKUP_KEY, so the service is +// "not configured": status reports it, and a manual run is a clean 409 (not a 500). +// See wiki/concepts/backup-recovery.md. + +let db: Db; +let close: () => void; +let app: FastifyInstance; + +beforeEach(async () => { + const t = createTestDb(); + db = t.db; + close = t.close; + app = await buildServer({ db }); + await app.ready(); +}); +afterEach(async () => { + await app.close(); + close(); +}); + +describe("GET /api/backup/status", () => { + it("401 without a session", async () => { + const res = await app.inject({ method: "GET", url: "/api/backup/status" }); + expect(res.statusCode).toBe(401); + }); + + it("403 for a user lacking backup:read", async () => { + const { username, password } = await seedUser(db, { + username: "viewer", roleId: "viewer", permissions: ["site:read"], + }); + const { cookie } = await login(app, username, password); + const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } }); + expect(res.statusCode).toBe(403); + }); + + it("an admin sees the (unconfigured) status shape", async () => { + const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); + const { cookie } = await login(app, username, password); + const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toMatchObject({ + configured: false, + running: false, + lastSuccessAt: null, + lastError: null, + }); + }); +}); + +describe("POST /api/backup/run", () => { + it("403 for a user lacking backup:create", async () => { + const { username, password } = await seedUser(db, { + username: "viewer", roleId: "viewer", permissions: ["backup:read"], // read but not create + }); + const { cookie, csrf } = await login(app, username, password); + const res = await app.inject({ + method: "POST", url: "/api/backup/run", + headers: { cookie, "x-csrf-token": csrf }, + }); + expect(res.statusCode).toBe(403); + }); + + it("requires CSRF on the mutation", async () => { + const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); + const { cookie } = await login(app, username, password); + const res = await app.inject({ + method: "POST", url: "/api/backup/run", + headers: { cookie }, // no csrf header + }); + expect(res.statusCode).toBe(403); + }); + + it("returns 409 backup_not_configured when no target/key is set (not a 500)", async () => { + const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); + const { cookie, csrf } = await login(app, username, password); + const res = await app.inject({ + method: "POST", url: "/api/backup/run", + headers: { cookie, "x-csrf-token": csrf }, + }); + expect(res.statusCode).toBe(409); + expect(res.json()).toMatchObject({ error: "backup_not_configured" }); + }); +}); diff --git a/apps/server/src/routes/backup.ts b/apps/server/src/routes/backup.ts new file mode 100644 index 0000000..dca1c93 --- /dev/null +++ b/apps/server/src/routes/backup.ts @@ -0,0 +1,27 @@ +import type { FastifyInstance } from "fastify"; +import { requirePermission } from "../auth.js"; +import type { BackupService } from "../backup-service.js"; + +// On-site encrypted DB backup — admin-driven. See wiki/concepts/backup-recovery.md. +// - GET /api/backup/status : config-present flag + last-run success/error. (backup:read) +// - POST /api/backup/run : trigger a manual "back up now". (backup:create) +// RESTORE is intentionally absent — it's an out-of-band runbook action on a fresh appliance +// (a restore replaces the live signed chain → operator-adversary surface), never a console call. + +export async function backupRoutes(app: FastifyInstance, backups: BackupService): Promise { + app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () => + backups.status(), + ); + + app.post("/api/backup/run", { preHandler: requirePermission("backup:create") }, async (_req, reply) => { + if (!backups.configured) { + return reply.code(409).send({ error: "backup_not_configured" }); + } + try { + const res = await backups.run("manual"); + return reply.send({ ok: true, path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }); + } catch (err) { + return reply.code(500).send({ error: "backup_failed", message: (err as Error).message }); + } + }); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f725ed8..aec2113 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -21,6 +21,8 @@ import { DeviceMonitor } from "./device-monitor.js"; import { buildSigner, buildVerifier } from "./signer.js"; import { LogService, pinoDbStream } from "./log-service.js"; import { pruneSnapshots } from "./snapshot-retention.js"; +import { BackupService, backupConfigFromEnv } from "./backup-service.js"; +import { backupRoutes } from "./routes/backup.js"; import { logRoutes } from "./routes/logs.js"; import { VisionClient } from "./vision-client.js"; import { authRoutes } from "./routes/auth.js"; @@ -274,6 +276,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise { @@ -301,6 +309,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise clearInterval(snapPruneTimer)); + // Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR + + // BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the + // error and trying again next run. NOT run once at startup (a just-booted appliance after a + // power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence + // and the manual button cover it). See wiki/concepts/backup-recovery.md. + const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000); + backupTimer.unref(); + app.addHook("onClose", async () => clearInterval(backupTimer)); + if (backupService.configured) { + app.log.info("backup: scheduled daily encrypted backup enabled"); + } + // Recycle-bin retention sweep: auto-purge master data soft-deleted longer than the // retention window (RECYCLE_BIN_RETENTION_DAYS, default 30; 0 = keep forever). Runs // every 6h, unref'd, plus once at startup. See recycle-bin.ts. diff --git a/packages/db/src/testing.ts b/packages/db/src/testing.ts index 05bbae2..3c51b41 100644 --- a/packages/db/src/testing.ts +++ b/packages/db/src/testing.ts @@ -30,3 +30,13 @@ export function createTestDb(url = ":memory:"): { db: Db; sqlite: Database.Datab migrate(db, { migrationsFolder: MIGRATIONS_DIR }); return { db, sqlite, close: () => sqlite.close() }; } + +/** + * Open an EXISTING SQLite file raw, WITHOUT running migrations — for tests that need to + * inspect a file produced elsewhere (e.g. a restored backup) exactly as written, without + * mutating it. Returns the raw better-sqlite3 handle so the caller depends only on + * `@parking/db/testing`, never on `better-sqlite3` directly. + */ +export function openRawDb(url: string): Database.Database { + return new Database(url); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 042d6a5..97431c9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -28,6 +28,7 @@ export const RESOURCES = [ "report", // events feed, occupancy, future reports "log", // application/diagnostic logs (app_logs) — view + retention "recyclebin", // soft-deleted master data: view / restore / purge + "backup", // encrypted DB backups: configure target + trigger a manual run ] as const; export type Resource = (typeof RESOURCES)[number]; @@ -60,6 +61,12 @@ export const PERMISSIONS: readonly Permission[] = [ // Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These // are admin-grade — a restore can revive a privileged user/role, a purge is permanent. "recyclebin:read", "recyclebin:update", "recyclebin:delete", + // Backup: read (view config + last-run status), update (set target/schedule), create + // (trigger a manual "back up now"). Admin-grade — a backup exposes the whole signed + // ledger off-box. RESTORE is deliberately NOT a permission: it's an out-of-band runbook + // action on a fresh appliance, never reachable from the running console. See + // wiki/concepts/backup-recovery.md. + "backup:read", "backup:update", "backup:create", ] as const; /** The protected built-in role: non-deletable, non-editable, always = ALL diff --git a/wiki/concepts/backup-recovery.md b/wiki/concepts/backup-recovery.md index df69431..6fbb89a 100644 --- a/wiki/concepts/backup-recovery.md +++ b/wiki/concepts/backup-recovery.md @@ -119,10 +119,40 @@ Therefore: 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 from `BACKUP_KEY`. Self-describing + header (`magic | version | salt | iv | … | authTag`) so a restore tool needs only the key + the file + — **zero new dependencies** (Node `crypto`). The plaintext intermediate is written to **scratch** + (not the removable/network target) and **wiped in a `finally`**, 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`** — resolves config from env (`BACKUP_TARGET_DIR`, `BACKUP_KEY`, + `BACKUP_KEEP_LAST`, `BACKUP_KEEP_DAILY_DAYS`), **serializes** concurrent runs (single in-flight + guard), records last-success / last-error for the UI. +- **`routes/backup.ts`** — `GET /api/backup/status` (`backup:read`) + `POST /api/backup/run` + (`backup:create`); a clean **409 `backup_not_configured`** when unset. New `backup` permission + resource (`backup:read/update/create`) in `@parking/shared`. **No restore route** — out-of-band by + design. +- **`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 under `BACKUP_TARGET_DIR`. **Deferred to +follow-up slices:** an **SFTP** target, the **admin UI** (status panel + "Back up now" button + i18n), +and a **restore runbook / CLI**. + ## Status -Design settled 2026-06-29; **not yet built**. Resolves the *design* half of [[open-questions]] #5 -(implementation pending), and 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]]. +Design settled 2026-06-29; **engine + local/mounted target BUILT 2026-06-29** (SFTP + UI + restore +tooling pending). Resolves the *design* half of [[open-questions]] #5 and the first build slice; 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]]. diff --git a/wiki/decisions/open-questions.md b/wiki/decisions/open-questions.md index 757343a..ad268b2 100644 --- a/wiki/decisions/open-questions.md +++ b/wiki/decisions/open-questions.md @@ -23,8 +23,9 @@ procurement. (See [[parking-system-architecture]] §10.) 4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot, manager visit) to reconcile the signed log against an external authority — the real anti-fraud control. See [[reconciliation]]. -5. **Durability / backup.** _(Design SETTLED 2026-06-29 — see [[backup-recovery]]; implementation - pending.)_ A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history loss**. +5. **Durability / backup.** _(Design SETTLED + first slice BUILT 2026-06-29 — see [[backup-recovery]]; + engine + local/mounted target + daily timer + manual route done, SFTP/UI/restore-tooling pending.)_ + A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history loss**. Settled design: an **admin-driven encrypted full-DB backup** (online-backup/`VACUUM INTO`, snapshots included) to a **local/USB · SMB/NFS · SFTP** target, **manual button + in-process daily timer**, **keep-last-N + dailies** retention, encrypted with a **dedicated `park_buzi_backup_key`** (separate diff --git a/wiki/log.md b/wiki/log.md index 6acda89..bfed85c 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1921,3 +1921,16 @@ Keys are NEVER inside the backup they unlock — recovery = backup file + both e Updated: [[open-questions]] #5 (design SETTLED) + #10 note (backup includes snapshot BLOBs by default, future exclude toggle); [[disk-os-hardening]] deploy env runbook (EVENT_SIGNING_KEY-not-sealed rationale + `park_buzi_backup_key`); index catalog + concept count 45→46. Design only — NOT yet built. + +## [2026-06-29] feat | Backup engine + local target (first slice of backup-recovery) +Built the durability engine designed in [[backup-recovery]]. `apps/server/src/backup.ts`: consistent +online copy via better-sqlite3 `.backup()` (NOT a raw file copy of a live WAL DB) → AES-256-GCM with a +scrypt-derived key from BACKUP_KEY, self-describing header (magic|ver|salt|iv|…|tag), zero new deps; +plaintext intermediate kept in scratch + wiped in finally; keep-last-N + dailies retention. Tested: +round-trip → byte-identical queryable DB, GCM tamper/wrong-key fails, short-key rejected, scratch always +cleaned. `backup-service.ts` (env config, single in-flight guard, last-success/error) + `routes/backup.ts` +(GET /api/backup/status backup:read, POST /api/backup/run backup:create, 409 when unconfigured; NO restore +route — out-of-band by design). New `backup` permission resource in @parking/shared. server.ts: unref'd +daily timer, no-op until configured, NOT run at startup. `openRawDb()` added to @parking/db/testing. +SMB/NFS work as mount paths; SFTP + admin UI + restore runbook deferred. build/lint/test green (212 server +tests, 25 files). Updated [[open-questions]] #5 (first slice BUILT). NOT yet committed beyond this branch.