feat(backup): admin UI with admin-chosen target directory
The backup destination is now chosen by the on-site admin in the UI (Setup -> Backup), not a server env var. An env-pinned target defeats the purpose: the admin can't point backups at a freshly-plugged USB or a NAS mount without editing .env and restarting. The encryption key stays a server secret. Target storage: - New site_config.backup_target_dir (migration 0016, nullable; null = not configured). BackupService reads it fresh each run, so a UI change takes effect with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it backs up. Routes: - PUT /api/backup/config -- set/clear the target (backup:update; upserts id=1). - POST /api/backup/test -- probe a candidate path server-side (exists / is a directory / writable) so the admin gets feedback before relying on it. - status() now exposes targetDir + keyPresent, so the UI distinguishes 'no target set' from 'BACKUP_KEY missing'. UI (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-key warning), a Back up now button, and the restore-is-out-of-band note. Full i18n (sq + en); nav.backup. - API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup. Also includes a small in-progress copy trim to the setup-intro i18n strings. Verified live with Playwright: typed a path -> Test reported writable -> Save persisted it -> status reflected it and showed the key-missing warning. Whole monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -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<TargetCheck> {
|
||||
const trimmed = dir.trim();
|
||||
if (!trimmed) return { ok: false, reason: "empty" };
|
||||
const path = resolve(trimmed);
|
||||
let st: Awaited<ReturnType<typeof stat>>;
|
||||
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<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)");
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<void> {
|
||||
interface ConfigBody {
|
||||
targetDir?: string | null;
|
||||
}
|
||||
interface TestBody {
|
||||
targetDir?: string;
|
||||
}
|
||||
|
||||
export async function backupRoutes(app: FastifyInstance, db: Db, backups: BackupService): Promise<void> {
|
||||
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" });
|
||||
|
||||
@@ -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<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);
|
||||
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: the target
|
||||
// directory is admin-chosen (site_config), the key is an env secret; status + a manual "back
|
||||
// up now"; the scheduled run is the daily timer below. A no-op until a target dir is set AND
|
||||
// BACKUP_KEY is present. See wiki/concepts/backup-recovery.md.
|
||||
const backupService = new BackupService(db, app.log);
|
||||
await backupRoutes(app, db, 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.
|
||||
|
||||
Reference in New Issue
Block a user