diff --git a/apps/server/.env.example b/apps/server/.env.example index 1b6c92d..b954015 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -15,15 +15,17 @@ JWT_SECRET= # them (keyId), so verifyChain still validates a chain that spans a key change. EVENT_SIGNING_KEY= -# On-site encrypted DB backup (durability for the signed ledger). Disabled until both -# BACKUP_TARGET_DIR and BACKUP_KEY are set. A daily timer + an admin "back up now" button -# write a consistent, AES-256-GCM-encrypted copy to the target. RESTORE is an out-of-band -# runbook action, not a console call. See wiki/concepts/backup-recovery.md. -# BACKUP_TARGET_DIR=/mnt/backup # a mounted local/USB/SATA/SMB/NFS path +# On-site encrypted DB backup (durability for the signed ledger). A daily timer + an admin +# "back up now" button write a consistent, AES-256-GCM-encrypted copy to the target. The +# TARGET DIRECTORY is chosen by the admin in the UI (Setup → Backup) and stored in the DB — +# NOT here. Only the encryption KEY is an env secret. RESTORE is an out-of-band runbook action, +# not a console call. See wiki/concepts/backup-recovery.md. +# # Dedicated backup-encryption key (>=16 chars), SEPARATE from EVENT_SIGNING_KEY so it can # rotate without fracturing the signed chain. Generate with: openssl rand -hex 32 # Escrow it offsite (alongside EVENT_SIGNING_KEY) — recovery needs both, and neither is ever -# stored inside the backup it unlocks. +# stored inside the backup it unlocks. Backups stay a no-op until BOTH this key and an in-UI +# target directory are set. # BACKUP_KEY= # BACKUP_KEEP_LAST=7 # keep this many newest backups always # BACKUP_KEEP_DAILY_DAYS=30 # plus one-per-day within this window diff --git a/apps/server/src/backup-service.ts b/apps/server/src/backup-service.ts index 22c3e03..0535a87 100644 --- a/apps/server/src/backup-service.ts +++ b/apps/server/src/backup-service.ts @@ -1,23 +1,35 @@ -import type { Db } from "@parking/db"; +import { constants } from "node:fs"; +import { access, stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { eq, siteConfig, 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. +// Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen +// and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI +// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) — +// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can +// show last-success / last-error, and serializes concurrent runs (manual + timer). 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; +/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */ +export function backupKeyFromEnv(): string { + return process.env.BACKUP_KEY ?? ""; +} + +export interface TargetCheck { + readonly ok: boolean; + /** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */ + readonly reason?: string; } export interface BackupStatus { - /** True once a target dir + key are configured (otherwise backups are a no-op). */ + /** True once a target dir is set AND a usable key is present (else backups are a no-op). */ readonly configured: boolean; + /** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */ + readonly targetDir: string | null; + /** Whether the env key is present + long enough (the UI flags a missing key distinctly). */ + readonly keyPresent: boolean; readonly running: boolean; readonly lastSuccessAt: string | null; readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null; @@ -25,18 +37,28 @@ export interface BackupStatus { 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 ?? "", - }; +/** Probe a candidate target path server-side: exists, is a directory, is writable. */ +export async function checkTargetDir(dir: string): Promise { + const trimmed = dir.trim(); + if (!trimmed) return { ok: false, reason: "empty" }; + const path = resolve(trimmed); + let st: Awaited>; + try { + st = await stat(path); + } catch { + return { ok: false, reason: "missing" }; + } + if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" }; + try { + await access(path, constants.W_OK); + } catch { + return { ok: false, reason: "not_writable" }; + } + return { ok: true }; } export class BackupService { readonly #db: Db; - readonly #config: BackupConfig; readonly #logger?: FastifyBaseLogger; #running = false; @@ -45,19 +67,31 @@ export class BackupService { #lastErrorAt: string | null = null; #lastError: string | null = null; - constructor(db: Db, config: BackupConfig, logger?: FastifyBaseLogger) { + constructor(db: Db, logger?: FastifyBaseLogger) { this.#db = db; - this.#config = config; this.#logger = logger; } + /** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */ + targetDir(): string | null { + const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); + const dir = row?.backupTargetDir?.trim(); + return dir ? dir : null; + } + + get keyPresent(): boolean { + return backupKeyFromEnv().length >= 16; + } + get configured(): boolean { - return this.#config.targetDir.length > 0 && this.#config.key.length >= 16; + return this.targetDir() !== null && this.keyPresent; } status(): BackupStatus { return { configured: this.configured, + targetDir: this.targetDir(), + keyPresent: this.keyPresent, running: this.#running, lastSuccessAt: this.#lastSuccessAt, lastResult: this.#lastResult @@ -69,26 +103,24 @@ export class BackupService { } /** - * 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. + * Run one backup. `trigger` is just for the log line. Serialized: if one is already in + * flight, resolves to that same promise. Reads the target dir + key at run time. Records + * last-success/last-error. Re-throws on failure so a manual caller (the route) can surface + * it; the scheduled timer wraps + swallows. */ #inflight: Promise | null = null; async run(trigger: "manual" | "scheduled"): Promise { if (this.#inflight) return this.#inflight; - if (!this.configured) { - throw new Error("backup: not configured (set BACKUP_TARGET_DIR and BACKUP_KEY ≥16 chars)"); - } + const targetDir = this.targetDir(); + const key = backupKeyFromEnv(); + if (!targetDir) throw new Error("backup: no target directory configured"); + if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥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.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`); + const res = await runBackup(this.#db, { targetDir, key, retention: DEFAULT_BACKUP_RETENTION }, this.#logger); this.#lastResult = res; this.#lastSuccessAt = new Date().toISOString(); this.#lastError = null; diff --git a/apps/server/src/routes/backup.routes.test.ts b/apps/server/src/routes/backup.routes.test.ts index ceafb6f..afd8b24 100644 --- a/apps/server/src/routes/backup.routes.test.ts +++ b/apps/server/src/routes/backup.routes.test.ts @@ -49,6 +49,7 @@ describe("GET /api/backup/status", () => { const body = res.json(); expect(body).toMatchObject({ configured: false, + targetDir: null, running: false, lastSuccessAt: null, lastError: null, @@ -56,6 +57,71 @@ describe("GET /api/backup/status", () => { }); }); +describe("PUT /api/backup/config — admin-chosen target", () => { + it("403 for a user lacking backup:update", async () => { + const { username, password } = await seedUser(db, { + username: "viewer", roleId: "viewer", permissions: ["backup:read"], + }); + const { cookie, csrf } = await login(app, username, password); + const res = await app.inject({ + method: "PUT", url: "/api/backup/config", + headers: { cookie, "x-csrf-token": csrf }, + payload: { targetDir: "/tmp/x" }, + }); + expect(res.statusCode).toBe(403); + }); + + it("persists the target dir and reflects it in status", async () => { + const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); + const { cookie, csrf } = await login(app, username, password); + const put = await app.inject({ + method: "PUT", url: "/api/backup/config", + headers: { cookie, "x-csrf-token": csrf }, + payload: { targetDir: " /mnt/backup " }, // trimmed server-side + }); + expect(put.statusCode).toBe(200); + expect(put.json()).toMatchObject({ targetDir: "/mnt/backup" }); + + const status = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } }); + expect(status.json().targetDir).toBe("/mnt/backup"); + }); + + it("clears the target dir when given empty/null", async () => { + const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); + const { cookie, csrf } = await login(app, username, password); + await app.inject({ + method: "PUT", url: "/api/backup/config", + headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "/mnt/backup" }, + }); + const clear = await app.inject({ + method: "PUT", url: "/api/backup/config", + headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "" }, + }); + expect(clear.json().targetDir).toBeNull(); + }); +}); + +describe("POST /api/backup/test — path probe", () => { + it("reports ok for a writable directory and a reason for a missing one", async () => { + const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" }); + const { cookie, csrf } = await login(app, username, password); + + const good = await app.inject({ + method: "POST", url: "/api/backup/test", + headers: { cookie, "x-csrf-token": csrf }, + payload: { targetDir: process.cwd() }, // an existing, writable dir + }); + expect(good.json()).toMatchObject({ ok: true }); + + const bad = await app.inject({ + method: "POST", url: "/api/backup/test", + headers: { cookie, "x-csrf-token": csrf }, + payload: { targetDir: "/no/such/path/here-xyz" }, + }); + expect(bad.json()).toMatchObject({ ok: false, reason: "missing" }); + }); +}); + describe("POST /api/backup/run", () => { it("403 for a user lacking backup:create", async () => { const { username, password } = await seedUser(db, { diff --git a/apps/server/src/routes/backup.ts b/apps/server/src/routes/backup.ts index dca1c93..ecc2a97 100644 --- a/apps/server/src/routes/backup.ts +++ b/apps/server/src/routes/backup.ts @@ -1,18 +1,60 @@ import type { FastifyInstance } from "fastify"; +import { eq, siteConfig, type Db } from "@parking/db"; import { requirePermission } from "../auth.js"; -import type { BackupService } from "../backup-service.js"; +import { checkTargetDir, 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. +// - GET /api/backup/status : config + last-run success/error. (backup:read) +// - PUT /api/backup/config : set the admin-chosen target directory. (backup:update) +// - POST /api/backup/test : probe a candidate path (exists/dir/writable). (backup:update) +// - POST /api/backup/run : trigger a manual "back up now". (backup:create) +// The target dir lives in site_config (admin picks it from the UI); the encryption key stays an +// env secret. RESTORE is intentionally absent — out-of-band runbook on a fresh appliance. -export async function backupRoutes(app: FastifyInstance, backups: BackupService): Promise { +interface ConfigBody { + targetDir?: string | null; +} +interface TestBody { + targetDir?: string; +} + +export async function backupRoutes(app: FastifyInstance, db: Db, backups: BackupService): Promise { app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () => backups.status(), ); + // Set (or clear) the target directory. Empty/null clears it (backups become a no-op). + app.put<{ Body: ConfigBody }>( + "/api/backup/config", + { preHandler: requirePermission("backup:update") }, + async (req, reply) => { + const raw = req.body?.targetDir; + if (raw != null && typeof raw !== "string") { + return reply.code(400).send({ error: "targetDir must be a string or null" }); + } + const next = raw == null ? null : raw.trim() || null; + const updatedAt = new Date().toISOString(); + // Single-row site_config (id=1): upsert, since a fresh install may not have it yet. + const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); + if (existing) { + db.update(siteConfig).set({ backupTargetDir: next, updatedAt }).where(eq(siteConfig.id, 1)).run(); + } else { + db.insert(siteConfig).values({ id: 1, backupTargetDir: next, updatedAt }).run(); + } + return backups.status(); + }, + ); + + // Probe a candidate path before relying on it (the UI "Test target" button). + app.post<{ Body: TestBody }>( + "/api/backup/test", + { preHandler: requirePermission("backup:update") }, + async (req) => { + const dir = typeof req.body?.targetDir === "string" ? req.body.targetDir : ""; + return checkTargetDir(dir); + }, + ); + app.post("/api/backup/run", { preHandler: requirePermission("backup:create") }, async (_req, reply) => { if (!backups.configured) { return reply.code(409).send({ error: "backup_not_configured" }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index aec2113..385ddbb 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -21,7 +21,7 @@ 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 { BackupService } from "./backup-service.js"; import { backupRoutes } from "./routes/backup.js"; import { logRoutes } from "./routes/logs.js"; import { VisionClient } from "./vision-client.js"; @@ -276,11 +276,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise string): string { + if (c.ok) return t("backup.testOk"); + switch (c.reason) { + case "empty": + return t("backup.testEmpty"); + case "not_a_dir": + return t("backup.testNotDir"); + case "not_writable": + return t("backup.testNotWritable"); + default: + return t("backup.testMissing"); + } +} + +function StatusBadge({ status }: { status: BackupStatus }) { + const { t } = useTranslation(); + if (!status.configured) { + return {t("backup.notConfigured")}; + } + if (status.running) { + return {t("backup.running")}; + } + return {t("backup.configured")}; +} + +export function BackupSettings() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [toast, setToast] = useState<{ kind: "ok" | "err"; msg: string } | null>(null); + const [target, setTarget] = useState(""); + const [check, setCheck] = useState<{ kind: "ok" | "err"; msg: string } | null>(null); + + const q = useQuery({ + queryKey: ["backup-status"], + queryFn: fetchBackupStatus, + refetchInterval: (query) => (query.state.data?.running ? 2000 : false), + }); + const status = q.data; + + // Seed the editable field from the saved value once it loads (and when it changes server-side). + useEffect(() => { + if (status) setTarget(status.targetDir ?? ""); + }, [status?.targetDir]); + + const save = useMutation({ + mutationFn: () => setBackupTarget(target.trim() || null), + onSuccess: (next) => { + setToast({ kind: "ok", msg: t("backup.saved") }); + setCheck(null); + qc.setQueryData(["backup-status"], next); + }, + onError: () => setToast({ kind: "err", msg: t("backup.runFailed") }), + }); + + const test = useMutation({ + mutationFn: () => testBackupTarget(target.trim()), + onSuccess: (res) => setCheck({ kind: res.ok ? "ok" : "err", msg: checkMessage(res, t) }), + }); + + const run = useMutation({ + mutationFn: runBackup, + onSuccess: () => { + setToast({ kind: "ok", msg: t("backup.runSuccess") }); + void qc.invalidateQueries({ queryKey: ["backup-status"] }); + }, + onError: (err: unknown) => { + const code = err instanceof ApiError ? err.message : ""; + setToast({ + kind: "err", + msg: code === "backup_not_configured" ? t("backup.notConfiguredError") : t("backup.runFailed"), + }); + void qc.invalidateQueries({ queryKey: ["backup-status"] }); + }, + }); + + const dirty = (status?.targetDir ?? "") !== target.trim(); + + return ( +
+
+

{t("backup.title")}

+ +
+ +

{t("backup.intro")}

+ + {toast && ( +
+ {toast.msg} +
+ )} + + {/* Target directory — the admin-chosen destination. */} +
+
+ {t("backup.targetLabel")} +
+ { + setTarget(e.target.value); + setCheck(null); + }} + /> + + +
+ {t("backup.targetHint")} + {check && ( + + {check.msg} + + )} +
+
+ +
+ {q.isLoading || !status ? ( +
{t("common.loading")}
+ ) : ( +
+
{t("backup.statusTitle")}
+
+ +
+ + {!status.keyPresent && ( + <> +
+
{t("backup.keyMissing")}
+ + )} + +
{t("backup.lastSuccess")}
+
+ {status.lastSuccessAt ? formatRelativeDateTime(status.lastSuccessAt, t) : t("backup.never")} +
+ + {status.lastResult && ( + <> +
{t("backup.size")}
+
+ {formatBytes(status.lastResult.bytes)} + {status.lastResult.prunedFiles > 0 && ( + + ({t("backup.pruned")}: {status.lastResult.prunedFiles}) + + )} +
+ + )} + + {status.lastError && ( + <> +
{t("backup.lastError")}
+
+ {status.lastError} + {status.lastErrorAt && ( + + ({formatRelativeDateTime(status.lastErrorAt, t)}) + + )} +
+ + )} +
+ )} +
+ +

{t("backup.restoreNote")}

+
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index dd260eb..23bee5d 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -227,6 +227,54 @@ export function fetchLogs(params: { return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`); } +// --- Backup --------------------------------------------------------------- +// On-site encrypted DB backup. See wiki/concepts/backup-recovery.md. + +export interface BackupStatus { + configured: boolean; + /** Admin-chosen target directory (null = not set). */ + targetDir: string | null; + /** Whether the env encryption key is present (a missing key is flagged distinctly). */ + keyPresent: boolean; + running: boolean; + lastSuccessAt: string | null; + lastResult: { path: string; bytes: number; prunedFiles: number } | null; + lastErrorAt: string | null; + lastError: string | null; +} + +export async function fetchBackupStatus(): Promise { + return apiFetch("/api/backup/status"); +} + +/** Set (or clear, with "") the admin-chosen target directory. Returns the new status. */ +export async function setBackupTarget(targetDir: string | null): Promise { + return apiFetch("/api/backup/config", { method: "PUT", body: JSON.stringify({ targetDir }) }); +} + +export interface TargetCheck { + ok: boolean; + /** "empty" | "missing" | "not_a_dir" | "not_writable" when !ok. */ + reason?: string; +} + +/** Probe a candidate target path server-side (exists / is a dir / is writable). */ +export async function testBackupTarget(targetDir: string): Promise { + return apiFetch("/api/backup/test", { method: "POST", body: JSON.stringify({ targetDir }) }); +} + +export interface BackupRunResult { + ok: true; + path: string; + bytes: number; + prunedFiles: number; +} + +/** Trigger a manual "back up now". Throws on 409 (not configured) / 500 (run failed). */ +export async function runBackup(): Promise { + return apiFetch("/api/backup/run", { method: "POST" }); +} + // --- Device setup --------------------------------------------------------- export interface ConfigField { diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index d7f8211..8b6d787 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -61,6 +61,7 @@ export const en: Catalog = { reports: "Reports", recycleBin: "Recycle bin", logs: "Logs", + backup: "Backup", profile: "Profile", }, profile: { @@ -307,7 +308,7 @@ export const en: Catalog = { setup: { title: "Setup", intro: - "Add your barrier controllers first — set which relay is entry/exit and which terminal the entry button is wired to. Then add readers, cameras and printers and point each at the barrier it serves.", + "Add your barrier controllers first. Then add readers (QR/RF), cameras, printers", catControllers: "Controllers (barriers + entry button)", catReaders: "Readers (QR / RFID)", catCameras: "Cameras (snapshot + plate)", @@ -830,6 +831,41 @@ export const en: Catalog = { path: "Path", empty: "No logs.", }, + backup: { + title: "Backup", + intro: + "An encrypted copy of the database (the signed ledger) to an external disk. Runs automatically every day and from the button below.", + statusTitle: "Status", + configured: "Enabled", + notConfigured: "Not configured", + notConfiguredHint: "Set BACKUP_TARGET_DIR and BACKUP_KEY on the server to enable backups.", + running: "Running…", + idle: "Idle", + lastSuccess: "Last successful backup", + lastError: "Last error", + never: "Never", + lastFile: "File", + size: "Size", + pruned: "Pruned", + runNow: "Back up now", + runSuccess: "Backup complete.", + runFailed: "Backup failed.", + notConfiguredError: "Backup is not configured.", + restoreNote: + "Restore is not done here — it's an out-of-band step when provisioning a fresh appliance (needs the backup file + the keys you escrowed offsite).", + targetLabel: "Backup location", + targetPlaceholder: "e.g. /mnt/backup or /media/usb", + targetHint: "An absolute path to a mounted disk (USB/SATA) or a network share (SMB/NFS).", + save: "Save", + saved: "Saved.", + test: "Test target", + testOk: "The location is writable.", + testEmpty: "Enter a path.", + testMissing: "The location does not exist.", + testNotDir: "The path is not a directory.", + testNotWritable: "The directory is not writable.", + keyMissing: "The encryption key (BACKUP_KEY) is missing on the server — set it to enable backups.", + }, pay: { ticket: "Ticket", entry: "Entry", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 8154648..f7edda0 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -63,6 +63,7 @@ export const sq = { reports: "Raportet", recycleBin: "Koshi", logs: "Loget", + backup: "Kopje rezervë", profile: "Profili", }, profile: { @@ -310,7 +311,7 @@ export const sq = { setup: { title: "Konfigurimi", intro: - "Shto fillimisht kontrollerat e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.", + "Shto fillimisht kontrollerat e barrierave — Pastaj shto lexues (QR/RF), kamera, printera.", // Category titles + the singular noun used in buttons/modal titles. catControllers: "Kontrollerat (barrierat + butoni i hyrjes)", catReaders: "Lexuesit (QR / RFID)", @@ -845,6 +846,42 @@ export const sq = { path: "Rruga", empty: "Asnjë regjistër.", }, + backup: { + title: "Kopje rezervë", + intro: + "Kopje e enkriptuar e bazës së të dhënave (regjistri i nënshkruar) në një disk të jashtëm. Bëhet automatikisht çdo ditë dhe me butonin më poshtë.", + statusTitle: "Gjendja", + configured: "Aktive", + notConfigured: "E pakonfiguruar", + notConfiguredHint: + "Cakto BACKUP_TARGET_DIR dhe BACKUP_KEY në server që të aktivizohet kopja rezervë.", + running: "Duke u kryer…", + idle: "Në pritje", + lastSuccess: "Kopja e fundit e suksesshme", + lastError: "Gabimi i fundit", + never: "Asnjëherë", + lastFile: "Skedari", + size: "Madhësia", + pruned: "Të hequra", + runNow: "Bëj kopje tani", + runSuccess: "Kopja rezervë u krye.", + runFailed: "Kopja rezervë dështoi.", + notConfiguredError: "Kopja rezervë nuk është e konfiguruar.", + restoreNote: + "Rikthimi nuk bëhet nga këtu — është veprim i jashtëm gjatë instalimit të një aparati të ri (kërkon skedarin e kopjes + çelësat e ruajtur jashtë).", + targetLabel: "Vendndodhja e kopjes", + targetPlaceholder: "p.sh. /mnt/backup ose /media/usb", + targetHint: "Rrugë absolute drejt një disku të lidhur (USB/SATA) ose një ndarjeje rrjeti (SMB/NFS).", + save: "Ruaj", + saved: "U ruajt.", + test: "Testo vendndodhjen", + testOk: "Vendndodhja është e shkruajtshme.", + testEmpty: "Shkruaj një rrugë.", + testMissing: "Vendndodhja nuk ekziston.", + testNotDir: "Rruga nuk është një dosje.", + testNotWritable: "Dosja nuk është e shkruajtshme.", + keyMissing: "Çelësi i enkriptimit (BACKUP_KEY) mungon në server — caktoje që kopja të aktivizohet.", + }, pay: { ticket: "Bileta", entry: "Hyrja", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 3dd5fe0..fc8a6e3 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -42,6 +42,7 @@ import { UsersManager } from "./UsersManager.js"; import { RolesManager } from "./RolesManager.js"; import { ShiftsHistory } from "./ShiftsHistory.js"; import { LogsViewer } from "./LogsViewer.js"; +import { BackupSettings } from "./BackupSettings.js"; import { RecycleBin } from "./RecycleBin.js"; import { Profile } from "./Profile.js"; // Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's @@ -106,6 +107,7 @@ function SetupLayout() { {show("role:read") && } {show("recyclebin:read") && } {show("log:read") && } + {show("backup:read") && } @@ -444,6 +446,7 @@ function RootLayout() { show("user:read") || show("role:read") || show("recyclebin:read") || + show("backup:read") || show("shift:read")) && }
@@ -694,6 +697,14 @@ const logsRoute = createRoute({ component: LogsViewer, }); +// Encrypted DB backup — status + manual run. Gated by backup:read (run by backup:create). +const backupRoute = createRoute({ + getParentRoute: () => setupRoute, + path: "backup", + beforeLoad: ({ context }) => requirePerm("backup:read")(context), + component: BackupSettings, +}); + // My profile — self-service for ANY signed-in user (no permission gate). Edits only // the caller's own name/email/password. See Profile.tsx and routes/auth.ts. const profileRoute = createRoute({ @@ -726,6 +737,7 @@ const routeTree = rootRoute.addChildren([ rolesRoute, recycleBinRoute, logsRoute, + backupRoute, ]), ]); diff --git a/packages/db/drizzle/0016_backup_target_dir.sql b/packages/db/drizzle/0016_backup_target_dir.sql new file mode 100644 index 0000000..bde000d --- /dev/null +++ b/packages/db/drizzle/0016_backup_target_dir.sql @@ -0,0 +1,5 @@ +-- The on-site admin chooses where encrypted DB backups are written (a mounted USB/SATA/ +-- SMB/NFS path), from the Backup screen — not a server env var. Additive, nullable ALTER: +-- null = not configured (backups stay a no-op). The BACKUP_KEY stays an env/Komodo secret +-- (a key must NEVER live in the DB it backs up). See wiki/concepts/backup-recovery.md. +ALTER TABLE `site_config` ADD `backup_target_dir` text; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 3dd5a06..a5b14a7 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1781885800000, "tag": "0015_dingtian_qr_reader_driverid", "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1781885900000, + "tag": "0016_backup_target_dir", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 725d6e0..eaa5c07 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -264,6 +264,11 @@ export const siteConfig = sqliteTable("site_config", { * payload so V2 category pricing reprices identically at exit. null ⇒ the shared * DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */ defaultVehicleCategory: text("default_vehicle_category"), + /** Admin-chosen directory encrypted DB backups are written to — a mounted local/USB/ + * SATA/SMB/NFS path. null = not configured (backups are a no-op). Set from the Backup + * screen; the encryption key (BACKUP_KEY) stays an env/Komodo secret and is NEVER stored + * here (a key must not live in the DB it backs up). See wiki/concepts/backup-recovery.md. */ + backupTargetDir: text("backup_target_dir"), updatedAt: text("updated_at") .notNull() .default(sql`(current_timestamp)`), diff --git a/wiki/concepts/backup-recovery.md b/wiki/concepts/backup-recovery.md index 6fbb89a..bab7906 100644 --- a/wiki/concepts/backup-recovery.md +++ b/wiki/concepts/backup-recovery.md @@ -133,26 +133,36 @@ timer + the manual route**. What landed: **keep-last-N + one-per-day-within-N-days** (`pruneOldBackups`). Tested: round-trip decrypts to a **byte-identical, queryable DB**; a flipped byte or wrong key **fails GCM auth**; short key rejected; scratch plaintext always removed. -- **`backup-service.ts`** — resolves config from env (`BACKUP_TARGET_DIR`, `BACKUP_KEY`, - `BACKUP_KEEP_LAST`, `BACKUP_KEEP_DAILY_DAYS`), **serializes** concurrent runs (single in-flight - guard), records last-success / last-error for the UI. -- **`routes/backup.ts`** — `GET /api/backup/status` (`backup:read`) + `POST /api/backup/run` - (`backup:create`); a clean **409 `backup_not_configured`** when unset. New `backup` permission - resource (`backup:read/update/create`) in `@parking/shared`. **No restore route** — out-of-band by - design. +- **`backup-service.ts`** — the **target directory is admin-chosen** (`site_config.backup_target_dir`, + migration 0016) and read **fresh each run**, so changing it in the UI takes effect with no restart. + Only the **encryption key stays an env/Komodo secret** (`BACKUP_KEY`) — a key must never live in the + DB it backs up. Retention knobs (`BACKUP_KEEP_LAST`, `BACKUP_KEEP_DAILY_DAYS`) stay env. The service + **serializes** concurrent runs (single in-flight guard) and records last-success / last-error; + `status()` exposes `targetDir` + `keyPresent` so the UI distinguishes "no target" from "no key". +- **`routes/backup.ts`** — `GET /api/backup/status` (`backup:read`); `PUT /api/backup/config` to set/ + clear the target (`backup:update`); `POST /api/backup/test` to probe a candidate path server-side — + exists / is-a-dir / writable (`backup:update`); `POST /api/backup/run` (`backup:create`), a clean + **409 `backup_not_configured`** when target+key aren't both set. New `backup` permission resource + (`backup:read/update/create`) in `@parking/shared`. **No restore route** — out-of-band by design. +- **`apps/web/src/BackupSettings.tsx`** — a Setup → **Backup** tab (gated `backup:read`): an editable + **target-path field** with a **Test target** probe (localized ok/missing/not-a-dir/not-writable), + **Save**, the status panel (config state, last-run size/pruned/error, a distinct amber **missing + BACKUP_KEY** warning), a **Back up now** button, and the restore-is-out-of-band note. Full i18n + (sq + en). - **`server.ts`** — an **unref'd daily timer** (`backupService.runScheduled`), a **no-op until configured**, and **deliberately NOT run at startup** (a just-power-cut booth shouldn't write to a possibly-unmounted disk; the daily cadence + the manual button cover it). - Env documented in `apps/server/.env.example` (with the escrow + separate-key notes). -**SMB/NFS already work** — they're just a mounted path under `BACKUP_TARGET_DIR`. **Deferred to -follow-up slices:** an **SFTP** target, the **admin UI** (status panel + "Back up now" button + i18n), -and a **restore runbook / CLI**. +**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to +follow-up slices:** an **SFTP** target and a **restore runbook / CLI**. ## Status -Design settled 2026-06-29; **engine + local/mounted target BUILT 2026-06-29** (SFTP + UI + restore -tooling pending). Resolves the *design* half of [[open-questions]] #5 and the first build slice; records -the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and #10 (snapshots bloat -backups → future exclude toggle). See [[append-only-event-chain]], [[disk-os-hardening]], [[tpm]], -[[fleet-deployment-komodo]], [[reconciliation]]. +Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT +2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI** +(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only +`BACKUP_KEY` stays a server secret. Resolves the *design* half of [[open-questions]] #5 and the first +build slices; records the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and +#10 (snapshots bloat backups → future exclude toggle). See [[append-only-event-chain]], +[[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]], [[reconciliation]]. diff --git a/wiki/decisions/open-questions.md b/wiki/decisions/open-questions.md index ad268b2..e3161fe 100644 --- a/wiki/decisions/open-questions.md +++ b/wiki/decisions/open-questions.md @@ -23,9 +23,12 @@ procurement. (See [[parking-system-architecture]] §10.) 4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot, manager visit) to reconcile the signed log against an external authority — the real anti-fraud control. See [[reconciliation]]. -5. **Durability / backup.** _(Design SETTLED + first slice BUILT 2026-06-29 — see [[backup-recovery]]; - engine + local/mounted target + daily timer + manual route done, SFTP/UI/restore-tooling pending.)_ - A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history loss**. +5. **Durability / backup.** _(Design SETTLED + engine/target/UI BUILT 2026-06-29 — see + [[backup-recovery]]; engine + admin-configured target (site_config, migration 0016) + daily timer + + manual route + admin UI (target field, Test-target probe, status, Back-up-now) done; SFTP + + restore-tooling pending. The target dir is admin-chosen in the UI, not env; only BACKUP_KEY is a + server secret.)_ A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history + loss**. Settled design: an **admin-driven encrypted full-DB backup** (online-backup/`VACUUM INTO`, snapshots included) to a **local/USB · SMB/NFS · SFTP** target, **manual button + in-process daily timer**, **keep-last-N + dailies** retention, encrypted with a **dedicated `park_buzi_backup_key`** (separate diff --git a/wiki/log.md b/wiki/log.md index bfed85c..51c4c77 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1934,3 +1934,17 @@ route — out-of-band by design). New `backup` permission resource in @parking/s daily timer, no-op until configured, NOT run at startup. `openRawDb()` added to @parking/db/testing. SMB/NFS work as mount paths; SFTP + admin UI + restore runbook deferred. build/lint/test green (212 server tests, 25 files). Updated [[open-questions]] #5 (first slice BUILT). NOT yet committed beyond this branch. + +## [2026-06-29] feat | Backup admin UI + admin-chosen target (site_config, not env) +The backup TARGET DIRECTORY is now chosen by the on-site admin in the UI, not a server env var — env +target defeats the purpose (admin can't change where backups land without editing .env + restart). Moved +to `site_config.backup_target_dir` (migration 0016, nullable); BackupService reads it fresh each run (no +restart to change). Only BACKUP_KEY stays an env secret — a key must NEVER live in the DB it backs up. +New routes: PUT /api/backup/config (set/clear target, backup:update, upserts the id=1 row), POST +/api/backup/test (server-side path probe: exists/is-dir/writable, backup:update). status() now exposes +targetDir + keyPresent so the UI tells "no target" from "no key". UI: Setup → Backup tab +(apps/web/src/BackupSettings.tsx) — editable target field + Test-target probe (localized reasons) + Save + +status panel (distinct amber "BACKUP_KEY missing" warning) + Back-up-now + restore-out-of-band note; full +i18n sq+en; nav.backup. Verified live with Playwright: typed path → Test "writable" → Save persisted → +status reflects it + key-missing warning shown. build/lint/test green (whole monorepo). Updated +[[backup-recovery]] as-built + [[open-questions]] #5.