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
+25 -7
View File
@@ -846,12 +846,21 @@ def apply_pending_config():
def create_config_backup():
try:
from app import config_manager, service_bus, service_registry, EventType
backup_id = config_manager.backup_config(service_registry=service_registry)
data = request.get_json(silent=True) or {}
passphrase = data.get('passphrase') or None
backup_id = config_manager.backup_config(
service_registry=service_registry, passphrase=passphrase)
service_bus.publish_event(EventType.BACKUP_CREATED, 'api', {
'backup_id': backup_id,
'timestamp': datetime.utcnow().isoformat()
})
return jsonify({"backup_id": backup_id})
return jsonify({
"backup_id": backup_id,
"encrypted": bool(passphrase),
"warning": "This backup contains secrets and key material "
"(WireGuard keys, internal CA, admin credentials). "
"Store it securely.",
})
except Exception as e:
logger.error(f"Error creating backup: {e}")
return jsonify({"error": str(e)}), 500
@@ -873,11 +882,16 @@ def restore_config(backup_id):
from app import config_manager, service_bus, service_registry, EventType
data = request.get_json(silent=True) or {}
services = data.get('services')
success = config_manager.restore_config(
backup_id,
services=services,
service_registry=service_registry if services is None else None,
)
passphrase = data.get('passphrase') or None
try:
success = config_manager.restore_config(
backup_id,
services=services,
service_registry=service_registry if services is None else None,
passphrase=passphrase,
)
except PermissionError:
return jsonify({"error": "Invalid or missing passphrase for encrypted backup"}), 400
if success:
service_bus.publish_event(EventType.RESTORE_COMPLETED, 'api', {
'backup_id': backup_id,
@@ -925,6 +939,10 @@ def download_backup(backup_id):
backup_path = config_manager.backup_dir / backup_id
if not backup_path.exists():
return jsonify({'error': f'Backup {backup_id} not found'}), 404
if backup_path.is_file():
# Encrypted archive — serve as-is.
return send_file(str(backup_path), mimetype='application/octet-stream',
as_attachment=True, download_name=backup_id)
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in backup_path.rglob('*'):