feat(backup): encrypted on-site DB backup engine + local target
The SQLite DB is the signed append-only ledger, so a disk failure / stolen or destroyed PC means total revenue-history loss (open-question #5). This is the first slice of the backup-recovery design: the engine + a local/mounted target + a daily timer + a manual route. Engine (apps/server/src/backup.ts): - Consistent online copy of the live WAL DB via better-sqlite3's native .backup() (not a raw file copy, which can capture a torn WAL) — the restored copy is a byte-identical, queryable DB. - AES-256-GCM with a scrypt-derived key from BACKUP_KEY; self-describing header (magic|version|salt|iv|...|authTag) so a restore tool needs only the key + file. Zero new dependencies (Node crypto). - The plaintext intermediate is kept in scratch (not the removable/network target) and wiped in a finally, success or fail. - Retention: keep-last-N + one-per-day within N days. Wiring: - BackupService (env config, single in-flight guard, last-success/last-error). - routes/backup.ts: GET /api/backup/status (backup:read), POST /api/backup/run (backup:create), 409 when unconfigured. No restore route — restore is an out-of-band runbook action on a fresh appliance, not a console call. - New permission resource in @parking/shared. - server.ts: an unref'd daily timer, a no-op until BACKUP_TARGET_DIR + BACKUP_KEY are set, deliberately not run at startup (a just-power-cut booth shouldn't write to a possibly-unmounted disk). - openRawDb() added to @parking/db/testing (open a file without migrating, for restore-verification tests). BACKUP_KEY is deliberately SEPARATE from EVENT_SIGNING_KEY (independent rotation; backups travel, the signing key shouldn't). SMB/NFS work as mount paths; SFTP + admin UI + restore runbook are deferred slices. Tests: round-trip byte-identical, GCM tamper/wrong-key fail, short-key rejected, scratch cleaned, route auth/RBAC + 409. build/lint/test green (212 server tests). Wiki + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -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<BackupResult> | null = null;
|
||||
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
|
||||
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<void> {
|
||||
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
/** 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<void> {
|
||||
// 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<unknown> };
|
||||
await client.backup(destPath);
|
||||
}
|
||||
|
||||
/** Encrypt `srcPath` → `destPath` streaming, with the self-describing header. */
|
||||
async function encryptFile(srcPath: string, destPath: string, key: string): Promise<void> {
|
||||
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<void>((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<BackupResult> {
|
||||
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<number> {
|
||||
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<string>();
|
||||
// 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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<FastifyInsta
|
||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||
await logRoutes(app, logService);
|
||||
|
||||
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: status +
|
||||
// a manual "back up now"; the scheduled run is the daily timer below. A no-op until
|
||||
// BACKUP_TARGET_DIR + BACKUP_KEY are set. See wiki/concepts/backup-recovery.md.
|
||||
const backupService = new BackupService(db, backupConfigFromEnv(), app.log);
|
||||
await backupRoutes(app, backupService);
|
||||
|
||||
// Periodic retention prune (age + row cap) so the log table stays bounded on the
|
||||
// offline appliance. Runs hourly; unref'd so it never holds the process open.
|
||||
const pruneTimer = setInterval(() => {
|
||||
@@ -301,6 +309,18 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
void runSnapPrune(); // once at startup
|
||||
app.addHook("onClose", async () => 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.
|
||||
|
||||
Reference in New Issue
Block a user