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 @@