Add double-VPN cascade from entry server to exit server.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 06:29:50 +03:00
co-authored by Cursor
parent e3ddc042c0
commit 8fa7f27223
5 changed files with 707 additions and 3 deletions
+188 -1
View File
@@ -46,6 +46,7 @@ from managers.awg_manager import AWGManager
from managers.xray_manager import XrayManager
from managers.wireguard_manager import WireGuardManager
from managers.backup_manager import BackupManager
from managers.cascade_manager import CascadeManager
import telegram_bot as tg_bot
# Configure logging
@@ -1443,6 +1444,13 @@ class ProtocolRequest(BaseModel):
protocol: str = 'awg'
class CascadeSetupRequest(BaseModel):
entry_protocol: str = 'awg2'
exit_server_id: Optional[int] = None
exit_protocol: str = 'awg2'
enabled: bool = True
class ContainerLogsRequest(BaseModel):
protocol: str = 'awg'
tail: Optional[int] = 200
@@ -1948,7 +1956,7 @@ async def server_detail(request: Request, server_id: int):
return RedirectResponse(url='/')
server = data['servers'][server_id]
users_list = data.get('users', [])
return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list)
return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list, all_servers=data['servers'])
@app.get('/users', response_class=HTMLResponse, tags=["System Templates"])
@@ -2759,6 +2767,185 @@ CONTAINER_NAMES = {
@app.post('/api/servers/{server_id}/cascade', tags=["Protocols"])
async def api_cascade_get(request: Request, server_id: int):
"""Return saved cascade settings and live tunnel status for this (entry) server."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
try:
data = await load_data_async()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
server = data['servers'][server_id]
cascade = dict(server.get('cascade') or {})
live = {'enabled': False, 'up': False}
entry_proto = cascade.get('entry_protocol') or ''
if cascade.get('enabled') and entry_proto and CascadeManager.is_wg_family(entry_proto):
def _status():
ssh = get_ssh(server)
ssh.connect()
try:
return CascadeManager(ssh).status(entry_proto)
finally:
ssh.disconnect()
live = await asyncio.to_thread(_status)
return {
'cascade': cascade,
'live': live,
'servers': [
{
'id': i,
'name': s.get('name') or s.get('host'),
'host': s.get('host'),
'protocols': list((s.get('protocols') or {}).keys()),
}
for i, s in enumerate(data['servers']) if i != server_id
],
}
except Exception as e:
logger.exception("Error reading cascade status")
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/cascade/setup', tags=["Protocols"])
async def api_cascade_setup(request: Request, server_id: int, req: CascadeSetupRequest):
"""Enable or disable double-VPN cascade: clients → this entry → exit server."""
if not _check_admin(request):
return JSONResponse({'error': 'Forbidden'}, status_code=403)
if not CascadeManager.is_wg_family(req.entry_protocol):
return JSONResponse({'error': 'Cascade supports WireGuard / AmneziaWG only'}, status_code=400)
if req.enabled and not CascadeManager.is_wg_family(req.exit_protocol):
return JSONResponse({'error': 'Cascade supports WireGuard / AmneziaWG only'}, status_code=400)
try:
data = await load_data_async()
if server_id >= len(data['servers']):
return JSONResponse({'error': 'Server not found'}, status_code=404)
entry = data['servers'][server_id]
if not req.enabled:
def _disable():
ssh = get_ssh(entry)
ssh.connect()
try:
return CascadeManager(ssh).disable(req.entry_protocol)
finally:
ssh.disconnect()
result = await asyncio.to_thread(_disable)
if result.get('status') == 'error':
return JSONResponse({'error': result.get('message', 'Failed to disable cascade')}, status_code=500)
prev = dict(entry.get('cascade') or {})
entry['cascade'] = {
'enabled': False,
'entry_protocol': req.entry_protocol or prev.get('entry_protocol'),
'exit_server_id': prev.get('exit_server_id'),
'exit_protocol': prev.get('exit_protocol') or req.exit_protocol,
'updated_at': datetime.now().isoformat(timespec='seconds'),
}
await save_data_async(data)
return {'status': 'success', 'cascade': entry['cascade']}
if req.exit_server_id is None:
return JSONResponse({'error': 'exit_server_id is required'}, status_code=400)
if req.exit_server_id < 0 or req.exit_server_id >= len(data['servers']):
return JSONResponse({'error': 'Exit server not found'}, status_code=404)
if req.exit_server_id == server_id:
return JSONResponse({'error': 'Entry and exit servers must be different'}, status_code=400)
exit_srv = data['servers'][req.exit_server_id]
# Enable: create/reuse peer on exit, then apply tunnel on entry
def _enable():
exit_ssh = get_ssh(exit_srv)
exit_ssh.connect()
try:
exit_mgr = get_protocol_manager(exit_ssh, req.exit_protocol)
clients = _manager_call(exit_mgr, 'get_clients', req.exit_protocol) or []
cascade_name = f"cascade-from-{(entry.get('name') or entry.get('host') or 'entry')[:40]}"
cascade_name = re.sub(r'[^\w.\-]+', '_', cascade_name).strip('._') or 'cascade-entry'
existing = None
for c in clients:
ud = c.get('userData') or {}
if ud.get('clientName') == cascade_name or ud.get('cascade_entry_server_id') == server_id:
existing = c
break
if existing:
client_id = existing.get('clientId')
conf = _manager_call(
exit_mgr, 'get_client_config', req.exit_protocol,
client_id, exit_srv['host'],
(exit_srv.get('protocols') or {}).get(req.exit_protocol, {}).get('port', '55424'),
)
else:
add_res = _manager_call(
exit_mgr, 'add_client', req.exit_protocol, cascade_name,
exit_srv['host'],
(exit_srv.get('protocols') or {}).get(req.exit_protocol, {}).get('port', '55424'),
)
if not add_res.get('client_id'):
raise RuntimeError(add_res.get('message') or 'Failed to create cascade peer on exit')
client_id = add_res['client_id']
conf = add_res.get('config')
if not conf:
conf = _manager_call(
exit_mgr, 'get_client_config', req.exit_protocol,
client_id, exit_srv['host'],
(exit_srv.get('protocols') or {}).get(req.exit_protocol, {}).get('port', '55424'),
)
# Mark peer in clientsTable metadata if possible
try:
table = exit_mgr._get_clients_table(req.exit_protocol) if hasattr(exit_mgr, '_get_clients_table') else []
for row in table:
if row.get('clientId') == client_id:
row.setdefault('userData', {})['cascade_entry_server_id'] = server_id
if hasattr(exit_mgr, '_save_clients_table'):
exit_mgr._save_clients_table(req.exit_protocol, table)
break
except Exception:
pass
finally:
exit_ssh.disconnect()
entry_ssh = get_ssh(entry)
entry_ssh.connect()
try:
applied = CascadeManager(entry_ssh).apply(
req.entry_protocol,
conf,
exit_srv.get('host') or '',
)
finally:
entry_ssh.disconnect()
if applied.get('status') != 'success':
raise RuntimeError(applied.get('message') or 'Failed to apply cascade on entry')
return {
'client_id': client_id,
'client_name': cascade_name,
'applied': applied,
}
info = await asyncio.to_thread(_enable)
entry['cascade'] = {
'enabled': True,
'entry_protocol': req.entry_protocol,
'exit_server_id': req.exit_server_id,
'exit_protocol': req.exit_protocol,
'exit_client_id': info['client_id'],
'exit_client_name': info['client_name'],
'up': bool((info.get('applied') or {}).get('up')),
'updated_at': datetime.now().isoformat(timespec='seconds'),
'last_error': None,
}
await save_data_async(data)
return {'status': 'success', 'cascade': entry['cascade'], 'applied': info.get('applied')}
except Exception as e:
logger.exception("Error setting up cascade")
return JSONResponse({'error': str(e)}, status_code=500)
@app.post('/api/servers/{server_id}/backups', tags=["Protocols"])
async def api_protocol_backups_list(request: Request, server_id: int, req: ProtocolRequest):
"""List backups created on the remote server for one protocol."""