diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..114ea57 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +data.json \ No newline at end of file diff --git a/.gitignore b/.gitignore index de8396c..c22c400 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ data.json # IDEs .vscode/ .idea/ +.worktrees/ +docs/ diff --git a/app.py b/app.py index 9271409..7240800 100644 --- a/app.py +++ b/app.py @@ -28,7 +28,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Stre from starlette.background import BackgroundTask from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi import FastAPI, Request, Query, UploadFile, File +from fastapi import FastAPI, Request, Query, UploadFile, File, Form from starlette.middleware.sessions import SessionMiddleware from pydantic import BaseModel, Field from typing import Optional, List, Dict @@ -3644,6 +3644,116 @@ async def api_protocol_backup_download(request: Request, server_id: int, req: Ba ssh.disconnect() +@app.post('/api/servers/{server_id}/backups/upload', tags=["Protocols"]) +async def api_protocol_backup_upload( + request: Request, + server_id: int, + protocol: str = Form(...), + file: UploadFile = File(...), +): + """Upload a protocol backup archive onto the remote server.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if not is_valid_protocol(protocol): + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + ssh = None + tmp_path = None + try: + data = load_data() + if server_id < 0 or server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + content = await file.read(BackupManager.MAX_UPLOAD_BYTES + 1) + if not content: + return JSONResponse({'error': 'Empty file'}, status_code=400) + if len(content) > BackupManager.MAX_UPLOAD_BYTES: + return JSONResponse({'error': 'Backup file is too large'}, status_code=413) + fd, tmp_path = tempfile.mkstemp(prefix='amnezia-backup-upload-', suffix='.tar.gz') + os.write(fd, content) + os.close(fd) + fd = None + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + result = BackupManager(ssh).upload_backup(protocol, file.filename, tmp_path) + if result.get('status') == 'error': + return JSONResponse({'error': result.get('message', 'Failed to upload backup')}, status_code=400) + return result + except Exception as e: + logger.exception("Error uploading protocol backup") + return JSONResponse({'error': 'Internal server error'}, status_code=500) + finally: + if tmp_path and os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except Exception: + pass + if ssh: + ssh.disconnect() + + +@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 from a backup archive on the remote server.""" + 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) + filename = BackupManager.safe_filename(req.filename) + if not filename: + return JSONResponse({'error': 'Invalid backup filename'}, status_code=400) + ssh = None + try: + data = load_data() + if server_id < 0 or server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + container = protocol_container_name(req.protocol) + if not container: + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + ssh = get_ssh(server) + ssh.connect() + result = BackupManager(ssh).restore_backup(req.protocol, container, 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': 'Internal server error'}, status_code=500) + finally: + if ssh: + ssh.disconnect() + + +@app.post('/api/servers/{server_id}/backups/delete', tags=["Protocols"]) +async def api_protocol_backup_delete(request: Request, server_id: int, req: BackupDownloadRequest): + """Delete one protocol backup archive on the remote server.""" + 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) + filename = BackupManager.safe_filename(req.filename) + if not filename: + return JSONResponse({'error': 'Invalid backup filename'}, status_code=400) + ssh = None + try: + data = load_data() + if server_id < 0 or server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + result = BackupManager(ssh).delete_backup(req.protocol, filename) + if result.get('status') == 'error': + return JSONResponse({'error': result.get('message', 'Failed to delete backup')}, status_code=404) + return result + except Exception as e: + logger.exception("Error deleting protocol backup") + return JSONResponse({'error': 'Internal server error'}, 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 4a9027f..1531c31 100644 --- a/managers/backup_manager.py +++ b/managers/backup_manager.py @@ -1,6 +1,10 @@ import json +import os import re import shlex +import tarfile +import tempfile +import time class BackupManager: @@ -12,6 +16,8 @@ class BackupManager: """ BACKUP_ROOT = '/opt/amnezia/backups' + MAX_UPLOAD_BYTES = 32 * 1024 * 1024 + BACKUP_FORMAT = 'amnezia-web-panel-protocol-v1' def __init__(self, ssh_manager): self.ssh = ssh_manager @@ -37,6 +43,55 @@ class BackupManager: return None return name + @classmethod + def normalize_member_name(cls, name): + name = str(name or '').replace('\\', '/') + while name.startswith('./'): + name = name[2:] + if name in ('', '.'): + return '' + if name != 'backup-info.json': + name = name.strip('/') + else: + name = name.lstrip('/') + return name + + @classmethod + def is_ignored_member_name(cls, name): + """Skip macOS AppleDouble / Finder noise that tar may include.""" + base = os.path.basename(cls.normalize_member_name(name) or name.replace('\\', '/')) + return base.startswith('._') or base in ('.DS_Store', 'Thumbs.db') + + @classmethod + def is_allowed_member_path(cls, name): + """Return True if a tar member path stays inside the backup layout.""" + name = cls.normalize_member_name(name) + # Root entry produced by `tar -C work_dir -czf archive .` + if name == '': + return True + if not name or '\0' in name: + return False + if name.startswith('/') or name.startswith('~'): + return False + parts = [p for p in name.split('/') if p not in ('', '.')] + if not parts or any(p == '..' for p in parts): + return False + top = parts[0] + if top == 'backup-info.json': + return len(parts) == 1 + return top in ('host', 'container', 'docker') + + @classmethod + def is_allowed_link_target(cls, member_name, linkname): + linkname = str(linkname or '').replace('\\', '/') + if not linkname or linkname.startswith('/') or linkname.startswith('~'): + return False + member_dir = os.path.dirname(cls.normalize_member_name(member_name)) + resolved = os.path.normpath(os.path.join(member_dir or '.', linkname)).replace('\\', '/') + if resolved.startswith('../') or resolved == '..': + return False + return cls.is_allowed_member_path(resolved) + def _paths_for(self, protocol, container_name): base = self.proto_base(protocol) idx = self.proto_instance(protocol) @@ -84,11 +139,13 @@ class BackupManager: def list_backups(self, protocol): safe_proto = self.safe_protocol(protocol) backup_dir = f'{self.BACKUP_ROOT}/{safe_proto}' - cmd = ( + # Entire pipeline must run under sudo: `sudo mkdir && find` leaves find as cloud. + inner = ( f"mkdir -p {shlex.quote(backup_dir)} && " f"find {shlex.quote(backup_dir)} -maxdepth 1 -type f -name '*.tar.gz' " "-printf '%f|%s|%T@\\n' 2>/dev/null | sort -t '|' -k3,3nr" ) + cmd = f"bash -c {shlex.quote(inner)}" out, err, code = self.ssh.run_sudo_command(cmd) if code != 0: return {'status': 'error', 'message': err or out or 'Failed to list backups'} @@ -162,9 +219,297 @@ chmod 0644 "$archive" printf '%s\n' "$archive" """.strip() - out, err, code = self.ssh.run_sudo_command(script) + out, err, code = self.ssh.run_sudo_script(script, timeout=180) if code != 0: return {'status': 'error', 'message': err or out or 'Failed to create backup'} path = (out or '').strip().splitlines()[-1] if (out or '').strip() else '' name = path.rsplit('/', 1)[-1] if path else '' + if not name or not path.startswith(self.BACKUP_ROOT): + return {'status': 'error', 'message': err or out or 'Backup archive was not created'} return {'status': 'success', 'protocol': protocol, 'backup': {'name': name, 'path': path}} + + @classmethod + def inspect_archive(cls, path): + """Validate layout/metadata of a protocol backup archive. + + Rejects tar-slip paths, absolute members, and link targets that escape + the allowlisted backup tree before any remote extract runs. + """ + try: + with tarfile.open(path, 'r:gz') as archive: + members = archive.getmembers() + if not members: + return {'status': 'error', 'message': 'Invalid backup archive'} + info_member = None + for member in members: + if cls.is_ignored_member_name(member.name): + continue + if not cls.is_allowed_member_path(member.name): + return {'status': 'error', 'message': 'Backup archive contains unsafe paths'} + normalized = cls.normalize_member_name(member.name) + if normalized == '': + if not member.isdir(): + return {'status': 'error', 'message': 'Backup archive contains unsafe paths'} + continue + if member.issym() or member.islnk(): + if not cls.is_allowed_link_target(member.name, member.linkname): + return {'status': 'error', 'message': 'Backup archive contains unsafe paths'} + elif not (member.isfile() or member.isdir()): + return {'status': 'error', 'message': 'Backup archive contains unsupported entries'} + if normalized == 'backup-info.json': + if not member.isfile() or member.size > 65536: + return {'status': 'error', 'message': 'Invalid backup archive'} + info_member = member + if info_member is None: + return {'status': 'error', 'message': 'Invalid backup archive'} + raw = archive.extractfile(info_member) + if raw is None: + return {'status': 'error', 'message': 'Invalid backup archive'} + info = json.loads(raw.read().decode('utf-8')) + except (tarfile.TarError, OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError): + return {'status': 'error', 'message': 'Invalid backup archive'} + if not isinstance(info, dict): + return {'status': 'error', 'message': 'Invalid backup archive'} + if str(info.get('backup_format') or '') != cls.BACKUP_FORMAT: + return {'status': 'error', 'message': 'Unsupported backup format'} + return {'status': 'success', 'info': info} + + def _fetch_remote_archive(self, remote_path): + """Copy a remote backup to a local temp file for validation.""" + filename = os.path.basename(remote_path) + safe_name = self.safe_filename(filename) + if not safe_name: + return None, 'Invalid backup filename' + tmp_remote = f'/tmp/_amnz_validate_{safe_name}' + quoted_remote = shlex.quote(remote_path) + quoted_tmp = shlex.quote(tmp_remote) + _, err, code = self.ssh.run_sudo_command( + f"test -f {quoted_remote} && cp {quoted_remote} {quoted_tmp} && chmod 0644 {quoted_tmp}" + ) + if code != 0: + return None, err or 'Backup not found' + fd, local_path = tempfile.mkstemp(prefix='amnezia-backup-validate-', suffix='.tar.gz') + os.close(fd) + try: + self.ssh.ensure_connected() + sftp = self.ssh.client.open_sftp() + try: + sftp.get(tmp_remote, local_path) + finally: + sftp.close() + self.ssh.run_sudo_command(f"rm -f {quoted_tmp}") + return local_path, None + except Exception as exc: + try: + os.remove(local_path) + except OSError: + pass + self.ssh.run_sudo_command(f"rm -f {quoted_tmp}") + return None, str(exc) or 'Failed to read backup archive' + + def upload_backup(self, protocol, filename, local_path): + inspected = self.inspect_archive(local_path) + if inspected.get('status') != 'success': + return inspected + info = inspected.get('info') or {} + archived_proto = str(info.get('protocol') or '') + if archived_proto != str(protocol): + return {'status': 'error', 'message': 'Backup protocol mismatch'} + + safe_proto = self.safe_protocol(protocol) + safe_name = self.safe_filename(os.path.basename(filename or '')) + stamp = time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()) + if not safe_name: + safe_name = f'{safe_proto}-{stamp}.tar.gz' + backup_dir = f'{self.BACKUP_ROOT}/{safe_proto}' + dest = f'{backup_dir}/{safe_name}' + dest_q = shlex.quote(dest) + _, _, exists_code = self.ssh.run_sudo_command(f'test -f {dest_q}') + if exists_code == 0: + safe_name = f'{safe_proto}-{stamp}.tar.gz' + dest = f'{backup_dir}/{safe_name}' + dest_q = shlex.quote(dest) + tmp_remote = f'/tmp/_amnz_upload_{safe_name}' + tmp_q = shlex.quote(tmp_remote) + self.ssh.ensure_connected() + sftp = self.ssh.client.open_sftp() + try: + sftp.put(local_path, tmp_remote) + finally: + sftp.close() + backup_dir_q = shlex.quote(backup_dir) + inner = ( + f'mkdir -p {backup_dir_q} && ' + f'mv {tmp_q} {dest_q} && chmod 0644 {dest_q}' + ) + out, err, code = self.ssh.run_sudo_command(f'bash -c {shlex.quote(inner)}') + if code != 0: + self.ssh.run_sudo_command(f'rm -f {tmp_q}') + return {'status': 'error', 'message': err or out or 'Failed to upload backup'} + return {'status': 'success', 'protocol': protocol, 'backup': {'name': safe_name, 'path': dest}} + + def delete_backup(self, protocol, filename): + safe_proto = self.safe_protocol(protocol) + safe_name = self.safe_filename(filename) + if not safe_name: + return {'status': 'error', 'message': 'Invalid backup filename'} + remote_path = f'{self.BACKUP_ROOT}/{safe_proto}/{safe_name}' + quoted = shlex.quote(remote_path) + inner = f'test -f {quoted} && rm -f {quoted}' + out, err, code = self.ssh.run_sudo_command(f'bash -c {shlex.quote(inner)}') + if code != 0: + return {'status': 'error', 'message': err or out or 'Backup not found'} + return {'status': 'success', 'protocol': protocol, 'filename': safe_name} + + def restore_backup(self, protocol, container_name, filename): + safe_proto = self.safe_protocol(protocol) + safe_name = self.safe_filename(filename) + if not safe_name: + return {'status': 'error', 'message': 'Invalid backup filename'} + backup_dir = f'{self.BACKUP_ROOT}/{safe_proto}' + remote_path = f'{backup_dir}/{safe_name}' + local_path, fetch_err = self._fetch_remote_archive(remote_path) + if not local_path: + return {'status': 'error', 'message': fetch_err or 'Backup not found'} + try: + inspected = self.inspect_archive(local_path) + if inspected.get('status') != 'success': + return inspected + info = inspected.get('info') or {} + if str(info.get('protocol') or '') != str(protocol): + return {'status': 'error', 'message': 'Backup protocol mismatch'} + finally: + try: + os.remove(local_path) + except OSError: + pass + + paths = self._paths_for(protocol, container_name) + filename_q = shlex.quote(safe_name) + backup_dir_q = shlex.quote(backup_dir) + container_q = shlex.quote(str(container_name or '')) + + host_restore_cmds = '' + for dest in paths['host']: + dest_q = shlex.quote(dest) + src = f'$work_dir/host{dest}' + # Replace host path when present in backup; remove when absent + # so restore is idempotent with the archived state. + host_restore_cmds += ( + f'if [ -d "{src}" ]; then\n' + f' mkdir -p "$(dirname {dest_q})"\n' + f' [ -e {dest_q} ] && rm -rf {dest_q}\n' + f' cp -a "{src}" {dest_q}\n' + f'elif [ -e {dest_q} ]; then\n' + f' rm -rf {dest_q}\n' + f'fi\n' + ) + + container_restore_cmds = '' + for dest in paths['container']: + dest_q = shlex.quote(dest) + # Prefer container snapshot; fall back to host copy of the same path. + # Replace via tar-stream so contents are fully overwritten (docker cp + # of a directory is easy to get wrong / nest). + container_restore_cmds += f""" +src="$work_dir/container{dest}" +host_src="$work_dir/host{dest}" +dest={dest_q} +if [ ! -d "$src" ] && [ -d "$host_src" ]; then + src="$host_src" +fi +if [ -d "$src" ]; then + docker exec "$container" rm -rf "$dest" + docker exec "$container" mkdir -p "$dest" + tar -C "$src" -cf - . | docker exec -i "$container" tar -C "$dest" -xf - +elif [ -f "$src" ] || [ -f "$host_src" ]; then + if [ ! -f "$src" ]; then src="$host_src"; fi + docker exec "$container" mkdir -p "$(dirname "$dest")" + docker cp "$src" "$container:$dest" +else + docker exec "$container" rm -rf "$dest" +fi +""" + + # Defense in depth: refuse absolute / traversal members before extract, + # then keep extraction rooted under $work_dir. + script = f""" +set -eu +umask 077 +container={container_q} +backup_dir={backup_dir_q} +archive="$backup_dir/{filename_q}" +work_dir=$(mktemp -d /tmp/amnezia-restore-XXXXXX) +cleanup() {{ rm -rf "$work_dir"; }} +trap cleanup EXIT + +if [ ! -f "$archive" ]; then + echo "Backup not found: $archive" >&2 + exit 1 +fi + +while IFS= read -r member; do + case "$member" in + *..*|/*|~*) + echo "Unsafe archive member: $member" >&2 + exit 1 + ;; + esac + member="${{member#./}}" + base="${{member##*/}}" + case "$base" in + ._*|.DS_Store|Thumbs.db) continue ;; + esac + case "$member" in + ''|.) continue ;; + backup-info.json|host|host/*|container|container/*|docker|docker/*) ;; + *) + echo "Unsafe archive member: $member" >&2 + exit 1 + ;; + esac +done < <(tar -tzf "$archive") + +tar -C "$work_dir" -xzf "$archive" +# Fail closed if anything landed outside the work dir (symlink escapes, etc.). +while IFS= read -r -d '' path; do + case "$path" in + "$work_dir"|"$work_dir"/*) ;; + *) + echo "Extract escaped work dir: $path" >&2 + exit 1 + ;; + esac +done < <(find "$work_dir" -print0) + +{host_restore_cmds} + +if [ -n "$container" ] && docker inspect "$container" >/dev/null 2>&1; then + if ! docker start "$container" >/dev/null 2>&1; then + echo "Failed to start container $container for restore" >&2 + exit 1 + fi + # Stop VPN interfaces before replacing on-disk config so awg/wg do not + # keep old peers alive across a partial file replace. + docker exec "$container" sh -c ' + for p in /opt/amnezia/awg/awg0.conf /opt/amnezia/awg/wg0.conf /opt/amnezia/wireguard/wg0.conf; do + [ -f "$p" ] || continue + awg-quick down "$p" 2>/dev/null || true + wg-quick down "$p" 2>/dev/null || true + done + true + ' >/dev/null 2>&1 || true +{container_restore_cmds} + if ! docker restart "$container" >/dev/null 2>&1; then + echo "Failed to restart container $container after restore" >&2 + exit 1 + fi +fi + +echo "restore_success" +""".strip() + + out, err, code = self.ssh.run_sudo_script(script, timeout=180) + if code != 0: + return {'status': 'error', 'message': err or out or 'Failed to restore backup'} + return {'status': 'success', 'protocol': protocol, 'filename': filename} diff --git a/static/css/style.css b/static/css/style.css index afadbd6..f70525a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1223,6 +1223,76 @@ a:hover { font-family: 'SF Mono', 'Fira Code', monospace; } +/* ===== Protocol backup list ===== */ +.backup-list { + display: flex; + flex-direction: column; + gap: var(--space-sm); + max-height: min(420px, 52vh); + overflow-y: auto; + margin-right: -4px; + padding-right: 4px; +} + +.backup-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm); + margin-bottom: var(--space-md); +} + +.backup-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: 10px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + text-align: left; +} + +.backup-row-meta { + min-width: 0; + flex: 1; +} + +.backup-row-name { + font-size: 0.82rem; + font-weight: 500; + font-family: 'SF Mono', 'Fira Code', monospace; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.backup-row-info { + margin-top: 2px; + font-size: 0.72rem; + color: var(--text-muted); +} + +.backup-row-actions { + display: flex; + flex-shrink: 0; + gap: var(--space-xs); +} + +@media (max-width: 520px) { + .backup-row { + flex-wrap: wrap; + align-items: flex-start; + } + + .backup-row-actions { + width: 100%; + justify-content: flex-end; + } +} + /* ===== Clients Table ===== */ .clients-section { margin-top: var(--space-xl); diff --git a/templates/server.html b/templates/server.html index b6b032e..120ed16 100644 --- a/templates/server.html +++ b/templates/server.html @@ -867,10 +867,14 @@
{{ _('backup_desc') }}
-
- + +
@@ -879,7 +883,7 @@
{{ _('no_backups') }}
{{ _('no_backups_desc') }}
-
+
@@ -2150,6 +2154,17 @@ await loadProtocolBackups(); } + document.getElementById('backupList').addEventListener('click', function (event) { + const btn = event.target.closest('[data-backup-action]'); + if (!btn || !document.getElementById('backupList').contains(btn)) return; + const proto = btn.getAttribute('data-proto'); + const filename = btn.getAttribute('data-filename'); + const action = btn.getAttribute('data-backup-action'); + if (action === 'restore') restoreProtocolBackup(proto, filename); + else if (action === 'download') downloadProtocolBackup(proto, filename); + else if (action === 'delete') deleteProtocolBackup(proto, filename); + }); + async function loadProtocolBackups() { const proto = document.getElementById('backupProto').value; const list = document.getElementById('backupList'); @@ -2166,12 +2181,16 @@ return; } list.innerHTML = backups.map(b => ` -
-
-
${b.name}
-
${formatBytes(b.size)} • ${formatBackupTime(b.mtime)}
+
+
+
${escapeHtml(b.name)}
+
${formatBytes(b.size)} · ${formatBackupTime(b.mtime)}
+
+
+ + +
-
`).join(''); } catch (err) { @@ -2199,6 +2218,42 @@ } } + document.getElementById('backupUploadFile').addEventListener('change', uploadProtocolBackup); + + async function uploadProtocolBackup(event) { + const file = event.target.files && event.target.files[0]; + event.target.value = ''; + if (!file) return; + const proto = document.getElementById('backupProto').value; + const btn = document.getElementById('uploadBackupBtn'); + const oldText = btn.textContent; + btn.disabled = true; + btn.textContent = _('uploading_backup'); + try { + const body = new FormData(); + body.append('protocol', proto); + body.append('file', file); + const res = await fetch(`/api/servers/${SERVER_ID}/backups/upload`, { + method: 'POST', + body + }); + let data = {}; + try { + data = await res.json(); + } catch (_) {} + if (!res.ok) { + throw new Error(data.error || 'Upload failed'); + } + showToast(_('backup_uploaded'), 'success'); + await loadProtocolBackups(); + } catch (err) { + showToast(_('error') + ': ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = oldText; + } + } + async function downloadProtocolBackup(proto, filename) { try { const res = await fetch(`/api/servers/${SERVER_ID}/backups/download`, { @@ -2228,6 +2283,37 @@ } } + async function restoreProtocolBackup(proto, filename) { + if (!confirm(_('restore_confirm_protocol'))) return; + try { + await apiCall(`/api/servers/${SERVER_ID}/backups/restore`, 'POST', { + protocol: proto, + filename: filename + }); + showToast(_('restore_success'), 'success'); + await loadProtocolBackups(); + if (typeof loadConnections === 'function') { + await loadConnections(); + } + } catch (err) { + showToast(_('error') + ': ' + err.message, 'error'); + } + } + + async function deleteProtocolBackup(proto, filename) { + if (!confirm(_('delete_backup_confirm'))) return; + try { + await apiCall(`/api/servers/${SERVER_ID}/backups/delete`, 'POST', { + protocol: proto, + filename: filename + }); + showToast(_('backup_deleted'), 'success'); + await loadProtocolBackups(); + } catch (err) { + showToast(_('error') + ': ' + err.message, 'error'); + } + } + async function uninstallProtocol(proto) { const name = getProtoTitle(proto); if (!confirm(_('uninstall_confirm').replace('{}', name))) return; diff --git a/tests/test_backup_archive_validation.py b/tests/test_backup_archive_validation.py new file mode 100644 index 0000000..6eadc62 --- /dev/null +++ b/tests/test_backup_archive_validation.py @@ -0,0 +1,135 @@ +import io +import json +import os +import shutil +import subprocess +import tarfile +import tempfile +import unittest + +from managers.backup_manager import BackupManager + + +class BackupArchiveValidationTests(unittest.TestCase): + def _write_archive(self, members): + fd, path = tempfile.mkstemp(suffix='.tar.gz') + os.close(fd) + with tarfile.open(path, 'w:gz') as archive: + for name, payload in members: + info = tarfile.TarInfo(name=name) + if payload is None: + info.type = tarfile.DIRTYPE + archive.addfile(info) + continue + data = payload if isinstance(payload, bytes) else payload.encode('utf-8') + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + self.addCleanup(lambda: os.path.exists(path) and os.remove(path)) + return path + + def _write_panel_style_archive(self): + """Match server create_backup: tar -C work_dir -czf archive .""" + work = tempfile.mkdtemp() + self.addCleanup(lambda: shutil.rmtree(work, ignore_errors=True)) + with open(os.path.join(work, 'backup-info.json'), 'w', encoding='utf-8') as fh: + json.dump({ + 'protocol': 'awg3', + 'container': 'amnezia-awg3', + 'backup_format': BackupManager.BACKUP_FORMAT, + }, fh) + os.makedirs(os.path.join(work, 'host/opt/amnezia/awg')) + with open(os.path.join(work, 'host/opt/amnezia/awg/awg0.conf'), 'w', encoding='utf-8') as fh: + fh.write('PrivateKey = x\n') + os.makedirs(os.path.join(work, 'container/opt/amnezia/awg')) + with open(os.path.join(work, 'container/opt/amnezia/awg/clientsTable'), 'w', encoding='utf-8') as fh: + fh.write('[]') + os.makedirs(os.path.join(work, 'docker')) + with open(os.path.join(work, 'docker/inspect.json'), 'w', encoding='utf-8') as fh: + fh.write('{}') + fd, path = tempfile.mkstemp(suffix='.tar.gz') + os.close(fd) + subprocess.check_call(['tar', '-C', work, '-czf', path, '.']) + self.addCleanup(lambda: os.path.exists(path) and os.remove(path)) + return path + + def test_accepts_panel_layout(self): + info = { + 'protocol': 'awg3', + 'container': 'amnezia-awg3', + 'backup_format': BackupManager.BACKUP_FORMAT, + } + path = self._write_archive([ + ('backup-info.json', json.dumps(info)), + ('host/opt/amnezia/awg/', None), + ('host/opt/amnezia/awg/awg0.conf', 'PrivateKey = x\n'), + ('container/opt/amnezia/awg/clientsTable', '[]'), + ('docker/inspect.json', '{}'), + ]) + result = BackupManager.inspect_archive(path) + self.assertEqual(result['status'], 'success') + self.assertEqual(result['info']['protocol'], 'awg3') + + def test_accepts_real_tar_dot_archive(self): + path = self._write_panel_style_archive() + result = BackupManager.inspect_archive(path) + self.assertEqual(result['status'], 'success', result) + self.assertEqual(result['info']['protocol'], 'awg3') + + def test_rejects_path_traversal(self): + info = { + 'protocol': 'awg3', + 'backup_format': BackupManager.BACKUP_FORMAT, + } + path = self._write_archive([ + ('backup-info.json', json.dumps(info)), + ('host/../../etc/cron.d/evil', 'evil'), + ]) + result = BackupManager.inspect_archive(path) + self.assertEqual(result['status'], 'error') + self.assertIn('unsafe', result['message'].lower()) + + def test_rejects_absolute_member(self): + info = { + 'protocol': 'awg3', + 'backup_format': BackupManager.BACKUP_FORMAT, + } + path = self._write_archive([ + ('backup-info.json', json.dumps(info)), + ('/etc/passwd', 'root:x'), + ]) + result = BackupManager.inspect_archive(path) + self.assertEqual(result['status'], 'error') + + def test_requires_backup_format(self): + path = self._write_archive([ + ('backup-info.json', json.dumps({'protocol': 'awg3'})), + ('host/opt/amnezia/awg/awg0.conf', 'x'), + ]) + result = BackupManager.inspect_archive(path) + self.assertEqual(result['status'], 'error') + self.assertIn('format', result['message'].lower()) + + def test_rejects_symlink_escape(self): + info = { + 'protocol': 'awg3', + 'backup_format': BackupManager.BACKUP_FORMAT, + } + fd, path = tempfile.mkstemp(suffix='.tar.gz') + os.close(fd) + with tarfile.open(path, 'w:gz') as archive: + meta = json.dumps(info).encode('utf-8') + meta_info = tarfile.TarInfo('backup-info.json') + meta_info.size = len(meta) + archive.addfile(meta_info, io.BytesIO(meta)) + link = tarfile.TarInfo('host/escape') + link.type = tarfile.SYMTYPE + link.linkname = '../../etc/passwd' + archive.addfile(link) + self.addCleanup(lambda: os.path.exists(path) and os.remove(path)) + result = BackupManager.inspect_archive(path) + self.assertEqual(result['status'], 'error') + self.assertIn('unsafe', result['message'].lower()) + + +if __name__ == '__main__': + unittest.main() diff --git a/translations/en.json b/translations/en.json index ac87f83..1b390e2 100644 --- a/translations/en.json +++ b/translations/en.json @@ -475,5 +475,13 @@ "warnings_cleared": "Warnings cleared", "max_speed": "Max speed (Mbps)", "speed_limit_unlim": "Speed limit (0 = unlim.)", - "port_in_use_by": "Port {port} is already used by {owner}. Choose another port." + "port_in_use_by": "Port {port} is already used by {owner}. Choose another port.", + "restore": "Restore", + "restore_confirm_protocol": "Are you sure? The current protocol configuration will be overwritten with this backup.", + "restore_success": "Backup restored successfully!", + "delete_backup_confirm": "Delete this backup from the server? This cannot be undone.", + "backup_deleted": "Backup deleted", + "upload_backup": "Upload", + "uploading_backup": "Uploading...", + "backup_uploaded": "Backup uploaded" } diff --git a/translations/fa.json b/translations/fa.json index 5ac66aa..a9461b2 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -442,5 +442,13 @@ "warnings_cleared": "هشدارها پاک شدند", "max_speed": "حداکثر سرعت (Mbps)", "speed_limit_unlim": "محدودیت سرعت (0 = نامحدود)", - "port_in_use_by": "پورت {port} قبلاً توسط {owner} استفاده شده است. پورت دیگری انتخاب کنید." + "port_in_use_by": "پورت {port} قبلاً توسط {owner} استفاده شده است. پورت دیگری انتخاب کنید.", + "restore": "بازیابی", + "restore_confirm_protocol": "آیا مطمئن هستید؟ پیکربندی فعلی پروتکل با این پشتیبان جایگزین خواهد شد.", + "restore_success": "بازیابی پشتیبان با موفقیت انجام شد!", + "delete_backup_confirm": "این پشتیبان از سرور حذف شود؟ این عمل قابل بازگشت نیست.", + "backup_deleted": "پشتیبان حذف شد", + "upload_backup": "بارگذاری", + "uploading_backup": "در حال بارگذاری...", + "backup_uploaded": "پشتیبان بارگذاری شد" } diff --git a/translations/fr.json b/translations/fr.json index 019e583..d769cbb 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -442,5 +442,13 @@ "warnings_cleared": "Avertissements effacés", "max_speed": "Vitesse max (Mbps)", "speed_limit_unlim": "Limite de vitesse (0 = illimité)", - "port_in_use_by": "Le port {port} est déjà utilisé par {owner}. Choisissez un autre port." + "port_in_use_by": "Le port {port} est déjà utilisé par {owner}. Choisissez un autre port.", + "restore": "Restaurer", + "restore_confirm_protocol": "Êtes-vous sûr ? La configuration actuelle du protocole sera écrasée par cette sauvegarde.", + "restore_success": "Sauvegarde restaurée avec succès !", + "delete_backup_confirm": "Supprimer cette sauvegarde du serveur ? Cette action est irréversible.", + "backup_deleted": "Sauvegarde supprimée", + "upload_backup": "Téléverser", + "uploading_backup": "Téléversement...", + "backup_uploaded": "Sauvegarde téléversée" } diff --git a/translations/ru.json b/translations/ru.json index b2864c6..5e0bcb7 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -475,5 +475,13 @@ "warnings_cleared": "Предупреждения очищены", "max_speed": "Макс. скорость (Мбит/с)", "speed_limit_unlim": "Лимит скорости (0 = без лимита)", - "port_in_use_by": "Порт {port} уже занят ({owner}). Выберите другой порт." + "port_in_use_by": "Порт {port} уже занят ({owner}). Выберите другой порт.", + "restore": "Восстановить", + "restore_confirm_protocol": "Вы уверены? Текущая конфигурация протокола будет заменена данным бекапом.", + "restore_success": "Бекап успешно восстановлен!", + "delete_backup_confirm": "Удалить этот бекап с сервера? Это действие нельзя отменить.", + "backup_deleted": "Бекап удалён", + "upload_backup": "Загрузить", + "uploading_backup": "Загрузка...", + "backup_uploaded": "Бекап загружен" } diff --git a/translations/zh.json b/translations/zh.json index 39e7a29..fad071c 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -442,5 +442,13 @@ "warnings_cleared": "警告已清除", "max_speed": "最大速度 (Mbps)", "speed_limit_unlim": "速度限制(0 = 不限)", - "port_in_use_by": "端口 {port} 已被 {owner} 占用,请选择其他端口。" + "port_in_use_by": "端口 {port} 已被 {owner} 占用,请选择其他端口。", + "restore": "恢复", + "restore_confirm_protocol": "您确定吗?当前协议配置将被此备份覆盖。", + "restore_success": "备份恢复成功!", + "delete_backup_confirm": "从服务器删除此备份?此操作无法撤销。", + "backup_deleted": "备份已删除", + "upload_backup": "上传", + "uploading_backup": "正在上传...", + "backup_uploaded": "备份已上传" }