84f00db48b
Retention (keep-last / keep-daily-days) is operational policy the on-site admin should tune, not a server env var requiring a redeploy -- same reasoning that moved the target directory to the UI. - Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable; null = code default 7 / 30 per field). - BackupService reads retention fresh each run; status() exposes keepLast + keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone). - PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null to reset to default; 400 on negative). - UI: two retention fields on the Backup config card; one Save covers target + retention. i18n sq + en. BACKUP_KEY wired into Komodo: - komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret, alongside JWT / signing keys). - komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY. Also carries the small in-progress setup-intro i18n copy trim. Tests: 218 server tests green, incl. retention persist / reset-to-default / reject- negative and the updated status shape. Migration applies cleanly (needed a statement-breakpoint between the two ALTERs). Wiki backup-recovery updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
218 lines
9.1 KiB
TypeScript
218 lines
9.1 KiB
TypeScript
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;
|
|
}
|
|
|
|
// Code defaults — the fallback when the admin hasn't set a value in site_config (the source of
|
|
// truth). NOT env-driven: retention is operational policy tuned from the Backup screen.
|
|
export const DEFAULT_BACKUP_RETENTION: BackupRetention = {
|
|
keepLast: 7,
|
|
keepDailyDays: 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;
|
|
}
|