feat: fix export/import, add backup download/upload, restore service checkboxes

- export_config: clean output (no internal _keys), identity exposed as 'identity'
- import_config: handle 'identity' key, merge into existing config (not replace)
- restore_config: accept optional services list for selective restore
- backup_config: include 'identity' in manifest services list
- new GET /api/config/backups/<id>/download → zip file download
- new POST /api/config/backup/upload → zip file upload
- webui: Download + Upload buttons, restore modal with per-service checkboxes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 08:51:40 -04:00
parent 2bd6545f0e
commit 15e009bd94
4 changed files with 281 additions and 36 deletions
+58 -3
View File
@@ -12,10 +12,13 @@ Provides REST API endpoints for managing:
""" """
import os import os
import io
import json import json
import zipfile
import shutil
import logging import logging
from datetime import datetime from datetime import datetime
from flask import Flask, request, jsonify, current_app from flask import Flask, request, jsonify, current_app, send_file
from flask_cors import CORS from flask_cors import CORS
import threading import threading
import time import time
@@ -998,9 +1001,11 @@ def list_config_backups():
@app.route('/api/config/restore/<backup_id>', methods=['POST']) @app.route('/api/config/restore/<backup_id>', methods=['POST'])
def restore_config(backup_id): def restore_config(backup_id):
"""Restore configuration from backup.""" """Restore configuration from backup. Body may contain {services: [...]} for selective restore."""
try: try:
success = config_manager.restore_config(backup_id) data = request.get_json(silent=True) or {}
services = data.get('services') # None = full restore
success = config_manager.restore_config(backup_id, services=services)
if success: if success:
service_bus.publish_event(EventType.RESTORE_COMPLETED, 'api', { service_bus.publish_event(EventType.RESTORE_COMPLETED, 'api', {
'backup_id': backup_id, 'backup_id': backup_id,
@@ -1044,6 +1049,56 @@ def import_config():
logger.error(f"Error importing config: {e}") logger.error(f"Error importing config: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/api/config/backups/<backup_id>/download', methods=['GET'])
def download_backup(backup_id):
"""Download a backup as a zip file."""
try:
from pathlib import Path
backup_path = config_manager.backup_dir / backup_id
if not backup_path.exists():
return jsonify({'error': f'Backup {backup_id} not found'}), 404
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in backup_path.rglob('*'):
if f.is_file():
zf.write(f, f.relative_to(backup_path))
buf.seek(0)
return send_file(buf, mimetype='application/zip',
as_attachment=True,
download_name=f'{backup_id}.zip')
except Exception as e:
logger.error(f"Error downloading backup: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/config/backup/upload', methods=['POST'])
def upload_backup():
"""Upload a backup zip file."""
try:
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
f = request.files['file']
filename = f.filename or ''
if filename.endswith('.zip'):
backup_id = filename[:-4]
else:
backup_id = f"backup_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}"
backup_id = ''.join(c for c in backup_id if c.isalnum() or c == '_')
backup_path = config_manager.backup_dir / backup_id
backup_path.mkdir(parents=True, exist_ok=True)
try:
with zipfile.ZipFile(io.BytesIO(f.read())) as zf:
zf.extractall(backup_path)
except zipfile.BadZipFile:
shutil.rmtree(backup_path, ignore_errors=True)
return jsonify({'error': 'Invalid zip file'}), 400
if not (backup_path / 'manifest.json').exists():
shutil.rmtree(backup_path, ignore_errors=True)
return jsonify({'error': 'Invalid backup: missing manifest.json'}), 400
return jsonify({'backup_id': backup_id})
except Exception as e:
logger.error(f"Error uploading backup: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/config/backups/<backup_id>', methods=['DELETE']) @app.route('/api/config/backups/<backup_id>', methods=['DELETE'])
def delete_config_backup(backup_id): def delete_config_backup(backup_id):
"""Delete a configuration backup.""" """Delete a configuration backup."""
+61 -16
View File
@@ -247,10 +247,11 @@ class ConfigManager:
for zone_file in dns_data.glob('*.zone'): for zone_file in dns_data.glob('*.zone'):
shutil.copy2(zone_file, zones_dir / zone_file.name) shutil.copy2(zone_file, zones_dir / zone_file.name)
services = ['identity'] + list(self.service_schemas.keys())
manifest = { manifest = {
"backup_id": backup_id, "backup_id": backup_id,
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
"services": list(self.service_schemas.keys()), "services": services,
"files": [f.name for f in backup_path.iterdir()], "files": [f.name for f in backup_path.iterdir()],
} }
with open(backup_path / 'manifest.json', 'w') as f: with open(backup_path / 'manifest.json', 'w') as f:
@@ -263,8 +264,8 @@ class ConfigManager:
logger.error(f"Error creating backup: {e}") logger.error(f"Error creating backup: {e}")
raise raise
def restore_config(self, backup_id: str) -> bool: def restore_config(self, backup_id: str, services: list = None) -> bool:
"""Restore cell_config.json, secrets, Caddyfile, .env, Corefile, and DNS zones from backup.""" """Restore from backup. If services list given, only restore those service configs (selective)."""
try: try:
backup_path = self.backup_dir / backup_id backup_path = self.backup_dir / backup_id
if not backup_path.exists(): if not backup_path.exists():
@@ -273,7 +274,23 @@ class ConfigManager:
if not manifest_file.exists(): if not manifest_file.exists():
raise ValueError(f"Backup manifest not found") raise ValueError(f"Backup manifest not found")
# Restore primary config if services is not None:
# Selective restore: only update specified services in running config
backup_cfg_path = backup_path / 'cell_config.json'
if backup_cfg_path.exists():
with open(backup_cfg_path) as f:
backup_cfg = json.load(f)
for svc in services:
if svc == 'identity':
if '_identity' in backup_cfg:
self.configs['_identity'] = backup_cfg['_identity']
elif svc in backup_cfg:
self.configs[svc] = backup_cfg[svc]
self._save_all_configs()
logger.info(f"Selectively restored {services} from backup: {backup_id}")
return True
# Full restore: copy all files back
config_backup = backup_path / 'cell_config.json' config_backup = backup_path / 'cell_config.json'
if config_backup.exists(): if config_backup.exists():
shutil.copy2(config_backup, self.config_file) shutil.copy2(config_backup, self.config_file)
@@ -281,7 +298,6 @@ class ConfigManager:
if secrets_backup.exists(): if secrets_backup.exists():
shutil.copy2(secrets_backup, self.secrets_file) shutil.copy2(secrets_backup, self.secrets_file)
# Restore runtime-generated files so they stay consistent with cell_config.json
config_dir = Path(os.environ.get('CONFIG_DIR', '/app/config')) config_dir = Path(os.environ.get('CONFIG_DIR', '/app/config'))
data_dir = Path(os.environ.get('DATA_DIR', '/app/data')) data_dir = Path(os.environ.get('DATA_DIR', '/app/data'))
env_file = Path(os.environ.get('ENV_FILE', '/app/.env')) env_file = Path(os.environ.get('ENV_FILE', '/app/.env'))
@@ -296,7 +312,6 @@ class ConfigManager:
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest) shutil.copy2(src, dest)
# Restore DNS zone files
zones_backup = backup_path / 'dns_zones' zones_backup = backup_path / 'dns_zones'
if zones_backup.is_dir(): if zones_backup.is_dir():
dns_data = data_dir / 'dns' dns_data = data_dir / 'dns'
@@ -353,21 +368,32 @@ class ConfigManager:
current_hash = self.get_config_hash(service) current_hash = self.get_config_hash(service)
return current_hash != previous_hash return current_hash != previous_hash
def export_config(self, format: str = 'json') -> str: def export_config(self, format: str = 'json', services: list = None) -> str:
"""Export all configurations in specified format""" """Export service configurations (excludes internal state like pending_restart)."""
try: try:
export_data = {}
# Include identity under a clean key
if '_identity' in self.configs:
export_data['identity'] = dict(self.configs['_identity'])
# Include service configs, skip internal _ keys
for key, val in self.configs.items():
if key.startswith('_'):
continue
if services is not None and key not in services:
continue
export_data[key] = val
if format == 'json': if format == 'json':
return json.dumps(self.configs, indent=2) return json.dumps(export_data, indent=2)
elif format == 'yaml': elif format == 'yaml':
return yaml.dump(self.configs, default_flow_style=False) return yaml.dump(export_data, default_flow_style=False)
else: else:
raise ValueError(f"Unsupported format: {format}") raise ValueError(f"Unsupported format: {format}")
except Exception as e: except Exception as e:
logger.error(f"Error exporting config: {e}") logger.error(f"Error exporting config: {e}")
raise raise
def import_config(self, config_data: str, format: str = 'json') -> bool: def import_config(self, config_data: str, format: str = 'json', services: list = None) -> bool:
"""Import configurations from string""" """Import configurations from string. Merges into existing config."""
try: try:
if format == 'json': if format == 'json':
configs = json.loads(config_data) configs = json.loads(config_data)
@@ -375,10 +401,29 @@ class ConfigManager:
configs = yaml.safe_load(config_data) configs = yaml.safe_load(config_data)
else: else:
raise ValueError(f"Unsupported format: {format}") raise ValueError(f"Unsupported format: {format}")
# Import only services present in the data — don't fabricate missing ones
for service, config in configs.items(): # Handle identity (exported as 'identity', stored as '_identity')
if service in self.service_schemas: if 'identity' in configs and (services is None or 'identity' in services):
self.update_service_config(service, config) ident = configs['identity']
cur = dict(self.configs.get('_identity', {}))
for k in ('cell_name', 'domain', 'ip_range', 'wireguard_port'):
if k in ident:
cur[k] = ident[k]
self.configs['_identity'] = cur
# Merge service configs (don't replace wholesale — keep existing fields not in import)
for key, val in configs.items():
if key == 'identity':
continue
if key not in self.service_schemas:
continue
if services is not None and key not in services:
continue
cur_svc = dict(self.configs.get(key, {}))
cur_svc.update(val)
self.configs[key] = cur_svc
self._save_all_configs()
logger.info("Imported configurations successfully") logger.info("Imported configurations successfully")
return True return True
except Exception as e: except Exception as e:
+139 -5
View File
@@ -5,10 +5,23 @@ import {
Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar, Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar,
HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw, HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw,
ChevronDown, ChevronRight, CheckCircle, XCircle, ChevronDown, ChevronRight, CheckCircle, XCircle,
RefreshCw, Lock RefreshCw, Lock, FolderDown, X
} from 'lucide-react'; } from 'lucide-react';
import { cellAPI } from '../services/api'; import { cellAPI } from '../services/api';
// constants
const RESTORE_SERVICES = [
{ key: 'identity', label: 'Identity (cell name, domain, IP range)' },
{ key: 'network', label: 'Network (DNS, DHCP, NTP)' },
{ key: 'wireguard', label: 'WireGuard VPN' },
{ key: 'email', label: 'Email' },
{ key: 'calendar', label: 'Calendar & Contacts' },
{ key: 'files', label: 'File Storage' },
{ key: 'routing', label: 'Routing' },
{ key: 'vault', label: 'Vault & Certificates' },
];
// helpers // helpers
function toast(msg, type = 'success') { function toast(msg, type = 'success') {
@@ -413,6 +426,9 @@ function Settings() {
const [backups, setBackups] = useState([]); const [backups, setBackups] = useState([]);
const [backupsLoading, setBackupsLoading] = useState(false); const [backupsLoading, setBackupsLoading] = useState(false);
const [backupCreating, setBackupCreating] = useState(false); const [backupCreating, setBackupCreating] = useState(false);
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 [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -566,17 +582,56 @@ function Settings() {
} }
}; };
const restoreBackup = async (id) => { const openRestoreModal = (backup) => {
if (!confirm(`Restore backup ${id}? Current config will be overwritten.`)) return; setRestoreServices(new Set(RESTORE_SERVICES.map(s => s.key)));
setRestoreModal(backup);
};
const doRestore = async () => {
if (!restoreModal) return;
const allSelected = restoreServices.size === RESTORE_SERVICES.length;
const services = allSelected ? null : Array.from(restoreServices);
try { try {
await cellAPI.restoreBackup(id); await cellAPI.restoreBackup(restoreModal.backup_id, services);
toast('Configuration restored — reloading…'); toast('Configuration restored — reloading…');
setRestoreModal(null);
setTimeout(() => loadAll(), 500); setTimeout(() => loadAll(), 500);
} catch { } catch {
toast('Failed to restore backup', 'error'); toast('Failed to restore backup', 'error');
} }
}; };
const downloadBackup = async (id) => {
try {
const res = await cellAPI.downloadBackup(id);
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = `${id}.zip`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast('Download failed', 'error');
}
};
const uploadBackup = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
setBackupUploading(true);
try {
await cellAPI.uploadBackup(file);
toast('Backup uploaded');
const res = await cellAPI.listBackups();
setBackups(res.data || []);
} catch {
toast('Upload failed — ensure it is a valid backup zip', 'error');
} finally {
setBackupUploading(false);
}
};
const deleteBackup = async (id) => { const deleteBackup = async (id) => {
if (!confirm(`Delete backup ${id}?`)) return; if (!confirm(`Delete backup ${id}?`)) return;
try { try {
@@ -689,6 +744,12 @@ function Settings() {
<Section icon={Archive} title="Backup & Restore" collapsible defaultOpen> <Section icon={Archive} title="Backup & Restore" collapsible defaultOpen>
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<span className="text-sm text-gray-600">{backups.length} backup{backups.length !== 1 ? 's' : ''} stored</span> <span className="text-sm text-gray-600">{backups.length} backup{backups.length !== 1 ? 's' : ''} stored</span>
<div className="flex gap-2">
<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
<input type="file" accept=".zip" className="hidden" onChange={uploadBackup} />
</label>
<button <button
onClick={createBackup} onClick={createBackup}
disabled={backupCreating} disabled={backupCreating}
@@ -698,6 +759,7 @@ function Settings() {
Create Backup Create Backup
</button> </button>
</div> </div>
</div>
{backups.length === 0 ? ( {backups.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No backups yet</p> <p className="text-sm text-gray-400 text-center py-4">No backups yet</p>
) : ( ) : (
@@ -720,7 +782,14 @@ function Settings() {
<td className="py-2"> <td className="py-2">
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button <button
onClick={() => restoreBackup(b.backup_id)} onClick={() => downloadBackup(b.backup_id)}
className="text-gray-500 hover:text-gray-700 flex items-center gap-1 text-xs"
title="Download"
>
<FolderDown className="h-3.5 w-3.5" /> Download
</button>
<button
onClick={() => openRestoreModal(b)}
className="text-blue-600 hover:text-blue-800 flex items-center gap-1 text-xs" className="text-blue-600 hover:text-blue-800 flex items-center gap-1 text-xs"
title="Restore" title="Restore"
> >
@@ -743,6 +812,71 @@ function Settings() {
)} )}
</Section> </Section>
{/* Restore modal */}
{restoreModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 w-96 max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-semibold text-gray-900">Restore Backup</h3>
<p className="text-xs text-gray-500 mt-0.5 font-mono">{restoreModal.backup_id}</p>
</div>
<button onClick={() => setRestoreModal(null)} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-gray-600 mb-3">Select which services to restore:</p>
<div className="space-y-2 mb-4">
{RESTORE_SERVICES.map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={restoreServices.has(key)}
onChange={(e) => {
setRestoreServices((prev) => {
const next = new Set(prev);
if (e.target.checked) next.add(key);
else next.delete(key);
return next;
});
}}
className="h-4 w-4 rounded"
/>
<span className="text-sm text-gray-700">{label}</span>
</label>
))}
</div>
<div className="flex justify-between items-center mb-4">
<button
onClick={() => setRestoreServices(new Set(RESTORE_SERVICES.map(s => s.key)))}
className="text-xs text-blue-600 hover:text-blue-800"
>
Select all
</button>
<button
onClick={() => setRestoreServices(new Set())}
className="text-xs text-gray-500 hover:text-gray-700"
>
Deselect all
</button>
</div>
{restoreServices.size === RESTORE_SERVICES.length && (
<p className="text-xs text-gray-400 mb-4">All services selected full restore including system files.</p>
)}
<div className="flex gap-2 justify-end">
<button onClick={() => setRestoreModal(null)} className="btn-secondary text-sm">Cancel</button>
<button
onClick={doRestore}
disabled={restoreServices.size === 0}
className="btn-primary text-sm disabled:opacity-50"
>
Restore Selected
</button>
</div>
</div>
</div>
)}
{/* Export / Import */} {/* Export / Import */}
<Section icon={Download} title="Export & Import"> <Section icon={Download} title="Export & Import">
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
+14 -3
View File
@@ -39,10 +39,21 @@ export const cellAPI = {
updateConfig: (config) => api.put('/api/config', config), updateConfig: (config) => api.put('/api/config', config),
createBackup: () => api.post('/api/config/backup'), createBackup: () => api.post('/api/config/backup'),
listBackups: () => api.get('/api/config/backups'), listBackups: () => api.get('/api/config/backups'),
restoreBackup: (id) => api.post(`/api/config/restore/${id}`), restoreBackup: (id, services = null) => api.post(`/api/config/restore/${id}`, services ? { services } : {}),
deleteBackup: (id) => api.delete(`/api/config/backups/${id}`), deleteBackup: (id) => api.delete(`/api/config/backups/${id}`),
exportConfig: (format = 'json') => api.get('/api/config/export', { params: { format } }), downloadBackup: (id) => api.get(`/api/config/backups/${id}/download`, { responseType: 'blob' }),
importConfig: (config, format = 'json') => api.post('/api/config/import', { config, format }), uploadBackup: (file) => {
const form = new FormData();
form.append('file', file);
return api.post('/api/config/backup/upload', form, { headers: { 'Content-Type': 'multipart/form-data' } });
},
exportConfig: (format = 'json', services = null) => {
const params = { format };
if (services) params.services = services.join(',');
return api.get('/api/config/export', { params });
},
importConfig: (config, format = 'json', services = null) =>
api.post('/api/config/import', { config, format, ...(services ? { services } : {}) }),
getPending: () => api.get('/api/config/pending'), getPending: () => api.get('/api/config/pending'),
cancelPending: () => api.delete('/api/config/pending'), cancelPending: () => api.delete('/api/config/pending'),
applyPending: () => api.post('/api/config/apply'), applyPending: () => api.post('/api/config/apply'),