Add WG/AWG client config ZIP export and backup restore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 05:54:32 +03:00
co-authored by Cursor
parent f2311c0795
commit 2b28892a9b
5 changed files with 306 additions and 5 deletions
+151
View File
@@ -2821,6 +2821,157 @@ async def api_protocol_backup_download(request: Request, server_id: int, req: Ba
ssh.disconnect() ssh.disconnect()
WG_BACKUP_BASES = {'awg', 'awg2', 'awg_legacy', 'wireguard'}
def _safe_conf_filename(name: str, used: set) -> str:
base = re.sub(r'[^\w.\-]+', '_', str(name or '').strip(), flags=re.UNICODE).strip('._') or 'client'
if len(base) > 80:
base = base[:80]
candidate = f'{base}.conf'
idx = 2
while candidate.lower() in used:
candidate = f'{base}_{idx}.conf'
idx += 1
used.add(candidate.lower())
return candidate
@app.post('/api/servers/{server_id}/backups/restore', tags=["Protocols"])
async def api_protocol_backup_restore(request: Request, server_id: int, req: BackupDownloadRequest):
"""Restore a protocol backup archive into the remote container/host paths."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if not is_valid_protocol(req.protocol):
return JSONResponse({'error': 'Unknown protocol'}, status_code=400)
ssh = None
try:
data = load_data()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
container = protocol_container_name(req.protocol)
if not container:
return JSONResponse({'error': 'Unknown protocol'}, status_code=400)
server = data['servers'][server_id]
ssh = get_ssh(server)
ssh.connect()
result = BackupManager(ssh).restore_backup(req.protocol, container, req.filename)
if result.get('status') == 'error':
return JSONResponse({'error': result.get('message', 'Failed to restore backup')}, status_code=500)
return result
except Exception as e:
logger.exception("Error restoring protocol backup")
return JSONResponse({'error': str(e)}, status_code=500)
finally:
if ssh:
ssh.disconnect()
@app.post('/api/servers/{server_id}/backups/export-clients', tags=["Protocols"])
async def api_protocol_export_clients(request: Request, server_id: int, req: ProtocolRequest):
"""Download a ZIP with all reconstructable WireGuard/AWG client .conf files."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if not is_valid_protocol(req.protocol):
return JSONResponse({'error': 'Unknown protocol'}, status_code=400)
base = protocol_base(req.protocol)
if base not in WG_BACKUP_BASES:
return JSONResponse(
{'error': 'Client config export is only available for WireGuard / AmneziaWG'},
status_code=400,
)
ssh = None
tmp_path = None
try:
data = load_data()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][server_id]
proto_info = server.get('protocols', {}).get(req.protocol, {})
port = proto_info.get('port', '55424')
ssh = get_ssh(server)
ssh.connect()
manager = get_protocol_manager(ssh, req.protocol)
clients = _manager_call(manager, 'get_clients', req.protocol) or []
fd, tmp_path = tempfile.mkstemp(prefix='amnezia-clients-', suffix='.zip')
os.close(fd)
used_names = set()
exported = 0
skipped = []
with zipfile.ZipFile(tmp_path, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
for client in clients:
ud = client.get('userData') or {}
client_id = client.get('clientId') or ''
client_name = ud.get('clientName') or client_id[:12] or 'client'
if ud.get('externalClient') or not ud.get('clientPrivateKey'):
skipped.append(f'{client_name}: no private key (external/native)')
continue
try:
config = _manager_call(
manager, 'get_client_config', req.protocol, client_id, server['host'], port
)
except Exception as exc:
skipped.append(f'{client_name}: {exc}')
continue
if not config:
skipped.append(f'{client_name}: empty config')
continue
filename = _safe_conf_filename(client_name, used_names)
zf.writestr(filename, config if config.endswith('\n') else config + '\n')
exported += 1
readme = [
f'Protocol: {req.protocol}',
f'Server: {server.get("name") or server.get("host")}',
f'Host: {server.get("host")}',
f'Exported: {exported}',
f'Skipped: {len(skipped)}',
'',
]
if skipped:
readme.append('Skipped clients:')
readme.extend(f'- {line}' for line in skipped)
zf.writestr('README.txt', '\n'.join(readme) + '\n')
ssh.disconnect()
ssh = None
if exported == 0:
try:
os.remove(tmp_path)
except Exception:
pass
tmp_path = None
return JSONResponse(
{'error': 'No client configs could be exported (missing private keys?)'},
status_code=404,
)
safe_proto = BackupManager.safe_protocol(req.protocol)
stamp = datetime.now().strftime('%Y%m%dT%H%M%S')
download_name = f'{safe_proto}-clients-{stamp}.zip'
return FileResponse(
tmp_path,
media_type='application/zip',
filename=download_name,
background=BackgroundTask(lambda p=tmp_path: os.path.exists(p) and os.remove(p)),
)
except Exception as e:
logger.exception("Error exporting client configs")
if tmp_path and os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except Exception:
pass
return JSONResponse({'error': str(e)}, status_code=500)
finally:
if ssh:
ssh.disconnect()
@app.post('/api/servers/{server_id}/container/toggle', tags=["Protocols"]) @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."""
+73
View File
@@ -168,3 +168,76 @@ printf '%s\n' "$archive"
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 ''
return {'status': 'success', 'protocol': protocol, 'backup': {'name': name, 'path': path}} return {'status': 'success', 'protocol': protocol, 'backup': {'name': name, 'path': path}}
def restore_backup(self, protocol, container_name, filename):
"""Restore protocol files from a previously created backup archive."""
safe_name = self.safe_filename(filename)
if not safe_name:
return {'status': 'error', 'message': 'Invalid backup filename'}
safe_proto = self.safe_protocol(protocol)
remote_archive = f'{self.BACKUP_ROOT}/{safe_proto}/{safe_name}'
paths = self._paths_for(protocol, container_name)
container_paths = ' '.join(shlex.quote(p) for p in paths['container'])
host_paths = ' '.join(shlex.quote(p) for p in paths['host'])
archive_q = shlex.quote(remote_archive)
container_q = shlex.quote(str(container_name or ''))
protocol_q = shlex.quote(str(protocol))
script = f"""
set -eu
umask 077
archive={archive_q}
container={container_q}
protocol={protocol_q}
if [ ! -f "$archive" ]; then
echo "Backup archive not found" >&2
exit 1
fi
work_dir=$(mktemp -d /tmp/amnezia-restore-XXXXXX)
cleanup() {{ rm -rf "$work_dir"; }}
trap cleanup EXIT
tar -xzf "$archive" -C "$work_dir"
if [ ! -f "$work_dir/backup-info.json" ]; then
echo "Invalid backup archive (missing backup-info.json)" >&2
exit 1
fi
restore_host_path() {{
src="$1"
staged="$work_dir/host$src"
if [ -e "$staged" ]; then
mkdir -p "$(dirname "$src")"
rm -rf "$src"
cp -a "$staged" "$src"
fi
}}
restore_container_path() {{
src="$1"
staged="$work_dir/container$src"
if [ -n "$container" ] && [ -e "$staged" ] && docker inspect "$container" >/dev/null 2>&1; then
if [ -d "$staged" ]; then
docker exec "$container" mkdir -p "$src" >/dev/null 2>&1 || true
docker cp "$staged/." "$container:$src/" >/dev/null 2>&1 || true
else
parent=$(dirname "$src")
docker exec "$container" mkdir -p "$parent" >/dev/null 2>&1 || true
docker cp "$staged" "$container:$src" >/dev/null 2>&1 || true
fi
fi
}}
for p in {host_paths}; do restore_host_path "$p"; done
for p in {container_paths}; do restore_container_path "$p"; done
if [ -n "$container" ] && docker inspect "$container" >/dev/null 2>&1; then
docker restart "$container" >/dev/null 2>&1 || true
# Prefer bringing the WireGuard/AWG interface back up after file restore
if docker exec "$container" test -x /opt/amnezia/start.sh >/dev/null 2>&1; then
docker exec "$container" /opt/amnezia/start.sh >/dev/null 2>&1 || true
fi
fi
printf 'restored:%s\\n' "$protocol"
""".strip()
out, err, code = self.ssh.run_sudo_command(script, timeout=180)
if code != 0:
return {'status': 'error', 'message': err or out or 'Failed to restore backup'}
return {'status': 'success', 'protocol': protocol, 'filename': safe_name}
+65 -2
View File
@@ -751,12 +751,16 @@
</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="config-actions" style="margin-bottom:var(--space-md); flex-wrap:wrap;">
<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" style="flex:1">
{{ _('create_backup') }} {{ _('create_backup') }}
</button> </button>
<button class="btn btn-secondary btn-sm hidden" onclick="exportProtocolClients()" id="exportClientsBtn" style="flex:1">
{{ _('export_client_configs') }}
</button>
<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 class="form-hint hidden" id="exportClientsHint" style="margin-bottom:var(--space-md);">{{ _('export_client_configs_hint') }}</div>
<div id="backupLoading" class="form-hint hidden">{{ _('loading') }}</div> <div id="backupLoading" class="form-hint hidden">{{ _('loading') }}</div>
<div id="backupEmpty" class="empty-state hidden" style="padding:var(--space-xl);"> <div id="backupEmpty" class="empty-state hidden" style="padding:var(--space-xl);">
<div class="empty-icon">{{ icon('package') }}</div> <div class="empty-icon">{{ icon('package') }}</div>
@@ -2014,6 +2018,10 @@
try { return new Date(mtime * 1000).toLocaleString(); } catch (_) { return '—'; } try { return new Date(mtime * 1000).toLocaleString(); } catch (_) { return '—'; }
} }
function isWgBackupFamily(proto) {
return ['awg', 'awg2', 'awg_legacy', 'wireguard'].includes(protoBase(proto));
}
function backupDownloadUrl(proto, filename) { function backupDownloadUrl(proto, filename) {
return `/api/servers/${SERVER_ID}/backups/download`; return `/api/servers/${SERVER_ID}/backups/download`;
} }
@@ -2021,6 +2029,9 @@
async function showProtocolBackups(proto) { async function showProtocolBackups(proto) {
document.getElementById('backupProto').value = proto; document.getElementById('backupProto').value = proto;
document.getElementById('backupModalTitle').textContent = `${_('backups')} — ${getProtoTitle(proto)}`; document.getElementById('backupModalTitle').textContent = `${_('backups')} — ${getProtoTitle(proto)}`;
const wg = isWgBackupFamily(proto);
document.getElementById('exportClientsBtn').classList.toggle('hidden', !wg);
document.getElementById('exportClientsHint').classList.toggle('hidden', !wg);
openModal('backupModal'); openModal('backupModal');
await loadProtocolBackups(); await loadProtocolBackups();
} }
@@ -2041,12 +2052,13 @@
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="protocol-info-item" style="align-items:center; gap:var(--space-sm); flex-wrap:wrap;">
<div style="min-width:0; flex:1;"> <div style="min-width:0; flex:1;">
<div class="protocol-info-value" style="word-break:break-all;">${b.name}</div> <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="protocol-info-label">${formatBytes(b.size)} • ${formatBackupTime(b.mtime)}</div>
</div> </div>
<button class="btn btn-secondary btn-sm" onclick="downloadProtocolBackup('${proto}', '${b.name}')">${_('download')}</button> <button class="btn btn-secondary btn-sm" onclick="downloadProtocolBackup('${proto}', '${b.name}')">${_('download')}</button>
<button class="btn btn-danger btn-sm" onclick="restoreProtocolBackup('${proto}', '${b.name}')">${_('restore_protocol_backup')}</button>
</div> </div>
`).join(''); `).join('');
} catch (err) { } catch (err) {
@@ -2074,6 +2086,57 @@
} }
} }
async function exportProtocolClients() {
const proto = document.getElementById('backupProto').value;
const btn = document.getElementById('exportClientsBtn');
const oldText = btn.textContent;
btn.disabled = true;
btn.textContent = _('exporting_client_configs');
try {
const res = await fetch(`/api/servers/${SERVER_ID}/backups/export-clients`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ protocol: proto })
});
if (!res.ok) {
let message = 'Export failed';
try {
const data = await res.json();
message = data.error || message;
} catch (_) {}
throw new Error(message);
}
const blob = await res.blob();
const cd = res.headers.get('content-disposition') || '';
const match = cd.match(/filename="?([^"]+)"?/i);
const filename = match ? match[1] : `${proto}-clients.zip`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showToast(_('export_client_configs_done'), 'success');
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = oldText;
}
}
async function restoreProtocolBackup(proto, filename) {
if (!confirm(_('restore_protocol_backup_confirm'))) return;
try {
await apiCall(`/api/servers/${SERVER_ID}/backups/restore`, 'POST', { protocol: proto, filename });
showToast(_('restore_protocol_backup_done'), 'success');
} catch (err) {
showToast(_('error') + ': ' + err.message, 'error');
}
}
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`, {
+8 -1
View File
@@ -505,11 +505,18 @@
"nginx_dns_hint": "Create this DNS record before installation:", "nginx_dns_hint": "Create this DNS record before installation:",
"backup": "Backup", "backup": "Backup",
"backups": "Backups", "backups": "Backups",
"backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.", "backup_desc": "Backups are stored on the server and include protocol files (config, keys, clientsTable). For WireGuard / AWG you can also download a ZIP of all client .conf files.",
"create_backup": "Create backup", "create_backup": "Create backup",
"creating_backup": "Creating backup...", "creating_backup": "Creating backup...",
"backup_created": "Backup created", "backup_created": "Backup created",
"no_backups": "No backups yet", "no_backups": "No backups yet",
"no_backups_desc": "Create the first backup for this protocol.", "no_backups_desc": "Create the first backup for this protocol.",
"export_client_configs": "Download all .conf",
"export_client_configs_hint": "ZIP with all WireGuard / AmneziaWG client configs (when the private key is stored).",
"exporting_client_configs": "Preparing ZIP…",
"export_client_configs_done": "Client configs ZIP downloaded",
"restore_protocol_backup": "Restore",
"restore_protocol_backup_confirm": "Restore this backup on the server? Current protocol files will be overwritten and the container will restart.",
"restore_protocol_backup_done": "Backup restored",
"refresh": "Refresh" "refresh": "Refresh"
} }
+9 -2
View File
@@ -503,13 +503,20 @@
"site_editor": "Редактор сайта", "site_editor": "Редактор сайта",
"site_saved": "Сайт сохранён", "site_saved": "Сайт сохранён",
"nginx_dns_hint": "Перед установкой создайте DNS-запись:", "nginx_dns_hint": "Перед установкой создайте DNS-запись:",
"backup": "Backup", "backup": "Бекап",
"backups": "Бекапы", "backups": "Бекапы",
"backup_desc": "Бекапы создаются на сервере и содержат ценные файлы протокола, нужные для восстановления или повторного запуска без потерь.", "backup_desc": "Бекапы создаются на сервере и содержат файлы протокола (конфиг, ключи, clientsTable). Для WireGuard / AWG можно также скачать ZIP со всеми клиентскими .conf.",
"create_backup": "Создать бекап", "create_backup": "Создать бекап",
"creating_backup": "Создание бекапа...", "creating_backup": "Создание бекапа...",
"backup_created": "Бекап создан", "backup_created": "Бекап создан",
"no_backups": "Бекапов пока нет", "no_backups": "Бекапов пока нет",
"no_backups_desc": "Создайте первый бекап для этого протокола.", "no_backups_desc": "Создайте первый бекап для этого протокола.",
"export_client_configs": "Скачать все .conf",
"export_client_configs_hint": "ZIP со всеми клиентскими конфигами WireGuard / AmneziaWG (если сохранён приватный ключ).",
"exporting_client_configs": "Готовлю ZIP…",
"export_client_configs_done": "ZIP с конфигами скачан",
"restore_protocol_backup": "Восстановить",
"restore_protocol_backup_confirm": "Восстановить этот бекап на сервер? Текущие файлы протокола будут перезаписаны, контейнер перезапустится.",
"restore_protocol_backup_done": "Бекап восстановлен",
"refresh": "Обновить" "refresh": "Обновить"
} }