feat: add ability to restore server backups

This commit is contained in:
Viktor Baranov committed 2026-09-04 13:39:56 +03:00
1 parent f38ad4fb77
commit 3770870e32
12 files changed
+805 -16

No files matched your search

+1
View File
@@ -0,0 +1 @@
data.json
+2
View File
@@ -35,3 +35,5 @@ data.json
# IDEs
.vscode/
.idea/
.worktrees/
docs/
+111 -1
View File
@@ -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."""
+347 -2
View File
@@ -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}
+70
View File
@@ -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);
+94 -8
View File
@@ -867,10 +867,14 @@
</div>
<input type="hidden" id="backupProto" value="" />
<div class="form-hint" style="margin-bottom:var(--space-md);">{{ _('backup_desc') }}</div>
<div class="config-actions" style="margin-bottom:var(--space-md);">
<button class="btn btn-primary btn-sm" onclick="createProtocolBackup()" id="createBackupBtn" style="flex:1">
<div class="backup-toolbar">
<button class="btn btn-primary btn-sm" onclick="createProtocolBackup()" id="createBackupBtn">
{{ _('create_backup') }}
</button>
<button class="btn btn-secondary btn-sm" onclick="document.getElementById('backupUploadFile').click()" id="uploadBackupBtn">
{{ _('upload_backup') }}
</button>
<input type="file" id="backupUploadFile" class="hidden" accept=".tar.gz,application/gzip,application/x-gzip">
<button class="btn btn-secondary btn-sm" onclick="loadProtocolBackups()">{{ _('refresh') }}</button>
</div>
<div id="backupLoading" class="form-hint hidden">{{ _('loading') }}</div>
@@ -879,7 +883,7 @@
<div class="empty-title">{{ _('no_backups') }}</div>
<div class="empty-desc">{{ _('no_backups_desc') }}</div>
</div>
<div id="backupList" style="display:flex; flex-direction:column; gap:var(--space-sm);"></div>
<div id="backupList" class="backup-list"></div>
</div>
</div>
@@ -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 => `
<div class="protocol-info-item" style="align-items:center; gap:var(--space-sm);">
<div style="min-width:0; flex:1;">
<div class="protocol-info-value" style="word-break:break-all;">${b.name}</div>
<div class="protocol-info-label">${formatBytes(b.size)} • ${formatBackupTime(b.mtime)}</div>
<div class="backup-row">
<div class="backup-row-meta">
<div class="backup-row-name" title="${escapeHtml(b.name)}">${escapeHtml(b.name)}</div>
<div class="backup-row-info">${formatBytes(b.size)} · ${formatBackupTime(b.mtime)}</div>
</div>
<div class="backup-row-actions">
<button type="button" class="btn btn-secondary btn-sm" data-backup-action="restore" data-proto="${escapeHtml(proto)}" data-filename="${escapeHtml(b.name)}">${_('restore')}</button>
<button type="button" class="btn btn-secondary btn-sm" data-backup-action="download" data-proto="${escapeHtml(proto)}" data-filename="${escapeHtml(b.name)}">${_('download')}</button>
<button type="button" class="btn btn-danger btn-sm" data-backup-action="delete" data-proto="${escapeHtml(proto)}" data-filename="${escapeHtml(b.name)}">${_('delete')}</button>
</div>
<button class="btn btn-secondary btn-sm" onclick="downloadProtocolBackup('${proto}', '${b.name}')">${_('download')}</button>
</div>
`).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;
+135
View File
@@ -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()
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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": "پشتیبان بارگذاری شد"
}
+9 -1
View File
@@ -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"
}
+9 -1
View File
@@ -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": "Бекап загружен"
}
+9 -1
View File
@@ -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": "备份已上传"
}