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:
1 parent
b5a4c6e2d9
commit
23eb7f0d70
6 files changed
+460
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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 }) }),
|
||||
},
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user