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:
bobbanandClaude Sonnet 5 committed 2026-09-19 21:18:41 +02:00
1 parent b5a4c6e2d9
commit 23eb7f0d70
6 files changed
+460

No files matched your search

+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>
</>
);
}