From 23eb7f0d7031f5307d9daabed71e820d54fb5e5b Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Sat, 19 Sep 2026 21:18:41 +0200 Subject: [PATCH] Add passphrase-protected export/import for integrations, DNS providers, and settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing let you back up or migrate the app's own configuration short of copying the raw SQLite file. Adds Settings -> Backup: export decrypts every integration/DNS provider credential (normally encrypted at rest with this server's CREDENTIALS_ENCRYPTION_KEY) and re-encrypts the whole payload with a passphrase you choose (scrypt- derived key, AES-256-GCM), so the file is portable to a different instance with a different encryption key rather than being tied to this one. Import decrypts with that passphrase and merges settings onto the current ones; integrations/DNS providers are only added when no existing row shares their type+name, so re-running an import never duplicates or overwrites a working credential. Scope is configuration only — no DNS records, secrets, IPAM, servers, or audit/diagnostic log data. Verified end-to-end against two isolated scratch databases with different encryption keys (proving actual cross-instance portability, not just round-tripping through the same key): export -> encrypt -> write file -> decrypt on the other DB -> import -> re-decrypt the newly created integration/provider using the target's own key, confirming the plaintext credentials survived correctly; a wrong passphrase failed loudly (GCM auth failure) as expected; and re-running the same import a second time skipped both rows instead of duplicating them. Confirmed the real dev database's mtime was untouched throughout. Co-Authored-By: Claude Sonnet 5 --- server/src/routes/settings.ts | 58 +++++++ server/src/services/configBackup.ts | 189 +++++++++++++++++++++ web/src/App.tsx | 2 + web/src/api/client.ts | 20 +++ web/src/pages/Settings.tsx | 1 + web/src/pages/settings/BackupSettings.tsx | 190 ++++++++++++++++++++++ 6 files changed, 460 insertions(+) create mode 100644 server/src/services/configBackup.ts create mode 100644 web/src/pages/settings/BackupSettings.tsx diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 0596999..76adc02 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -8,6 +8,7 @@ import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryS import { scheduleLogRetentionPurge } from "../services/logRetentionScheduler.js"; import { purgeOldLogs } from "../services/logRetention.js"; import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js"; +import { buildExportPayload, encryptExport, decryptExport, applyImportPayload, type EncryptedExportFile } from "../services/configBackup.js"; import { asyncHandler } from "../utils/asyncHandler.js"; export const settingsRouter = Router(); @@ -174,3 +175,60 @@ settingsRouter.post("/purge-logs", requireRole("admin"), asyncHandler(async (req }); res.json(result); })); + +const exportSchema = z.object({ passphrase: z.string().min(8) }); + +settingsRouter.post("/export", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = exportSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + + const payload = await buildExportPayload(); + const file = encryptExport(payload, parsed.data.passphrase); + + await recordAudit({ + actor: req.currentUser!, + category: "settings", + action: "export_config", + detail: { integrations: payload.integrations.length, dnsProviders: payload.dnsProviders.length }, + }); + + res.json(file); +})); + +const encryptedFileSchema = z.object({ + app: z.literal("homelab-manager-backup"), + version: z.literal(1), + salt: z.string().min(1), + iv: z.string().min(1), + authTag: z.string().min(1), + ciphertext: z.string().min(1), +}); + +const importSchema = z.object({ passphrase: z.string().min(1), file: encryptedFileSchema }); + +settingsRouter.post("/import", requireRole("admin"), asyncHandler(async (req, res) => { + const parsed = importSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + + let payload; + try { + payload = decryptExport(parsed.data.file as EncryptedExportFile, parsed.data.passphrase); + } catch { + return res.status(400).json({ error: "decrypt_failed", message: "Wrong passphrase, or the file is corrupted." }); + } + + if (!payload || typeof payload !== "object" || !Array.isArray(payload.integrations) || !Array.isArray(payload.dnsProviders) || !payload.settings) { + return res.status(400).json({ error: "invalid_payload", message: "Decrypted file doesn't look like a Homelab Manager backup." }); + } + + const result = await applyImportPayload(payload); + + await recordAudit({ + actor: req.currentUser!, + category: "settings", + action: "import_config", + detail: result, + }); + + res.json(result); +})); diff --git a/server/src/services/configBackup.ts b/server/src/services/configBackup.ts new file mode 100644 index 0000000..4b9890d --- /dev/null +++ b/server/src/services/configBackup.ts @@ -0,0 +1,189 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; +import { eq, and } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { integrations, integrationCredentials, dnsProviders, type IntegrationType, type DnsProviderType } from "../db/schema.js"; +import { encryptSecret, decryptSecret } from "../crypto.js"; +import { getSettings, updateSettings, type AppSettings } from "./settingsStore.js"; +import { resolveBaseUrl } from "../integrations/fieldSchemas.js"; + +const ALGO = "aes-256-gcm"; +const SCRYPT_KEYLEN = 32; + +export interface EncryptedExportFile { + app: "homelab-manager-backup"; + version: 1; + salt: string; + iv: string; + authTag: string; + ciphertext: string; +} + +export interface ExportPayload { + exportedAt: string; + settings: AppSettings; + integrations: { + type: IntegrationType; + name: string; + enabled: boolean; + config: Record; + secretFields: Record; + }[]; + dnsProviders: { + providerType: DnsProviderType; + name: string; + enabled: boolean; + config: Record; + secretFields: Record; + }[]; +} + +async function decryptCredential(credentialId: number | null): Promise> { + if (!credentialId) return {}; + const [cred] = await db.select().from(integrationCredentials).where(eq(integrationCredentials.id, credentialId)).limit(1); + if (!cred) return {}; + return JSON.parse(decryptSecret(cred.encryptedSecret)); +} + +/** Gathers every integration, DNS provider (with credentials decrypted), and app setting into one exportable payload. */ +export async function buildExportPayload(): Promise { + const settings = await getSettings(); + + const integrationRows = await db.select().from(integrations); + const exportedIntegrations = await Promise.all( + integrationRows.map(async (row) => ({ + type: row.type, + name: row.name, + enabled: row.enabled, + config: row.config ? JSON.parse(row.config) : {}, + secretFields: await decryptCredential(row.credentialId), + })), + ); + + const providerRows = await db.select().from(dnsProviders); + const exportedProviders = await Promise.all( + providerRows.map(async (row) => ({ + providerType: row.providerType, + name: row.name, + enabled: row.enabled, + config: row.config ? JSON.parse(row.config) : {}, + secretFields: await decryptCredential(row.credentialId), + })), + ); + + return { + exportedAt: new Date().toISOString(), + settings, + integrations: exportedIntegrations, + dnsProviders: exportedProviders, + }; +} + +/** Encrypts an export payload with a user-chosen passphrase (scrypt-derived key, AES-256-GCM) so the file is portable across instances with different CREDENTIALS_ENCRYPTION_KEY values. */ +export function encryptExport(payload: ExportPayload, passphrase: string): EncryptedExportFile { + const salt = randomBytes(16); + const key = scryptSync(passphrase, salt, SCRYPT_KEYLEN); + const iv = randomBytes(12); + const cipher = createCipheriv(ALGO, key, iv); + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + return { + app: "homelab-manager-backup", + version: 1, + salt: salt.toString("hex"), + iv: iv.toString("hex"), + authTag: authTag.toString("hex"), + ciphertext: ciphertext.toString("hex"), + }; +} + +/** Reverses encryptExport(). Throws (GCM auth failure) if the passphrase is wrong or the file was tampered with/corrupted. */ +export function decryptExport(file: EncryptedExportFile, passphrase: string): ExportPayload { + const salt = Buffer.from(file.salt, "hex"); + const key = scryptSync(passphrase, salt, SCRYPT_KEYLEN); + const decipher = createDecipheriv(ALGO, key, Buffer.from(file.iv, "hex")); + decipher.setAuthTag(Buffer.from(file.authTag, "hex")); + const plaintext = Buffer.concat([decipher.update(Buffer.from(file.ciphertext, "hex")), decipher.final()]); + return JSON.parse(plaintext.toString("utf8")); +} + +export interface ImportResult { + integrationsCreated: number; + integrationsSkipped: string[]; + dnsProvidersCreated: number; + dnsProvidersSkipped: string[]; +} + +/** + * Applies an imported payload: settings are merged onto current settings + * (same per-key merge as a normal settings update); integrations and DNS + * providers are only created when no existing row shares their type/name — + * an import never overwrites or deletes an existing integration, so it's + * safe to re-run against a live instance without risking a working + * credential you didn't mean to touch. + */ +export async function applyImportPayload(payload: ExportPayload): Promise { + await updateSettings(payload.settings); + + const result: ImportResult = { integrationsCreated: 0, integrationsSkipped: [], dnsProvidersCreated: 0, dnsProvidersSkipped: [] }; + + for (const item of payload.integrations) { + const [existing] = await db + .select({ id: integrations.id }) + .from(integrations) + .where(and(eq(integrations.type, item.type), eq(integrations.name, item.name))) + .limit(1); + if (existing) { + result.integrationsSkipped.push(item.name); + continue; + } + + let credentialId: number | null = null; + if (Object.keys(item.secretFields).length > 0) { + const [cred] = await db + .insert(integrationCredentials) + .values({ name: `${item.type}:${item.name}`, encryptedSecret: encryptSecret(JSON.stringify(item.secretFields)) }) + .returning(); + credentialId = cred.id; + } + await db.insert(integrations).values({ + type: item.type, + name: item.name, + baseUrl: resolveBaseUrl(item.type, item.config), + credentialId, + config: JSON.stringify(item.config), + enabled: item.enabled, + }); + result.integrationsCreated++; + } + + for (const item of payload.dnsProviders) { + const [existing] = await db + .select({ id: dnsProviders.id }) + .from(dnsProviders) + .where(and(eq(dnsProviders.providerType, item.providerType), eq(dnsProviders.name, item.name))) + .limit(1); + if (existing) { + result.dnsProvidersSkipped.push(item.name); + continue; + } + + let credentialId: number | null = null; + if (Object.keys(item.secretFields).length > 0) { + const [cred] = await db + .insert(integrationCredentials) + .values({ name: `${item.providerType}:${item.name}`, encryptedSecret: encryptSecret(JSON.stringify(item.secretFields)) }) + .returning(); + credentialId = cred.id; + } + await db.insert(dnsProviders).values({ + providerType: item.providerType, + name: item.name, + credentialId, + config: JSON.stringify(item.config), + enabled: item.enabled, + }); + result.dnsProvidersCreated++; + } + + return result; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index e265aeb..fdf4d08 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -24,6 +24,7 @@ import BadgeSettings from "./pages/settings/BadgeSettings"; import DisplaySettings from "./pages/settings/DisplaySettings"; import CacheSettings from "./pages/settings/CacheSettings"; import LogSettings from "./pages/settings/LogSettings"; +import BackupSettings from "./pages/settings/BackupSettings"; import AppShell from "./layout/AppShell"; import { setDateTimeSettings } from "./utils/date"; import { setPageSize } from "./utils/pageSize"; @@ -128,6 +129,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 096033d..df40430 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -175,6 +175,22 @@ export interface AppSettings { export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial }; +export interface EncryptedExportFile { + app: "homelab-manager-backup"; + version: 1; + salt: string; + iv: string; + authTag: string; + ciphertext: string; +} + +export interface ImportResult { + integrationsCreated: number; + integrationsSkipped: string[]; + dnsProvidersCreated: number; + dnsProvidersSkipped: string[]; +} + export interface DnsProviderField { key: string; label: string; @@ -803,5 +819,9 @@ export const api = { request<{ ok: true }>("/api/settings/test-webhook", { method: "POST", body: JSON.stringify(data) }), purgeLogs: () => request<{ diagDeleted: number; auditDeleted: number }>("/api/settings/purge-logs", { method: "POST" }), + exportConfig: (passphrase: string) => + request("/api/settings/export", { method: "POST", body: JSON.stringify({ passphrase }) }), + importConfig: (passphrase: string, file: EncryptedExportFile) => + request("/api/settings/import", { method: "POST", body: JSON.stringify({ passphrase, file }) }), }, }; diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index e09928d..767bdbd 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -6,6 +6,7 @@ const SUB_NAV = [ { to: "/settings/display", label: "Display" }, { to: "/settings/cache", label: "Cache" }, { to: "/settings/logs", label: "Logs" }, + { to: "/settings/backup", label: "Backup" }, ]; export default function Settings() { diff --git a/web/src/pages/settings/BackupSettings.tsx b/web/src/pages/settings/BackupSettings.tsx new file mode 100644 index 0000000..514af86 --- /dev/null +++ b/web/src/pages/settings/BackupSettings.tsx @@ -0,0 +1,190 @@ +import { useRef, useState } from "react"; +import { api, type EncryptedExportFile, type ImportResult } from "../../api/client"; + +function downloadJson(filename: string, data: unknown) { + const blob = new Blob([JSON.stringify(data)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export default function BackupSettings() { + const [exportPassphrase, setExportPassphrase] = useState(""); + const [exportPassphraseConfirm, setExportPassphraseConfirm] = useState(""); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); + const [exported, setExported] = useState(false); + + const [importPassphrase, setImportPassphrase] = useState(""); + const [importFile, setImportFile] = useState(null); + const [importFileName, setImportFileName] = useState(""); + const [importFileError, setImportFileError] = useState(null); + const [importing, setImporting] = useState(false); + const [importError, setImportError] = useState(null); + const [importResult, setImportResult] = useState(null); + const fileInputRef = useRef(null); + + const passphraseMismatch = exportPassphraseConfirm.length > 0 && exportPassphrase !== exportPassphraseConfirm; + + async function handleExport() { + setExporting(true); + setExportError(null); + setExported(false); + try { + const file = await api.settings.exportConfig(exportPassphrase); + downloadJson(`homelab-manager-backup-${new Date().toISOString().slice(0, 10)}.json`, file); + setExported(true); + setExportPassphrase(""); + setExportPassphraseConfirm(""); + setTimeout(() => setExported(false), 5000); + } catch (err) { + setExportError(err instanceof Error ? err.message : String(err)); + } finally { + setExporting(false); + } + } + + function handleFilePicked(e: React.ChangeEvent) { + const picked = e.target.files?.[0]; + setImportFile(null); + setImportFileError(null); + setImportResult(null); + if (!picked) return; + setImportFileName(picked.name); + picked + .text() + .then((text) => { + const parsed = JSON.parse(text); + setImportFile(parsed); + }) + .catch(() => setImportFileError("That file isn't valid JSON.")); + } + + async function handleImport() { + if (!importFile) return; + if (!confirm("Import this backup? Existing integrations and DNS providers with the same name are left untouched — only new ones are added.")) return; + setImporting(true); + setImportError(null); + setImportResult(null); + try { + const result = await api.settings.importConfig(importPassphrase, importFile); + setImportResult(result); + setImportPassphrase(""); + setImportFile(null); + setImportFileName(""); + if (fileInputRef.current) fileInputRef.current.value = ""; + } catch (err) { + setImportError(err instanceof Error ? err.message : String(err)); + } finally { + setImporting(false); + } + } + + return ( + <> +

Backup

+ +
+
+
+
+

Export configuration

+
+
+

+ Downloads every integration, DNS provider, and app setting (notifications, badge colors, display, + log retention) as one file — including credentials, decrypted and re-encrypted with the + passphrase below so the file is portable to a fresh install with a different encryption key. + Anyone with the file and the passphrase can read those credentials, so store it + somewhere you trust and don't lose the passphrase — it can't be recovered. +

+

Not included: DNS records, secrets, IPAM, servers, or the audit/diagnostic logs — this is configuration only.

+ {exportError &&
{exportError}
} +
+ + setExportPassphrase(e.target.value)} + /> +
+
+ + setExportPassphraseConfirm(e.target.value)} + /> + {passphraseMismatch &&
Passphrases don't match.
} +
+
+
+ + {exported && ✓ Downloaded} +
+
+
+ +
+
+
+

Import configuration

+
+
+

+ Restores from a backup file. Settings (notifications, badges, display, log retention) are merged + onto the current ones. Integrations and DNS providers are only added — anything + that already exists with the same name is left alone, so this is safe to run more than once. +

+ {importError &&
{importError}
} + {importFileError &&
{importFileError}
} + {importResult && ( +
+
+ Integrations: {importResult.integrationsCreated} added + {importResult.integrationsSkipped.length > 0 && `, ${importResult.integrationsSkipped.length} skipped (already exist: ${importResult.integrationsSkipped.join(", ")})`} +
+
+ DNS providers: {importResult.dnsProvidersCreated} added + {importResult.dnsProvidersSkipped.length > 0 && `, ${importResult.dnsProvidersSkipped.length} skipped (already exist: ${importResult.dnsProvidersSkipped.join(", ")})`} +
+
+ )} +
+ + + {importFileName &&
Selected: {importFileName}
} +
+
+ + setImportPassphrase(e.target.value)} + /> +
+
+
+ +
+
+
+
+ + ); +}