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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user