Add passphrase-protected export/import for integrations, DNS providers, and settings

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 21:18:41 +02:00
co-authored by Claude Sonnet 5
parent b5a4c6e2d9
commit 23eb7f0d70
6 changed files with 460 additions and 0 deletions
+58
View File
@@ -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);
}));
+189
View File
@@ -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<string, string | boolean>;
secretFields: Record<string, string | boolean>;
}[];
dnsProviders: {
providerType: DnsProviderType;
name: string;
enabled: boolean;
config: Record<string, string | boolean>;
secretFields: Record<string, string | boolean>;
}[];
}
async function decryptCredential(credentialId: number | null): Promise<Record<string, string | boolean>> {
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<ExportPayload> {
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<ImportResult> {
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;
}
+2
View File
@@ -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() {
<Route path="display" element={<DisplaySettings />} />
<Route path="cache" element={<CacheSettings />} />
<Route path="logs" element={<LogSettings />} />
<Route path="backup" element={<BackupSettings />} />
</Route>
</Routes>
</AppShell>
+20
View File
@@ -175,6 +175,22 @@ export interface AppSettings {
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
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<EncryptedExportFile>("/api/settings/export", { method: "POST", body: JSON.stringify({ passphrase }) }),
importConfig: (passphrase: string, file: EncryptedExportFile) =>
request<ImportResult>("/api/settings/import", { method: "POST", body: JSON.stringify({ passphrase, file }) }),
},
};
+1
View File
@@ -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() {
+190
View File
@@ -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<string | null>(null);
const [exported, setExported] = useState(false);
const [importPassphrase, setImportPassphrase] = useState("");
const [importFile, setImportFile] = useState<EncryptedExportFile | null>(null);
const [importFileName, setImportFileName] = useState("");
const [importFileError, setImportFileError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<>
<h3 className="mb-3">Backup</h3>
<div className="row row-cards">
<div className="col-md-6">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Export configuration</h3>
</div>
<div className="card-body">
<p className="text-secondary">
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 <strong>and</strong> the passphrase can read those credentials, so store it
somewhere you trust and don't lose the passphrase — it can't be recovered.
</p>
<p className="text-secondary">Not included: DNS records, secrets, IPAM, servers, or the audit/diagnostic logs — this is configuration only.</p>
{exportError && <div className="alert alert-danger">{exportError}</div>}
<div className="mb-2">
<label className="form-label">Passphrase</label>
<input
type="password"
className="form-control"
minLength={8}
value={exportPassphrase}
onChange={(e) => setExportPassphrase(e.target.value)}
/>
</div>
<div className="mb-2">
<label className="form-label">Confirm passphrase</label>
<input
type="password"
className={`form-control ${passphraseMismatch ? "is-invalid" : ""}`}
value={exportPassphraseConfirm}
onChange={(e) => setExportPassphraseConfirm(e.target.value)}
/>
{passphraseMismatch && <div className="invalid-feedback">Passphrases don't match.</div>}
</div>
</div>
<div className="card-footer d-flex align-items-center gap-2">
<button
className="btn btn-primary"
onClick={handleExport}
disabled={exporting || exportPassphrase.length < 8 || exportPassphrase !== exportPassphraseConfirm}
>
{exporting ? "Exporting…" : "Export configuration"}
</button>
{exported && <span className="text-success small">✓ Downloaded</span>}
</div>
</div>
</div>
<div className="col-md-6">
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">Import configuration</h3>
</div>
<div className="card-body">
<p className="text-secondary">
Restores from a backup file. Settings (notifications, badges, display, log retention) are merged
onto the current ones. Integrations and DNS providers are only <strong>added</strong> — anything
that already exists with the same name is left alone, so this is safe to run more than once.
</p>
{importError && <div className="alert alert-danger">{importError}</div>}
{importFileError && <div className="alert alert-danger">{importFileError}</div>}
{importResult && (
<div className="alert alert-success">
<div>
Integrations: {importResult.integrationsCreated} added
{importResult.integrationsSkipped.length > 0 && `, ${importResult.integrationsSkipped.length} skipped (already exist: ${importResult.integrationsSkipped.join(", ")})`}
</div>
<div>
DNS providers: {importResult.dnsProvidersCreated} added
{importResult.dnsProvidersSkipped.length > 0 && `, ${importResult.dnsProvidersSkipped.length} skipped (already exist: ${importResult.dnsProvidersSkipped.join(", ")})`}
</div>
</div>
)}
<div className="mb-2">
<label className="form-label">Backup file</label>
<input ref={fileInputRef} type="file" accept="application/json,.json" className="form-control" onChange={handleFilePicked} />
{importFileName && <div className="form-hint">Selected: {importFileName}</div>}
</div>
<div className="mb-2">
<label className="form-label">Passphrase</label>
<input
type="password"
className="form-control"
value={importPassphrase}
onChange={(e) => setImportPassphrase(e.target.value)}
/>
</div>
</div>
<div className="card-footer">
<button className="btn btn-primary" onClick={handleImport} disabled={importing || !importFile || !importPassphrase}>
{importing ? "Importing…" : "Import configuration"}
</button>
</div>
</div>
</div>
</div>
</>
);
}