diff --git a/app.py b/app.py index fa78f39..5c911fb 100644 --- a/app.py +++ b/app.py @@ -2821,6 +2821,157 @@ async def api_protocol_backup_download(request: Request, server_id: int, req: Ba ssh.disconnect() +WG_BACKUP_BASES = {'awg', 'awg2', 'awg_legacy', 'wireguard'} + + +def _safe_conf_filename(name: str, used: set) -> str: + base = re.sub(r'[^\w.\-]+', '_', str(name or '').strip(), flags=re.UNICODE).strip('._') or 'client' + if len(base) > 80: + base = base[:80] + candidate = f'{base}.conf' + idx = 2 + while candidate.lower() in used: + candidate = f'{base}_{idx}.conf' + idx += 1 + used.add(candidate.lower()) + return candidate + + +@app.post('/api/servers/{server_id}/backups/restore', tags=["Protocols"]) +async def api_protocol_backup_restore(request: Request, server_id: int, req: BackupDownloadRequest): + """Restore a protocol backup archive into the remote container/host paths.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if not is_valid_protocol(req.protocol): + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + ssh = None + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + container = protocol_container_name(req.protocol) + if not container: + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + result = BackupManager(ssh).restore_backup(req.protocol, container, req.filename) + if result.get('status') == 'error': + return JSONResponse({'error': result.get('message', 'Failed to restore backup')}, status_code=500) + return result + except Exception as e: + logger.exception("Error restoring protocol backup") + return JSONResponse({'error': str(e)}, status_code=500) + finally: + if ssh: + ssh.disconnect() + + +@app.post('/api/servers/{server_id}/backups/export-clients', tags=["Protocols"]) +async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest): + """Download a ZIP with all reconstructable WireGuard/AWG client .conf files.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if not is_valid_protocol(req.protocol): + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + base = protocol_base(req.protocol) + if base not in WG_BACKUP_BASES: + return JSONResponse( + {'error': 'Client config export is only available for WireGuard / AmneziaWG'}, + status_code=400, + ) + + ssh = None + tmp_path = None + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + proto_info = server.get('protocols', {}).get(req.protocol, {}) + port = proto_info.get('port', '55424') + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + clients = _manager_call(manager, 'get_clients', req.protocol) or [] + + fd, tmp_path = tempfile.mkstemp(prefix='amnezia-clients-', suffix='.zip') + os.close(fd) + used_names = set() + exported = 0 + skipped = [] + + with zipfile.ZipFile(tmp_path, 'w', compression=zipfile.ZIP_DEFLATED) as zf: + for client in clients: + ud = client.get('userData') or {} + client_id = client.get('clientId') or '' + client_name = ud.get('clientName') or client_id[:12] or 'client' + if ud.get('externalClient') or not ud.get('clientPrivateKey'): + skipped.append(f'{client_name}: no private key (external/native)') + continue + try: + config = _manager_call( + manager, 'get_client_config', req.protocol, client_id, server['host'], port + ) + except Exception as exc: + skipped.append(f'{client_name}: {exc}') + continue + if not config: + skipped.append(f'{client_name}: empty config') + continue + filename = _safe_conf_filename(client_name, used_names) + zf.writestr(filename, config if config.endswith('\n') else config + '\n') + exported += 1 + + readme = [ + f'Protocol: {req.protocol}', + f'Server: {server.get("name") or server.get("host")}', + f'Host: {server.get("host")}', + f'Exported: {exported}', + f'Skipped: {len(skipped)}', + '', + ] + if skipped: + readme.append('Skipped clients:') + readme.extend(f'- {line}' for line in skipped) + zf.writestr('README.txt', '\n'.join(readme) + '\n') + + ssh.disconnect() + ssh = None + + if exported == 0: + try: + os.remove(tmp_path) + except Exception: + pass + tmp_path = None + return JSONResponse( + {'error': 'No client configs could be exported (missing private keys?)'}, + status_code=404, + ) + + safe_proto = BackupManager.safe_protocol(req.protocol) + stamp = datetime.now().strftime('%Y%m%dT%H%M%S') + download_name = f'{safe_proto}-clients-{stamp}.zip' + return FileResponse( + tmp_path, + media_type='application/zip', + filename=download_name, + background=BackgroundTask(lambda p=tmp_path: os.path.exists(p) and os.remove(p)), + ) + except Exception as e: + logger.exception("Error exporting client configs") + if tmp_path and os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except Exception: + pass + return JSONResponse({'error': str(e)}, status_code=500) + finally: + if ssh: + ssh.disconnect() + + @app.post('/api/servers/{server_id}/container/toggle', tags=["Protocols"]) async def api_container_toggle(request: Request, server_id: int, req: ProtocolRequest): """Start or stop a protocol Docker container.""" diff --git a/managers/backup_manager.py b/managers/backup_manager.py index 35305ed..bc56924 100644 --- a/managers/backup_manager.py +++ b/managers/backup_manager.py @@ -168,3 +168,76 @@ printf '%s\n' "$archive" path = (out or '').strip().splitlines()[-1] if (out or '').strip() else '' name = path.rsplit('/', 1)[-1] if path else '' return {'status': 'success', 'protocol': protocol, 'backup': {'name': name, 'path': path}} + + def restore_backup(self, protocol, container_name, filename): + """Restore protocol files from a previously created backup archive.""" + safe_name = self.safe_filename(filename) + if not safe_name: + return {'status': 'error', 'message': 'Invalid backup filename'} + + safe_proto = self.safe_protocol(protocol) + remote_archive = f'{self.BACKUP_ROOT}/{safe_proto}/{safe_name}' + paths = self._paths_for(protocol, container_name) + container_paths = ' '.join(shlex.quote(p) for p in paths['container']) + host_paths = ' '.join(shlex.quote(p) for p in paths['host']) + archive_q = shlex.quote(remote_archive) + container_q = shlex.quote(str(container_name or '')) + protocol_q = shlex.quote(str(protocol)) + + script = f""" +set -eu +umask 077 +archive={archive_q} +container={container_q} +protocol={protocol_q} +if [ ! -f "$archive" ]; then + echo "Backup archive not found" >&2 + exit 1 +fi +work_dir=$(mktemp -d /tmp/amnezia-restore-XXXXXX) +cleanup() {{ rm -rf "$work_dir"; }} +trap cleanup EXIT +tar -xzf "$archive" -C "$work_dir" +if [ ! -f "$work_dir/backup-info.json" ]; then + echo "Invalid backup archive (missing backup-info.json)" >&2 + exit 1 +fi +restore_host_path() {{ + src="$1" + staged="$work_dir/host$src" + if [ -e "$staged" ]; then + mkdir -p "$(dirname "$src")" + rm -rf "$src" + cp -a "$staged" "$src" + fi +}} +restore_container_path() {{ + src="$1" + staged="$work_dir/container$src" + if [ -n "$container" ] && [ -e "$staged" ] && docker inspect "$container" >/dev/null 2>&1; then + if [ -d "$staged" ]; then + docker exec "$container" mkdir -p "$src" >/dev/null 2>&1 || true + docker cp "$staged/." "$container:$src/" >/dev/null 2>&1 || true + else + parent=$(dirname "$src") + docker exec "$container" mkdir -p "$parent" >/dev/null 2>&1 || true + docker cp "$staged" "$container:$src" >/dev/null 2>&1 || true + fi + fi +}} +for p in {host_paths}; do restore_host_path "$p"; done +for p in {container_paths}; do restore_container_path "$p"; done +if [ -n "$container" ] && docker inspect "$container" >/dev/null 2>&1; then + docker restart "$container" >/dev/null 2>&1 || true + # Prefer bringing the WireGuard/AWG interface back up after file restore + if docker exec "$container" test -x /opt/amnezia/start.sh >/dev/null 2>&1; then + docker exec "$container" /opt/amnezia/start.sh >/dev/null 2>&1 || true + fi +fi +printf 'restored:%s\\n' "$protocol" +""".strip() + + out, err, code = self.ssh.run_sudo_command(script, timeout=180) + if code != 0: + return {'status': 'error', 'message': err or out or 'Failed to restore backup'} + return {'status': 'success', 'protocol': protocol, 'filename': safe_name} diff --git a/templates/server.html b/templates/server.html index 01fcb71..37ebe28 100644 --- a/templates/server.html +++ b/templates/server.html @@ -751,12 +751,16 @@
{{ _('backup_desc') }}
-
+
+
+