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:
@@ -15,15 +15,17 @@ JWT_SECRET=
|
|||||||
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
# them (keyId), so verifyChain still validates a chain that spans a key change.
|
||||||
EVENT_SIGNING_KEY=
|
EVENT_SIGNING_KEY=
|
||||||
|
|
||||||
# On-site encrypted DB backup (durability for the signed ledger). Disabled until both
|
# On-site encrypted DB backup (durability for the signed ledger). A daily timer + an admin
|
||||||
# BACKUP_TARGET_DIR and BACKUP_KEY are set. A daily timer + an admin "back up now" button
|
# "back up now" button write a consistent, AES-256-GCM-encrypted copy to the target. The
|
||||||
# write a consistent, AES-256-GCM-encrypted copy to the target. RESTORE is an out-of-band
|
# TARGET DIRECTORY is chosen by the admin in the UI (Setup → Backup) and stored in the DB —
|
||||||
# runbook action, not a console call. See wiki/concepts/backup-recovery.md.
|
# NOT here. Only the encryption KEY is an env secret. RESTORE is an out-of-band runbook action,
|
||||||
# BACKUP_TARGET_DIR=/mnt/backup # a mounted local/USB/SATA/SMB/NFS path
|
# not a console call. See wiki/concepts/backup-recovery.md.
|
||||||
|
#
|
||||||
# Dedicated backup-encryption key (>=16 chars), SEPARATE from EVENT_SIGNING_KEY so it can
|
# 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
|
# 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
|
# 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_KEY=
|
||||||
# BACKUP_KEEP_LAST=7 # keep this many newest backups always
|
# BACKUP_KEEP_LAST=7 # keep this many newest backups always
|
||||||
# BACKUP_KEEP_DAILY_DAYS=30 # plus one-per-day within this window
|
# BACKUP_KEEP_DAILY_DAYS=30 # plus one-per-day within this window
|
||||||
|
|||||||
@@ -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 type { FastifyBaseLogger } from "fastify";
|
||||||
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult } from "./backup.js";
|
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult } from "./backup.js";
|
||||||
|
|
||||||
// Thin coordinator around the backup engine (backup.ts): resolves config once, runs a backup
|
// Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen
|
||||||
// (manual or scheduled), and remembers the last outcome so the route + UI can show last-success
|
// and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI
|
||||||
// / last-error without re-deriving it. One instance is shared by the daily timer and the
|
// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) —
|
||||||
// "back up now" route, so a concurrent manual+timer run can't overlap (a single in-flight guard).
|
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
||||||
// See wiki/concepts/backup-recovery.md.
|
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||||
|
// wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
export interface BackupConfig {
|
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||||
/** Mounted directory backups are written to (local/USB/SATA/SMB/NFS). Empty = disabled. */
|
export function backupKeyFromEnv(): string {
|
||||||
readonly targetDir: string;
|
return process.env.BACKUP_KEY ?? "";
|
||||||
/** Encryption key (BACKUP_KEY / park_buzi_backup_key). */
|
}
|
||||||
readonly key: string;
|
|
||||||
|
export interface TargetCheck {
|
||||||
|
readonly ok: boolean;
|
||||||
|
/** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */
|
||||||
|
readonly reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupStatus {
|
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;
|
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 running: boolean;
|
||||||
readonly lastSuccessAt: string | null;
|
readonly lastSuccessAt: string | null;
|
||||||
readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null;
|
readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null;
|
||||||
@@ -25,18 +37,28 @@ export interface BackupStatus {
|
|||||||
readonly lastError: string | null;
|
readonly lastError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve backup config from env. (First cut: env-driven, like EVENT_SIGNING_KEY + snapshot
|
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
|
||||||
* retention; a future admin-UI knob can override the target dir.) */
|
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
|
||||||
export function backupConfigFromEnv(): BackupConfig {
|
const trimmed = dir.trim();
|
||||||
return {
|
if (!trimmed) return { ok: false, reason: "empty" };
|
||||||
targetDir: (process.env.BACKUP_TARGET_DIR ?? "").trim(),
|
const path = resolve(trimmed);
|
||||||
key: process.env.BACKUP_KEY ?? "",
|
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 {
|
export class BackupService {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #config: BackupConfig;
|
|
||||||
readonly #logger?: FastifyBaseLogger;
|
readonly #logger?: FastifyBaseLogger;
|
||||||
|
|
||||||
#running = false;
|
#running = false;
|
||||||
@@ -45,19 +67,31 @@ export class BackupService {
|
|||||||
#lastErrorAt: string | null = null;
|
#lastErrorAt: string | null = null;
|
||||||
#lastError: string | null = null;
|
#lastError: string | null = null;
|
||||||
|
|
||||||
constructor(db: Db, config: BackupConfig, logger?: FastifyBaseLogger) {
|
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||||
this.#db = db;
|
this.#db = db;
|
||||||
this.#config = config;
|
|
||||||
this.#logger = logger;
|
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 {
|
get configured(): boolean {
|
||||||
return this.#config.targetDir.length > 0 && this.#config.key.length >= 16;
|
return this.targetDir() !== null && this.keyPresent;
|
||||||
}
|
}
|
||||||
|
|
||||||
status(): BackupStatus {
|
status(): BackupStatus {
|
||||||
return {
|
return {
|
||||||
configured: this.configured,
|
configured: this.configured,
|
||||||
|
targetDir: this.targetDir(),
|
||||||
|
keyPresent: this.keyPresent,
|
||||||
running: this.#running,
|
running: this.#running,
|
||||||
lastSuccessAt: this.#lastSuccessAt,
|
lastSuccessAt: this.#lastSuccessAt,
|
||||||
lastResult: this.#lastResult
|
lastResult: this.#lastResult
|
||||||
@@ -69,26 +103,24 @@ export class BackupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one backup. `trigger` is just for the log line ("manual" | "scheduled"). Serialized:
|
* Run one backup. `trigger` is just for the log line. Serialized: if one is already in
|
||||||
* if one is already in flight, this resolves to that same promise rather than starting a
|
* flight, resolves to that same promise. Reads the target dir + key at run time. Records
|
||||||
* second. Records last-success/last-error on the instance. Re-throws on failure so a manual
|
* last-success/last-error. Re-throws on failure so a manual caller (the route) can surface
|
||||||
* caller (the route) can surface it; the scheduled timer wraps + swallows.
|
* it; the scheduled timer wraps + swallows.
|
||||||
*/
|
*/
|
||||||
#inflight: Promise<BackupResult> | null = null;
|
#inflight: Promise<BackupResult> | null = null;
|
||||||
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
|
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
|
||||||
if (this.#inflight) return this.#inflight;
|
if (this.#inflight) return this.#inflight;
|
||||||
if (!this.configured) {
|
const targetDir = this.targetDir();
|
||||||
throw new Error("backup: not configured (set BACKUP_TARGET_DIR and BACKUP_KEY ≥16 chars)");
|
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.#running = true;
|
||||||
this.#inflight = (async () => {
|
this.#inflight = (async () => {
|
||||||
try {
|
try {
|
||||||
this.#logger?.info(`backup: starting (${trigger})`);
|
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||||
const res = await runBackup(
|
const res = await runBackup(this.#db, { targetDir, key, retention: DEFAULT_BACKUP_RETENTION }, this.#logger);
|
||||||
this.#db,
|
|
||||||
{ targetDir: this.#config.targetDir, key: this.#config.key, retention: DEFAULT_BACKUP_RETENTION },
|
|
||||||
this.#logger,
|
|
||||||
);
|
|
||||||
this.#lastResult = res;
|
this.#lastResult = res;
|
||||||
this.#lastSuccessAt = new Date().toISOString();
|
this.#lastSuccessAt = new Date().toISOString();
|
||||||
this.#lastError = null;
|
this.#lastError = null;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ describe("GET /api/backup/status", () => {
|
|||||||
const body = res.json();
|
const body = res.json();
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
configured: false,
|
configured: false,
|
||||||
|
targetDir: null,
|
||||||
running: false,
|
running: false,
|
||||||
lastSuccessAt: null,
|
lastSuccessAt: null,
|
||||||
lastError: 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", () => {
|
describe("POST /api/backup/run", () => {
|
||||||
it("403 for a user lacking backup:create", async () => {
|
it("403 for a user lacking backup:create", async () => {
|
||||||
const { username, password } = await seedUser(db, {
|
const { username, password } = await seedUser(db, {
|
||||||
|
|||||||
@@ -1,18 +1,60 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { eq, siteConfig, type Db } from "@parking/db";
|
||||||
import { requirePermission } from "../auth.js";
|
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.
|
// 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)
|
// - GET /api/backup/status : config + last-run success/error. (backup:read)
|
||||||
// - POST /api/backup/run : trigger a manual "back up now". (backup:create)
|
// - PUT /api/backup/config : set the admin-chosen target directory. (backup:update)
|
||||||
// RESTORE is intentionally absent — it's an out-of-band runbook action on a fresh appliance
|
// - POST /api/backup/test : probe a candidate path (exists/dir/writable). (backup:update)
|
||||||
// (a restore replaces the live signed chain → operator-adversary surface), never a console call.
|
// - 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 () =>
|
app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () =>
|
||||||
backups.status(),
|
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) => {
|
app.post("/api/backup/run", { preHandler: requirePermission("backup:create") }, async (_req, reply) => {
|
||||||
if (!backups.configured) {
|
if (!backups.configured) {
|
||||||
return reply.code(409).send({ error: "backup_not_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 { buildSigner, buildVerifier } from "./signer.js";
|
||||||
import { LogService, pinoDbStream } from "./log-service.js";
|
import { LogService, pinoDbStream } from "./log-service.js";
|
||||||
import { pruneSnapshots } from "./snapshot-retention.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 { backupRoutes } from "./routes/backup.js";
|
||||||
import { logRoutes } from "./routes/logs.js";
|
import { logRoutes } from "./routes/logs.js";
|
||||||
import { VisionClient } from "./vision-client.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.
|
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||||
await logRoutes(app, logService);
|
await logRoutes(app, logService);
|
||||||
|
|
||||||
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: status +
|
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: the target
|
||||||
// a manual "back up now"; the scheduled run is the daily timer below. A no-op until
|
// directory is admin-chosen (site_config), the key is an env secret; status + a manual "back
|
||||||
// BACKUP_TARGET_DIR + BACKUP_KEY are set. See wiki/concepts/backup-recovery.md.
|
// up now"; the scheduled run is the daily timer below. A no-op until a target dir is set AND
|
||||||
const backupService = new BackupService(db, backupConfigFromEnv(), app.log);
|
// BACKUP_KEY is present. See wiki/concepts/backup-recovery.md.
|
||||||
await backupRoutes(app, backupService);
|
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
|
// 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.
|
// offline appliance. Runs hourly; unref'd so it never holds the process open.
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
fetchBackupStatus,
|
||||||
|
runBackup,
|
||||||
|
setBackupTarget,
|
||||||
|
testBackupTarget,
|
||||||
|
type BackupStatus,
|
||||||
|
type TargetCheck,
|
||||||
|
} from "./api.js";
|
||||||
|
import { formatRelativeDateTime } from "./lib/format.js";
|
||||||
|
|
||||||
|
// Admin screen for the on-site encrypted DB backup. The admin picks the TARGET DIRECTORY here
|
||||||
|
// (stored in site_config; a mounted USB/SATA/SMB/NFS path) — the encryption key stays a server
|
||||||
|
// secret. Shows status + last-run outcome, a "Test target" probe, and a manual "Back up now".
|
||||||
|
// Gated by backup:read (config/test by backup:update, run by backup:create). RESTORE is absent
|
||||||
|
// by design — out-of-band on a fresh appliance. See wiki/concepts/backup-recovery.md.
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
const mb = n / 1048576;
|
||||||
|
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||||
|
return `${(mb / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a target-check result to a localized message. */
|
||||||
|
function checkMessage(c: TargetCheck, t: (k: string) => 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 <span className="text-[0.75rem] font-semibold text-term-muted">{t("backup.notConfigured")}</span>;
|
||||||
|
}
|
||||||
|
if (status.running) {
|
||||||
|
return <span className="text-[0.75rem] font-semibold text-term-amber">{t("backup.running")}</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-[0.75rem] font-semibold text-term-green">{t("backup.configured")}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("backup.title")}</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={!status?.configured || status?.running || run.isPending || dirty}
|
||||||
|
onClick={() => {
|
||||||
|
setToast(null);
|
||||||
|
run.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status?.running || run.isPending ? t("backup.running") : t("backup.runNow")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-3 max-w-2xl text-[0.75rem] text-term-muted">{t("backup.intro")}</p>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
className={`mb-3 rounded-term border px-3 py-2 text-[0.75rem] ${
|
||||||
|
toast.kind === "ok"
|
||||||
|
? "border-term-green/40 bg-term-green/5 text-term-green"
|
||||||
|
: "border-term-red/40 bg-term-red/5 text-term-red"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Target directory — the admin-chosen destination. */}
|
||||||
|
<div className="card mb-3 p-4">
|
||||||
|
<div className="field">
|
||||||
|
<span className="label">{t("backup.targetLabel")}</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="input w-96 max-w-full"
|
||||||
|
value={target}
|
||||||
|
placeholder={t("backup.targetPlaceholder")}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTarget(e.target.value);
|
||||||
|
setCheck(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
disabled={test.isPending || !target.trim()}
|
||||||
|
onClick={() => test.mutate()}
|
||||||
|
>
|
||||||
|
{t("backup.test")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={save.isPending || !dirty}
|
||||||
|
onClick={() => {
|
||||||
|
setToast(null);
|
||||||
|
save.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("backup.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.targetHint")}</span>
|
||||||
|
{check && (
|
||||||
|
<span className={`mt-1 text-[0.75rem] ${check.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
|
||||||
|
{check.msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
{q.isLoading || !status ? (
|
||||||
|
<div className="text-[0.75rem] text-term-muted">{t("common.loading")}</div>
|
||||||
|
) : (
|
||||||
|
<dl className="grid grid-cols-[10rem_1fr] gap-x-4 gap-y-2 text-[0.8125rem]">
|
||||||
|
<dt className="text-term-muted">{t("backup.statusTitle")}</dt>
|
||||||
|
<dd>
|
||||||
|
<StatusBadge status={status} />
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
{!status.keyPresent && (
|
||||||
|
<>
|
||||||
|
<dt className="text-term-muted" />
|
||||||
|
<dd className="text-[0.75rem] text-term-amber">{t("backup.keyMissing")}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<dt className="text-term-muted">{t("backup.lastSuccess")}</dt>
|
||||||
|
<dd className="text-term-text">
|
||||||
|
{status.lastSuccessAt ? formatRelativeDateTime(status.lastSuccessAt, t) : t("backup.never")}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
{status.lastResult && (
|
||||||
|
<>
|
||||||
|
<dt className="text-term-muted">{t("backup.size")}</dt>
|
||||||
|
<dd className="text-term-text tabular-nums">
|
||||||
|
{formatBytes(status.lastResult.bytes)}
|
||||||
|
{status.lastResult.prunedFiles > 0 && (
|
||||||
|
<span className="ml-2 text-term-muted">
|
||||||
|
({t("backup.pruned")}: {status.lastResult.prunedFiles})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status.lastError && (
|
||||||
|
<>
|
||||||
|
<dt className="text-term-muted">{t("backup.lastError")}</dt>
|
||||||
|
<dd className="text-term-red">
|
||||||
|
{status.lastError}
|
||||||
|
{status.lastErrorAt && (
|
||||||
|
<span className="ml-2 text-term-muted">
|
||||||
|
({formatRelativeDateTime(status.lastErrorAt, t)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 max-w-2xl text-[0.6875rem] text-term-muted">{t("backup.restoreNote")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -227,6 +227,54 @@ export function fetchLogs(params: {
|
|||||||
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
|
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<BackupStatus> {
|
||||||
|
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<BackupStatus> {
|
||||||
|
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<TargetCheck> {
|
||||||
|
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<BackupRunResult> {
|
||||||
|
return apiFetch("/api/backup/run", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
// --- Device setup ---------------------------------------------------------
|
// --- Device setup ---------------------------------------------------------
|
||||||
|
|
||||||
export interface ConfigField {
|
export interface ConfigField {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export const en: Catalog = {
|
|||||||
reports: "Reports",
|
reports: "Reports",
|
||||||
recycleBin: "Recycle bin",
|
recycleBin: "Recycle bin",
|
||||||
logs: "Logs",
|
logs: "Logs",
|
||||||
|
backup: "Backup",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
@@ -307,7 +308,7 @@ export const en: Catalog = {
|
|||||||
setup: {
|
setup: {
|
||||||
title: "Setup",
|
title: "Setup",
|
||||||
intro:
|
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)",
|
catControllers: "Controllers (barriers + entry button)",
|
||||||
catReaders: "Readers (QR / RFID)",
|
catReaders: "Readers (QR / RFID)",
|
||||||
catCameras: "Cameras (snapshot + plate)",
|
catCameras: "Cameras (snapshot + plate)",
|
||||||
@@ -830,6 +831,41 @@ export const en: Catalog = {
|
|||||||
path: "Path",
|
path: "Path",
|
||||||
empty: "No logs.",
|
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: {
|
pay: {
|
||||||
ticket: "Ticket",
|
ticket: "Ticket",
|
||||||
entry: "Entry",
|
entry: "Entry",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const sq = {
|
|||||||
reports: "Raportet",
|
reports: "Raportet",
|
||||||
recycleBin: "Koshi",
|
recycleBin: "Koshi",
|
||||||
logs: "Loget",
|
logs: "Loget",
|
||||||
|
backup: "Kopje rezervë",
|
||||||
profile: "Profili",
|
profile: "Profili",
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
@@ -310,7 +311,7 @@ export const sq = {
|
|||||||
setup: {
|
setup: {
|
||||||
title: "Konfigurimi",
|
title: "Konfigurimi",
|
||||||
intro:
|
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.
|
// Category titles + the singular noun used in buttons/modal titles.
|
||||||
catControllers: "Kontrollerat (barrierat + butoni i hyrjes)",
|
catControllers: "Kontrollerat (barrierat + butoni i hyrjes)",
|
||||||
catReaders: "Lexuesit (QR / RFID)",
|
catReaders: "Lexuesit (QR / RFID)",
|
||||||
@@ -845,6 +846,42 @@ export const sq = {
|
|||||||
path: "Rruga",
|
path: "Rruga",
|
||||||
empty: "Asnjë regjistër.",
|
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: {
|
pay: {
|
||||||
ticket: "Bileta",
|
ticket: "Bileta",
|
||||||
entry: "Hyrja",
|
entry: "Hyrja",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { UsersManager } from "./UsersManager.js";
|
|||||||
import { RolesManager } from "./RolesManager.js";
|
import { RolesManager } from "./RolesManager.js";
|
||||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||||
import { LogsViewer } from "./LogsViewer.js";
|
import { LogsViewer } from "./LogsViewer.js";
|
||||||
|
import { BackupSettings } from "./BackupSettings.js";
|
||||||
import { RecycleBin } from "./RecycleBin.js";
|
import { RecycleBin } from "./RecycleBin.js";
|
||||||
import { Profile } from "./Profile.js";
|
import { Profile } from "./Profile.js";
|
||||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||||
@@ -106,6 +107,7 @@ function SetupLayout() {
|
|||||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||||
|
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
@@ -444,6 +446,7 @@ function RootLayout() {
|
|||||||
show("user:read") ||
|
show("user:read") ||
|
||||||
show("role:read") ||
|
show("role:read") ||
|
||||||
show("recyclebin:read") ||
|
show("recyclebin:read") ||
|
||||||
|
show("backup:read") ||
|
||||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
@@ -694,6 +697,14 @@ const logsRoute = createRoute({
|
|||||||
component: LogsViewer,
|
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
|
// 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.
|
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
|
||||||
const profileRoute = createRoute({
|
const profileRoute = createRoute({
|
||||||
@@ -726,6 +737,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
rolesRoute,
|
rolesRoute,
|
||||||
recycleBinRoute,
|
recycleBinRoute,
|
||||||
logsRoute,
|
logsRoute,
|
||||||
|
backupRoute,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -113,6 +113,13 @@
|
|||||||
"when": 1781885800000,
|
"when": 1781885800000,
|
||||||
"tag": "0015_dingtian_qr_reader_driverid",
|
"tag": "0015_dingtian_qr_reader_driverid",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781885900000,
|
||||||
|
"tag": "0016_backup_target_dir",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -264,6 +264,11 @@ export const siteConfig = sqliteTable("site_config", {
|
|||||||
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared
|
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared
|
||||||
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
|
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
|
||||||
defaultVehicleCategory: text("default_vehicle_category"),
|
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")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
|
|||||||
@@ -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
|
**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;
|
**byte-identical, queryable DB**; a flipped byte or wrong key **fails GCM auth**; short key rejected;
|
||||||
scratch plaintext always removed.
|
scratch plaintext always removed.
|
||||||
- **`backup-service.ts`** — resolves config from env (`BACKUP_TARGET_DIR`, `BACKUP_KEY`,
|
- **`backup-service.ts`** — the **target directory is admin-chosen** (`site_config.backup_target_dir`,
|
||||||
`BACKUP_KEEP_LAST`, `BACKUP_KEEP_DAILY_DAYS`), **serializes** concurrent runs (single in-flight
|
migration 0016) and read **fresh each run**, so changing it in the UI takes effect with no restart.
|
||||||
guard), records last-success / last-error for the UI.
|
Only the **encryption key stays an env/Komodo secret** (`BACKUP_KEY`) — a key must never live in the
|
||||||
- **`routes/backup.ts`** — `GET /api/backup/status` (`backup:read`) + `POST /api/backup/run`
|
DB it backs up. Retention knobs (`BACKUP_KEEP_LAST`, `BACKUP_KEEP_DAILY_DAYS`) stay env. The service
|
||||||
(`backup:create`); a clean **409 `backup_not_configured`** when unset. New `backup` permission
|
**serializes** concurrent runs (single in-flight guard) and records last-success / last-error;
|
||||||
resource (`backup:read/update/create`) in `@parking/shared`. **No restore route** — out-of-band by
|
`status()` exposes `targetDir` + `keyPresent` so the UI distinguishes "no target" from "no key".
|
||||||
design.
|
- **`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
|
- **`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
|
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).
|
possibly-unmounted disk; the daily cadence + the manual button cover it).
|
||||||
- Env documented in `apps/server/.env.example` (with the escrow + separate-key notes).
|
- 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
|
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
|
||||||
follow-up slices:** an **SFTP** target, the **admin UI** (status panel + "Back up now" button + i18n),
|
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
|
||||||
and a **restore runbook / CLI**.
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Design settled 2026-06-29; **engine + local/mounted target BUILT 2026-06-29** (SFTP + UI + restore
|
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
|
||||||
tooling pending). Resolves the *design* half of [[open-questions]] #5 and the first build slice; records
|
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
|
||||||
the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and #10 (snapshots bloat
|
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
|
||||||
backups → future exclude toggle). See [[append-only-event-chain]], [[disk-os-hardening]], [[tpm]],
|
`BACKUP_KEY` stays a server secret. Resolves the *design* half of [[open-questions]] #5 and the first
|
||||||
[[fleet-deployment-komodo]], [[reconciliation]].
|
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]].
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ procurement. (See [[parking-system-architecture]] §10.)
|
|||||||
4. **Reconciliation channel.** Even if "offline," establish *some* periodic path (USB, hotspot,
|
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
|
manager visit) to reconcile the signed log against an external authority — the real anti-fraud
|
||||||
control. See [[reconciliation]].
|
control. See [[reconciliation]].
|
||||||
5. **Durability / backup.** _(Design SETTLED + first slice BUILT 2026-06-29 — see [[backup-recovery]];
|
5. **Durability / backup.** _(Design SETTLED + engine/target/UI BUILT 2026-06-29 — see
|
||||||
engine + local/mounted target + daily timer + manual route done, SFTP/UI/restore-tooling pending.)_
|
[[backup-recovery]]; engine + admin-configured target (site_config, migration 0016) + daily timer +
|
||||||
A disk failure / stolen-or-destroyed PC currently leaves **total revenue-history loss**.
|
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
|
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**,
|
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
|
**keep-last-N + dailies** retention, encrypted with a **dedicated `park_buzi_backup_key`** (separate
|
||||||
|
|||||||
+14
@@ -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.
|
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
|
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.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user