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()
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"])
async def api_container_toggle(request: Request, server_id: int, req: ProtocolRequest):
"""Start or stop a protocol Docker container."""