From 15e009bd94b49a589462a3599298c64185c39b95 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Fri, 24 Apr 2026 08:51:40 -0400 Subject: [PATCH] feat: fix export/import, add backup download/upload, restore service checkboxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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//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 --- api/app.py | 61 ++++++++++++- api/config_manager.py | 79 +++++++++++++---- webui/src/pages/Settings.jsx | 160 ++++++++++++++++++++++++++++++++--- webui/src/services/api.js | 17 +++- 4 files changed, 281 insertions(+), 36 deletions(-) diff --git a/api/app.py b/api/app.py index c4019dc..6bb4486 100644 --- a/api/app.py +++ b/api/app.py @@ -12,10 +12,13 @@ Provides REST API endpoints for managing: """ import os +import io import json +import zipfile +import shutil import logging 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 import threading import time @@ -998,9 +1001,11 @@ def list_config_backups(): @app.route('/api/config/restore/', methods=['POST']) def restore_config(backup_id): - """Restore configuration from backup.""" + """Restore configuration from backup. Body may contain {services: [...]} for selective restore.""" 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: service_bus.publish_event(EventType.RESTORE_COMPLETED, 'api', { 'backup_id': backup_id, @@ -1044,6 +1049,56 @@ def import_config(): logger.error(f"Error importing config: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/config/backups//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/', methods=['DELETE']) def delete_config_backup(backup_id): """Delete a configuration backup.""" diff --git a/api/config_manager.py b/api/config_manager.py index fd9cca5..0a584fc 100644 --- a/api/config_manager.py +++ b/api/config_manager.py @@ -247,10 +247,11 @@ class ConfigManager: for zone_file in dns_data.glob('*.zone'): shutil.copy2(zone_file, zones_dir / zone_file.name) + services = ['identity'] + list(self.service_schemas.keys()) manifest = { "backup_id": backup_id, "timestamp": datetime.now().isoformat(), - "services": list(self.service_schemas.keys()), + "services": services, "files": [f.name for f in backup_path.iterdir()], } with open(backup_path / 'manifest.json', 'w') as f: @@ -263,8 +264,8 @@ class ConfigManager: logger.error(f"Error creating backup: {e}") raise - def restore_config(self, backup_id: str) -> bool: - """Restore cell_config.json, secrets, Caddyfile, .env, Corefile, and DNS zones from backup.""" + def restore_config(self, backup_id: str, services: list = None) -> bool: + """Restore from backup. If services list given, only restore those service configs (selective).""" try: backup_path = self.backup_dir / backup_id if not backup_path.exists(): @@ -273,7 +274,23 @@ class ConfigManager: if not manifest_file.exists(): 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' if config_backup.exists(): shutil.copy2(config_backup, self.config_file) @@ -281,7 +298,6 @@ class ConfigManager: if secrets_backup.exists(): 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')) data_dir = Path(os.environ.get('DATA_DIR', '/app/data')) env_file = Path(os.environ.get('ENV_FILE', '/app/.env')) @@ -296,7 +312,6 @@ class ConfigManager: dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dest) - # Restore DNS zone files zones_backup = backup_path / 'dns_zones' if zones_backup.is_dir(): dns_data = data_dir / 'dns' @@ -353,21 +368,32 @@ class ConfigManager: current_hash = self.get_config_hash(service) return current_hash != previous_hash - def export_config(self, format: str = 'json') -> str: - """Export all configurations in specified format""" + def export_config(self, format: str = 'json', services: list = None) -> str: + """Export service configurations (excludes internal state like pending_restart).""" 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': - return json.dumps(self.configs, indent=2) + return json.dumps(export_data, indent=2) elif format == 'yaml': - return yaml.dump(self.configs, default_flow_style=False) + return yaml.dump(export_data, default_flow_style=False) else: raise ValueError(f"Unsupported format: {format}") except Exception as e: logger.error(f"Error exporting config: {e}") raise - - def import_config(self, config_data: str, format: str = 'json') -> bool: - """Import configurations from string""" + + def import_config(self, config_data: str, format: str = 'json', services: list = None) -> bool: + """Import configurations from string. Merges into existing config.""" try: if format == 'json': configs = json.loads(config_data) @@ -375,10 +401,29 @@ class ConfigManager: configs = yaml.safe_load(config_data) else: raise ValueError(f"Unsupported format: {format}") - # Import only services present in the data — don't fabricate missing ones - for service, config in configs.items(): - if service in self.service_schemas: - self.update_service_config(service, config) + + # Handle identity (exported as 'identity', stored as '_identity') + if 'identity' in configs and (services is None or 'identity' in services): + 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") return True except Exception as e: diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index 0a59b05..2302ccf 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -5,10 +5,23 @@ import { Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar, HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw, ChevronDown, ChevronRight, CheckCircle, XCircle, - RefreshCw, Lock + RefreshCw, Lock, FolderDown, X } from 'lucide-react'; 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 ────────────────────────────────────────────────────────────────── function toast(msg, type = 'success') { @@ -413,6 +426,9 @@ function Settings() { const [backups, setBackups] = useState([]); const [backupsLoading, setBackupsLoading] = 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); @@ -566,17 +582,56 @@ function Settings() { } }; - const restoreBackup = async (id) => { - if (!confirm(`Restore backup ${id}? Current config will be overwritten.`)) return; + const openRestoreModal = (backup) => { + 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 { - await cellAPI.restoreBackup(id); + await cellAPI.restoreBackup(restoreModal.backup_id, services); toast('Configuration restored — reloading…'); + setRestoreModal(null); setTimeout(() => loadAll(), 500); } catch { 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) => { if (!confirm(`Delete backup ${id}?`)) return; try { @@ -689,14 +744,21 @@ function Settings() {
{backups.length} backup{backups.length !== 1 ? 's' : ''} stored - +
+ + +
{backups.length === 0 ? (

No backups yet

@@ -720,7 +782,14 @@ function Settings() {
+
+ {/* Restore modal */} + {restoreModal && ( +
+
+
+
+

Restore Backup

+

{restoreModal.backup_id}

+
+ +
+

Select which services to restore:

+
+ {RESTORE_SERVICES.map(({ key, label }) => ( + + ))} +
+
+ + +
+ {restoreServices.size === RESTORE_SERVICES.length && ( +

All services selected — full restore including system files.

+ )} +
+ + +
+
+
+ )} + {/* Export / Import */}
diff --git a/webui/src/services/api.js b/webui/src/services/api.js index 79e12b7..77b62ca 100644 --- a/webui/src/services/api.js +++ b/webui/src/services/api.js @@ -39,10 +39,21 @@ export const cellAPI = { updateConfig: (config) => api.put('/api/config', config), createBackup: () => api.post('/api/config/backup'), 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}`), - exportConfig: (format = 'json') => api.get('/api/config/export', { params: { format } }), - importConfig: (config, format = 'json') => api.post('/api/config/import', { config, format }), + downloadBackup: (id) => api.get(`/api/config/backups/${id}/download`, { responseType: 'blob' }), + 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'), cancelPending: () => api.delete('/api/config/pending'), applyPending: () => api.post('/api/config/apply'),