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