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:
@@ -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