fix: overhaul backup/restore — full secrets coverage, ordered reapply, optional passphrase encryption
Unit Tests / test (push) Successful in 12m25s

P0 — backups previously omitted peers/keys/vault(CA+fernet)/auth/cell-links/ddns/connectivity
configs (a restore lost everything incl admin login + CA) and included logs/trash; restore did
file-copies only with no reapply.

Changes:
- api/config_manager.py: backup_config now includes auth_users.json, .flask_secret_key,
  peers.json, peer_service_credentials.json, WireGuard keys + wg_confs + api/wireguard/keys,
  vault/** (incl fernet.key), api/services + service configs, cell_links.json, ddns_token,
  caddy/**; new _is_excluded() drops logs/config_backups/.test_admin_pass/.gitkeep/*.tmp/
  *.partial/__pycache__; restore_config reordered (vault/fernet → config → wg keys/peers →
  cell_links → caddy/dns → service configs → auth/ddns → volumes) + new _reapply_runtime_state()
  (regenerate Caddyfile/Corefile, reapply services, connectivity apply_routes, replay cell pushes)
- api/backup_crypto.py (new): optional passphrase encryption via scrypt-derived key + Fernet;
  encrypted archives written 0600
- api/routes/config.py: backup/restore accept optional {passphrase}; wrong/missing passphrase
  returns 400; backup response warns it contains secrets
- Makefile: backup target applies same excludes + chmod 0600 + secrets warning
- webui/src/services/api.js + webui/src/pages/Settings.jsx: passphrase field on create backup,
  restore prompt, "contains secrets" banner
- tests/test_config_backup_overhaul.py (new, 18 tests) + tests/test_config_backup_restore_http.py
  (2 assertions updated)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:41:10 -04:00
parent c3ba82251a
commit 82a0c0e9bd
8 changed files with 743 additions and 71 deletions
+41 -7
View File
@@ -362,6 +362,8 @@ function Settings() {
const [restoreModal, setRestoreModal] = useState(null); // backup object or null
const [restoreServices, setRestoreServices] = useState(new Set(RESTORE_SERVICES.map(s => s.key)));
const [backupUploading, setBackupUploading] = useState(false);
const [backupPassphrase, setBackupPassphrase] = useState('');
const [restorePassphrase, setRestorePassphrase] = useState('');
const [isLoading, setIsLoading] = useState(true);
@@ -677,8 +679,9 @@ function Settings() {
const createBackup = async () => {
setBackupCreating(true);
try {
await cellAPI.createBackup();
toast('Backup created');
await cellAPI.createBackup(backupPassphrase || null);
toast(backupPassphrase ? 'Encrypted backup created' : 'Backup created');
setBackupPassphrase('');
const res = await cellAPI.listBackups();
setBackups(res.data || []);
} catch {
@@ -690,6 +693,7 @@ function Settings() {
const openRestoreModal = (backup) => {
setRestoreServices(new Set(RESTORE_SERVICES.map(s => s.key)));
setRestorePassphrase('');
setRestoreModal(backup);
};
@@ -698,12 +702,15 @@ function Settings() {
const allSelected = restoreServices.size === RESTORE_SERVICES.length;
const services = allSelected ? null : Array.from(restoreServices);
try {
await cellAPI.restoreBackup(restoreModal.backup_id, services);
await cellAPI.restoreBackup(restoreModal.backup_id, services, restorePassphrase || null);
toast('Configuration restored — reloading…');
setRestoreModal(null);
setTimeout(() => loadAll(), 500);
} catch {
toast('Failed to restore backup', 'error');
} catch (e) {
const msg = e?.response?.status === 400
? 'Invalid or missing passphrase for this encrypted backup'
: 'Failed to restore backup';
toast(msg, 'error');
}
};
@@ -1063,9 +1070,21 @@ function Settings() {
{/* Backup & Restore */}
<Section icon={Archive} title="Backup & Restore" collapsible defaultOpen>
<div className="flex justify-between items-center mb-3">
<div className="mb-3 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
Backups contain secrets and key material (WireGuard keys, internal CA, vault key, admin credentials).
Set a passphrase to encrypt the archive, and store it securely.
</div>
<div className="flex justify-between items-center gap-2 mb-3 flex-wrap">
<span className="text-sm text-gray-600">{backups.length} backup{backups.length !== 1 ? 's' : ''} stored</span>
<div className="flex gap-2">
<div className="flex gap-2 items-center">
<input
type="password"
value={backupPassphrase}
onChange={(e) => setBackupPassphrase(e.target.value)}
placeholder="Passphrase (optional)"
autoComplete="new-password"
className="input text-sm w-44"
/>
<label className="btn-secondary flex items-center gap-2 text-sm cursor-pointer" title="Upload backup zip">
{backupUploading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Upload
@@ -1184,6 +1203,21 @@ function Settings() {
{restoreServices.size === RESTORE_SERVICES.length && (
<p className="text-xs text-gray-400 mb-4">All services selected full restore including system files.</p>
)}
{restoreModal.encrypted && (
<div className="mb-4">
<label className="text-xs text-gray-600 mb-1 block">
This backup is encrypted enter its passphrase:
</label>
<input
type="password"
value={restorePassphrase}
onChange={(e) => setRestorePassphrase(e.target.value)}
placeholder="Passphrase"
autoComplete="off"
className="input text-sm w-full"
/>
</div>
)}
<div className="flex gap-2 justify-end">
<button onClick={() => setRestoreModal(null)} className="btn-secondary text-sm">Cancel</button>
<button
+7 -2
View File
@@ -90,9 +90,14 @@ export const cellAPI = {
getStatus: () => api.get('/api/status'),
getConfig: () => api.get('/api/config'),
updateConfig: (config) => api.put('/api/config', config),
createBackup: () => api.post('/api/config/backup'),
createBackup: (passphrase = null) => api.post('/api/config/backup', passphrase ? { passphrase } : {}),
listBackups: () => api.get('/api/config/backups'),
restoreBackup: (id, services = null) => api.post(`/api/config/restore/${id}`, services ? { services } : {}),
restoreBackup: (id, services = null, passphrase = null) => {
const body = {};
if (services) body.services = services;
if (passphrase) body.passphrase = passphrase;
return api.post(`/api/config/restore/${id}`, body);
},
deleteBackup: (id) => api.delete(`/api/config/backups/${id}`),
downloadBackup: (id) => api.get(`/api/config/backups/${id}/download`, { responseType: 'blob' }),
uploadBackup: (file) => {