Add WG/AWG client config ZIP export and backup restore.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user