mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-10-23 03:37:36 +09:00
feat: add ability to restore server backups
This commit is contained in:
1 parent
f38ad4fb77
commit
3770870e32
12 files changed
+805
-16
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
data.json
|
||||||
@@ -35,3 +35,5 @@ data.json
|
|||||||
# IDEs
|
# IDEs
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
.worktrees/
|
||||||
|
docs/
|
||||||
@@ -28,7 +28,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, Stre
|
|||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
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 starlette.middleware.sessions import SessionMiddleware
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, List, Dict
|
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()
|
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"])
|
@app.post('/api/servers/{server_id}/container/toggle', tags=["Protocols"])
|
||||||
async def api_container_toggle(request: Request, server_id: int, req: ProtocolRequest):
|
async def api_container_toggle(request: Request, server_id: int, req: ProtocolRequest):
|
||||||
"""Start or stop a protocol Docker container."""
|
"""Start or stop a protocol Docker container."""
|
||||||
|
|||||||
+347
-2
@@ -1,6 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
class BackupManager:
|
class BackupManager:
|
||||||
@@ -12,6 +16,8 @@ class BackupManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
BACKUP_ROOT = '/opt/amnezia/backups'
|
BACKUP_ROOT = '/opt/amnezia/backups'
|
||||||
|
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
|
||||||
|
BACKUP_FORMAT = 'amnezia-web-panel-protocol-v1'
|
||||||
|
|
||||||
def __init__(self, ssh_manager):
|
def __init__(self, ssh_manager):
|
||||||
self.ssh = ssh_manager
|
self.ssh = ssh_manager
|
||||||
@@ -37,6 +43,55 @@ class BackupManager:
|
|||||||
return None
|
return None
|
||||||
return name
|
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):
|
def _paths_for(self, protocol, container_name):
|
||||||
base = self.proto_base(protocol)
|
base = self.proto_base(protocol)
|
||||||
idx = self.proto_instance(protocol)
|
idx = self.proto_instance(protocol)
|
||||||
@@ -84,11 +139,13 @@ class BackupManager:
|
|||||||
def list_backups(self, protocol):
|
def list_backups(self, protocol):
|
||||||
safe_proto = self.safe_protocol(protocol)
|
safe_proto = self.safe_protocol(protocol)
|
||||||
backup_dir = f'{self.BACKUP_ROOT}/{safe_proto}'
|
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"mkdir -p {shlex.quote(backup_dir)} && "
|
||||||
f"find {shlex.quote(backup_dir)} -maxdepth 1 -type f -name '*.tar.gz' "
|
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"
|
"-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)
|
out, err, code = self.ssh.run_sudo_command(cmd)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
return {'status': 'error', 'message': err or out or 'Failed to list backups'}
|
return {'status': 'error', 'message': err or out or 'Failed to list backups'}
|
||||||
@@ -162,9 +219,297 @@ chmod 0644 "$archive"
|
|||||||
printf '%s\n' "$archive"
|
printf '%s\n' "$archive"
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
out, err, code = self.ssh.run_sudo_command(script)
|
out, err, code = self.ssh.run_sudo_script(script, timeout=180)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
return {'status': 'error', 'message': err or out or 'Failed to create backup'}
|
return {'status': 'error', 'message': err or out or 'Failed to create backup'}
|
||||||
path = (out or '').strip().splitlines()[-1] if (out or '').strip() else ''
|
path = (out or '').strip().splitlines()[-1] if (out or '').strip() else ''
|
||||||
name = path.rsplit('/', 1)[-1] if path 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}}
|
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}
|
||||||
@@ -1223,6 +1223,76 @@ a:hover {
|
|||||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
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 Table ===== */
|
||||||
.clients-section {
|
.clients-section {
|
||||||
margin-top: var(--space-xl);
|
margin-top: var(--space-xl);
|
||||||
|
|||||||
+94
-8
@@ -867,10 +867,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="backupProto" value="" />
|
<input type="hidden" id="backupProto" value="" />
|
||||||
<div class="form-hint" style="margin-bottom:var(--space-md);">{{ _('backup_desc') }}</div>
|
<div class="form-hint" style="margin-bottom:var(--space-md);">{{ _('backup_desc') }}</div>
|
||||||
<div class="config-actions" style="margin-bottom:var(--space-md);">
|
<div class="backup-toolbar">
|
||||||
<button class="btn btn-primary btn-sm" onclick="createProtocolBackup()" id="createBackupBtn" style="flex:1">
|
<button class="btn btn-primary btn-sm" onclick="createProtocolBackup()" id="createBackupBtn">
|
||||||
{{ _('create_backup') }}
|
{{ _('create_backup') }}
|
||||||
</button>
|
</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>
|
<button class="btn btn-secondary btn-sm" onclick="loadProtocolBackups()">{{ _('refresh') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="backupLoading" class="form-hint hidden">{{ _('loading') }}</div>
|
<div id="backupLoading" class="form-hint hidden">{{ _('loading') }}</div>
|
||||||
@@ -879,7 +883,7 @@
|
|||||||
<div class="empty-title">{{ _('no_backups') }}</div>
|
<div class="empty-title">{{ _('no_backups') }}</div>
|
||||||
<div class="empty-desc">{{ _('no_backups_desc') }}</div>
|
<div class="empty-desc">{{ _('no_backups_desc') }}</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2150,6 +2154,17 @@
|
|||||||
await loadProtocolBackups();
|
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() {
|
async function loadProtocolBackups() {
|
||||||
const proto = document.getElementById('backupProto').value;
|
const proto = document.getElementById('backupProto').value;
|
||||||
const list = document.getElementById('backupList');
|
const list = document.getElementById('backupList');
|
||||||
@@ -2166,12 +2181,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = backups.map(b => `
|
list.innerHTML = backups.map(b => `
|
||||||
<div class="protocol-info-item" style="align-items:center; gap:var(--space-sm);">
|
<div class="backup-row">
|
||||||
<div style="min-width:0; flex:1;">
|
<div class="backup-row-meta">
|
||||||
<div class="protocol-info-value" style="word-break:break-all;">${b.name}</div>
|
<div class="backup-row-name" title="${escapeHtml(b.name)}">${escapeHtml(b.name)}</div>
|
||||||
<div class="protocol-info-label">${formatBytes(b.size)} • ${formatBackupTime(b.mtime)}</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>
|
</div>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="downloadProtocolBackup('${proto}', '${b.name}')">${_('download')}</button>
|
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
} catch (err) {
|
} 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) {
|
async function downloadProtocolBackup(proto, filename) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/servers/${SERVER_ID}/backups/download`, {
|
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) {
|
async function uninstallProtocol(proto) {
|
||||||
const name = getProtoTitle(proto);
|
const name = getProtoTitle(proto);
|
||||||
if (!confirm(_('uninstall_confirm').replace('{}', name))) return;
|
if (!confirm(_('uninstall_confirm').replace('{}', name))) return;
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -475,5 +475,13 @@
|
|||||||
"warnings_cleared": "Warnings cleared",
|
"warnings_cleared": "Warnings cleared",
|
||||||
"max_speed": "Max speed (Mbps)",
|
"max_speed": "Max speed (Mbps)",
|
||||||
"speed_limit_unlim": "Speed limit (0 = unlim.)",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -442,5 +442,13 @@
|
|||||||
"warnings_cleared": "هشدارها پاک شدند",
|
"warnings_cleared": "هشدارها پاک شدند",
|
||||||
"max_speed": "حداکثر سرعت (Mbps)",
|
"max_speed": "حداکثر سرعت (Mbps)",
|
||||||
"speed_limit_unlim": "محدودیت سرعت (0 = نامحدود)",
|
"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": "پشتیبان بارگذاری شد"
|
||||||
}
|
}
|
||||||
@@ -442,5 +442,13 @@
|
|||||||
"warnings_cleared": "Avertissements effacés",
|
"warnings_cleared": "Avertissements effacés",
|
||||||
"max_speed": "Vitesse max (Mbps)",
|
"max_speed": "Vitesse max (Mbps)",
|
||||||
"speed_limit_unlim": "Limite de vitesse (0 = illimité)",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -475,5 +475,13 @@
|
|||||||
"warnings_cleared": "Предупреждения очищены",
|
"warnings_cleared": "Предупреждения очищены",
|
||||||
"max_speed": "Макс. скорость (Мбит/с)",
|
"max_speed": "Макс. скорость (Мбит/с)",
|
||||||
"speed_limit_unlim": "Лимит скорости (0 = без лимита)",
|
"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": "Бекап загружен"
|
||||||
}
|
}
|
||||||
@@ -442,5 +442,13 @@
|
|||||||
"warnings_cleared": "警告已清除",
|
"warnings_cleared": "警告已清除",
|
||||||
"max_speed": "最大速度 (Mbps)",
|
"max_speed": "最大速度 (Mbps)",
|
||||||
"speed_limit_unlim": "速度限制(0 = 不限)",
|
"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": "备份已上传"
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user