From aa4e91491bc6c8be08863b71b920d1855b1295be Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Thu, 14 May 2026 07:44:01 +0200 Subject: [PATCH] feat(server): backup + restore (AES-256-GCM, PBKDF2, admin UI) --- .../src/plugins/service-admin-http/index.ts | 2 + .../service-admin-http/routes-admin.ts | 59 ++++++++ server/src/shared/backup.ts | 128 ++++++++++++++++++ server/src/web-templates/admin-pages.tsx | 59 ++++++++ server/src/web-templates/layout.tsx | 1 + 5 files changed, 249 insertions(+) create mode 100644 server/src/shared/backup.ts diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index bd44a7b..856533f 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -84,6 +84,7 @@ export interface AdminDeps { cookieName: string; nodered: NoderedBridge; firmware: FirmwareApi; + dataDir: string; } // ---- Plugin ----------------------------------------------------------------- @@ -146,6 +147,7 @@ export class Plugin extends BSBService, typeof Event cookieName, nodered, firmware, + dataDir, }; const app = new H3(); diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index fae9016..0763d83 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -13,6 +13,7 @@ import { CameraEditPage, CameraDiscoverPage, AuditLogPage, + BackupPage, CameraDiscoverResultsPage, EntitiesPage, EntityNewPage, @@ -39,6 +40,7 @@ import { generateBundle } from "../../shared/bundle.js"; import { captureSnapshot } from "../../shared/snapshot.js"; import { stripSecrets } from "../../shared/strip-secrets.js"; import { audit } from "../../shared/audit.js"; +import { createBackup, restoreBackup } from "../../shared/backup.js"; interface DiscoverAddStream { profile_name: string; @@ -271,6 +273,63 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 301, headers: { location: "/admin/" } }); }); + // ---- Backup / restore ----------------------------------------------------- + + app.get("/admin/backup", (event) => { + const user = event.context.user!; + return htmlPage(BackupPage({ user: user.username })); + }); + + app.post("/admin/backup/download", async (event) => { + const body = await readBody>(event); + const pass = body?.["passphrase"] ?? ""; + let res; + try { + res = createBackup(deps.dataDir, pass); + } catch (err) { + audit(deps.repo, event as any, "backup.create", { + result: "failed", metadata: { error: (err as Error).message }, + }); + return htmlPage(BackupPage({ user: event.context.user!.username, error: (err as Error).message })); + } + audit(deps.repo, event as any, "backup.create", { + metadata: { file_count: res.fileCount, size: res.blob.length }, + }); + return new Response(new Uint8Array(res.blob), { + status: 200, + headers: { + "content-type": "application/octet-stream", + "content-disposition": `attachment; filename="${res.filename}"`, + "content-length": String(res.blob.length), + }, + }); + }); + + app.post("/admin/backup/restore", async (event) => { + const form = await event.req.formData(); + const file = form.get("blob"); + const pass = String(form.get("passphrase") ?? ""); + if (!(file instanceof File) || !pass) { + return htmlPage(BackupPage({ user: event.context.user!.username, error: "blob + passphrase required" })); + } + try { + const buf = Buffer.from(await file.arrayBuffer()); + const res = restoreBackup(deps.dataDir, pass, buf); + audit(deps.repo, event as any, "backup.restore", { + metadata: { file_count: res.fileCount, files: res.files }, + }); + return htmlPage(BackupPage({ + user: event.context.user!.username, + success: `Restored ${String(res.fileCount)} files: ${res.files.join(", ")}. RESTART THE SERVER NOW for changes to take effect.`, + })); + } catch (err) { + audit(deps.repo, event as any, "backup.restore", { + result: "failed", metadata: { error: (err as Error).message }, + }); + return htmlPage(BackupPage({ user: event.context.user!.username, error: (err as Error).message })); + } + }); + // ---- Audit log ------------------------------------------------------------ app.get("/admin/audit", (event) => { diff --git a/server/src/shared/backup.ts b/server/src/shared/backup.ts new file mode 100644 index 0000000..92506b5 --- /dev/null +++ b/server/src/shared/backup.ts @@ -0,0 +1,128 @@ +/** + * Encrypted server backup / restore. + * + * Bundles the SQLite DB + master secret + firmware signing keypair into a + * single `.bfbak` blob encrypted with AES-256-GCM. Key is derived from an + * admin-supplied passphrase via PBKDF2(SHA-256, 200k iters). + * + * format: "bfbak1" || salt[32] || nonce[12] || ciphertext+tag + * + * inner plaintext: JSON + * { version: 1, + * created_at: , + * files: { "betterframe.db": "", "secret.key": "", ... } } + * + * Firmware blobs (firmware/*.bin) are excluded — re-upload via the admin + * Firmware page after restore. They can be GB-sized and would defeat the + * point of a "small portable backup". + */ +import { + createCipheriv, + createDecipheriv, + pbkdf2Sync, + randomBytes, +} from "node:crypto"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +const MAGIC = Buffer.from("bfbak1", "utf8"); // 6 bytes +const SALT_LEN = 32; +const NONCE_LEN = 12; +const PBKDF2_ITERS = 200_000; +const KEY_LEN = 32; + +const BACKED_UP_FILES = [ + "betterframe.db", + "secret.key", + "firmware-signing.key", + "firmware-signing.pub", +] as const; + +export interface BackupResult { + blob: Buffer; + filename: string; + fileCount: number; +} + +export function createBackup(dataDir: string, passphrase: string): BackupResult { + if (passphrase.length < 8) { + throw new Error("passphrase must be at least 8 characters"); + } + + const files: Record = {}; + for (const name of BACKED_UP_FILES) { + const path = join(dataDir, name); + if (existsSync(path)) { + files[name] = readFileSync(path).toString("base64"); + } + } + + const inner = JSON.stringify({ + version: 1, + created_at: new Date().toISOString(), + files, + }); + + const salt = randomBytes(SALT_LEN); + const nonce = randomBytes(NONCE_LEN); + const key = pbkdf2Sync(passphrase, salt, PBKDF2_ITERS, KEY_LEN, "sha256"); + const cipher = createCipheriv("aes-256-gcm", key, nonce); + const enc = Buffer.concat([cipher.update(Buffer.from(inner, "utf8")), cipher.final()]); + const tag = cipher.getAuthTag(); + + const blob = Buffer.concat([MAGIC, salt, nonce, enc, tag]); + return { + blob, + filename: `betterframe-${new Date().toISOString().replace(/[:.]/g, "-")}.bfbak`, + fileCount: Object.keys(files).length, + }; +} + +export function restoreBackup(dataDir: string, passphrase: string, blob: Buffer): { + fileCount: number; + files: string[]; +} { + if (blob.length < MAGIC.length + SALT_LEN + NONCE_LEN + 16) { + throw new Error("backup file too short / corrupt"); + } + if (!blob.subarray(0, MAGIC.length).equals(MAGIC)) { + throw new Error("not a bfbak file"); + } + let off = MAGIC.length; + const salt = blob.subarray(off, off + SALT_LEN); + off += SALT_LEN; + const nonce = blob.subarray(off, off + NONCE_LEN); + off += NONCE_LEN; + const tag = blob.subarray(blob.length - 16); + const enc = blob.subarray(off, blob.length - 16); + + const key = pbkdf2Sync(passphrase, salt, PBKDF2_ITERS, KEY_LEN, "sha256"); + const decipher = createDecipheriv("aes-256-gcm", key, nonce); + decipher.setAuthTag(tag); + let dec: Buffer; + try { + dec = Buffer.concat([decipher.update(enc), decipher.final()]); + } catch { + throw new Error("wrong passphrase or corrupted backup"); + } + + const inner = JSON.parse(dec.toString("utf8")) as { + version: number; + files: Record; + }; + if (inner.version !== 1) throw new Error(`unsupported backup version ${String(inner.version)}`); + + mkdirSync(dataDir, { recursive: true }); + const restored: string[] = []; + for (const [name, b64] of Object.entries(inner.files)) { + // Refuse path traversal in filenames. + if (name.includes("/") || name.includes("\\") || name.startsWith(".")) continue; + if (!(BACKED_UP_FILES as readonly string[]).includes(name)) continue; + const target = join(dataDir, name); + writeFileSync(target, Buffer.from(b64, "base64"), { + mode: name.endsWith(".key") ? 0o600 : 0o644, + }); + restored.push(name); + } + return { fileCount: restored.length, files: restored }; +} diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index ba8a7d2..df57205 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -3045,3 +3045,62 @@ export function AuditLogPage(props: AuditLogPageProps) { ); } + +// ---- Backup / restore ------------------------------------------------------- + +interface BackupPageProps { + user: string; + error?: string; + success?: string; +} + +export function BackupPage(props: BackupPageProps) { + return ( + +

+ Encrypted snapshot of the SQLite DB + master secret + firmware signing + key. Passphrase protects the file (AES-256-GCM, PBKDF2 200k). Lose + the passphrase = lose the backup. Firmware binaries are excluded. +

+ +
+
+

Download backup

+
+
+ + +
Min 8 chars. Store somewhere safe.
+
+ +
+
+ +
+

Restore from backup

+
+
+ + +
+
+ + +
+
+ Warning: overwrites DB and master keys. + Restart the server immediately after restore. +
+ +
+
+
+
+ ); +} diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx index 997ec1f..edb8d8d 100644 --- a/server/src/web-templates/layout.tsx +++ b/server/src/web-templates/layout.tsx @@ -52,6 +52,7 @@ function Sidebar(props: { activeNav?: string }) { +