fix: overhaul backup/restore — full secrets coverage, ordered reapply, optional passphrase encryption
Unit Tests / test (push) Successful in 12m25s
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:
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user