From 8fa7f27223dca5817f9885d67dedf3ae79a1c479 Mon Sep 17 00:00:00 2001 From: orohi Date: Sun, 26 Jul 2026 06:29:50 +0300 Subject: [PATCH] Add double-VPN cascade from entry server to exit server. Co-authored-by: Cursor --- app.py | 189 ++++++++++++++++++++- managers/cascade_manager.py | 319 ++++++++++++++++++++++++++++++++++++ templates/server.html | 160 ++++++++++++++++++ translations/en.json | 21 ++- translations/ru.json | 21 ++- 5 files changed, 707 insertions(+), 3 deletions(-) create mode 100644 managers/cascade_manager.py diff --git a/app.py b/app.py index 502bb5e..9c763bf 100644 --- a/app.py +++ b/app.py @@ -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.""" diff --git a/managers/cascade_manager.py b/managers/cascade_manager.py new file mode 100644 index 0000000..f8c3257 --- /dev/null +++ b/managers/cascade_manager.py @@ -0,0 +1,319 @@ +"""Cascade (double-VPN) between two panel-managed WireGuard/AWG servers. + +Traffic path: + Clients → Entry server (accessible) → Exit server (blocked / target) → Internet + +Implemented inside the entry Docker container as a second AWG/WG interface +(`cascade.conf`) with source-based routing for the VPN client subnet. +""" + +from __future__ import annotations + +import re +import shlex +from datetime import datetime + + +WG_FAMILY = {'awg', 'awg2', 'awg_legacy', 'wireguard'} +CASCADE_MARKER = '# amnezia-web-panel-cascade' + + +class CascadeManager: + def __init__(self, ssh_manager): + self.ssh = ssh_manager + + @staticmethod + def proto_base(protocol: str) -> str: + return str(protocol or '').split('__', 1)[0] + + @staticmethod + def is_wg_family(protocol: str) -> bool: + return CascadeManager.proto_base(protocol) in WG_FAMILY + + @staticmethod + def _container_name(protocol: str) -> str: + base = CascadeManager.proto_base(protocol) + match = re.search(r'__(\d+)$', str(protocol or '')) + idx = int(match.group(1)) if match else 1 + names = { + 'awg': 'amnezia-awg', + 'awg2': 'amnezia-awg2', + 'awg_legacy': 'amnezia-awg-legacy', + 'wireguard': 'amnezia-wireguard', + } + name = names.get(base) + if not name: + return '' + return name if idx <= 1 else f'{name}-{idx}' + + @staticmethod + def _wg_binary(protocol: str) -> str: + return 'wg' if CascadeManager.proto_base(protocol) in ('awg_legacy', 'wireguard') else 'awg' + + @staticmethod + def _quick_binary(protocol: str) -> str: + return 'wg-quick' if CascadeManager.proto_base(protocol) in ('awg_legacy', 'wireguard') else 'awg-quick' + + @staticmethod + def _conf_dir(protocol: str) -> str: + return '/opt/amnezia/wireguard' if CascadeManager.proto_base(protocol) == 'wireguard' else '/opt/amnezia/awg' + + @classmethod + def _cascade_conf(cls, protocol: str) -> str: + return f'{cls._conf_dir(protocol)}/cascade.conf' + + @classmethod + def _cascade_up(cls, protocol: str) -> str: + return f'{cls._conf_dir(protocol)}/cascade-up.sh' + + @staticmethod + def _server_conf(protocol: str) -> str: + base = CascadeManager.proto_base(protocol) + if base == 'wireguard': + return '/opt/amnezia/wireguard/wg0.conf' + if base == 'awg_legacy': + return '/opt/amnezia/awg/wg0.conf' + return '/opt/amnezia/awg/awg0.conf' + + @staticmethod + def prepare_exit_client_config(raw_config: str, endpoint_host: str) -> str: + """Adapt a normal client config for use as an entry→exit cascade tunnel.""" + lines = [] + in_interface = False + has_table = False + for raw in (raw_config or '').splitlines(): + line = raw.rstrip('\n') + stripped = line.strip() + lower = stripped.lower() + if stripped.startswith('[') and stripped.endswith(']'): + in_interface = stripped.lower() == '[interface]' + lines.append(stripped) + continue + if in_interface and lower.startswith('dns'): + continue + if in_interface and lower.startswith('table'): + has_table = True + lines.append('Table = off') + continue + if lower.startswith('endpoint'): + port = '51820' + if '=' in stripped: + rhs = stripped.split('=', 1)[1].strip() + if ':' in rhs: + port = rhs.rsplit(':', 1)[-1] + host = (endpoint_host or '').strip() + if host: + lines.append(f'Endpoint = {host}:{port}') + else: + lines.append(stripped) + continue + lines.append(stripped) + + if not has_table: + out = [] + inserted = False + for line in lines: + out.append(line) + if not inserted and line.strip().lower() == '[interface]': + out.append('Table = off') + inserted = True + lines = out + return '\n'.join(lines).strip() + '\n' + + @staticmethod + def _extract_endpoint_host(config_text: str) -> str: + for line in (config_text or '').splitlines(): + if line.strip().lower().startswith('endpoint'): + rhs = line.split('=', 1)[-1].strip() + return rhs.rsplit(':', 1)[0].strip().strip('[]') + return '' + + def status(self, entry_protocol: str) -> dict: + container = self._container_name(entry_protocol) + if not container: + return {'enabled': False, 'up': False, 'error': 'Unsupported protocol'} + conf = self._cascade_conf(entry_protocol) + wg_bin = self._wg_binary(entry_protocol) + out, _, _ = self.ssh.run_sudo_command( + f"docker exec {shlex.quote(container)} bash -lc " + f"'test -f {shlex.quote(conf)} && echo HAS_CONF; " + f"ip -o link show cascade 2>/dev/null | head -1; " + f"{wg_bin} show interfaces 2>/dev/null'" + ) + text = out or '' + ifaces = [] + for line in text.splitlines(): + if line.strip() == 'HAS_CONF': + continue + ifaces.extend(line.split()) + up = 'cascade' in ifaces or bool(re.search(r'\bcascade\b', text)) + return { + 'enabled': 'HAS_CONF' in text, + 'up': up, + 'raw': text.strip()[:2000], + } + + def _vpn_subnet(self, entry_protocol: str, container: str) -> str: + conf = self._server_conf(entry_protocol) + out, _, _ = self.ssh.run_sudo_command( + f"docker exec {shlex.quote(container)} bash -lc " + f"\"grep -E '^Address' {shlex.quote(conf)} | head -1 | cut -d= -f2 | tr -d ' '\"" + ) + addr = (out or '').strip() + if not addr: + return '10.8.1.0/24' + if '/' in addr: + ip, cidr = addr.split('/', 1) + parts = ip.split('.') + if len(parts) == 4 and cidr.isdigit() and int(cidr) >= 24: + return f"{parts[0]}.{parts[1]}.{parts[2]}.0/{cidr}" + return addr + parts = addr.split('.') + if len(parts) == 4: + return f"{parts[0]}.{parts[1]}.{parts[2]}.0/24" + return '10.8.1.0/24' + + def apply(self, entry_protocol: str, exit_client_config: str, exit_host: str) -> dict: + container = self._container_name(entry_protocol) + if not container: + return {'status': 'error', 'message': 'Unsupported entry protocol'} + + exists, _, code = self.ssh.run_sudo_command( + f"docker inspect -f '{{{{.State.Running}}}}' {shlex.quote(container)} 2>/dev/null" + ) + if code != 0 or exists.strip().lower() != 'true': + return {'status': 'error', 'message': f'Entry container {container} is not running'} + + cascade_conf_path = self._cascade_conf(entry_protocol) + cascade_up_path = self._cascade_up(entry_protocol) + cascade_conf = self.prepare_exit_client_config(exit_client_config, exit_host) + endpoint_host = self._extract_endpoint_host(cascade_conf) or exit_host + subnet = self._vpn_subnet(entry_protocol, container) + wg_bin = self._wg_binary(entry_protocol) + quick_bin = self._quick_binary(entry_protocol) + conf_dir = self._conf_dir(entry_protocol) + + up_script = f"""#!/bin/bash +{CASCADE_MARKER} +set -e +CONF={shlex.quote(cascade_conf_path)} +QUICK={shlex.quote(quick_bin)} +SUBNET={shlex.quote(subnet)} +EXIT_HOST={shlex.quote(endpoint_host)} +IFACE=cascade + +"$QUICK" down "$CONF" 2>/dev/null || true +ip link delete "$IFACE" 2>/dev/null || true + +# Resolve hostname to IP if needed (pin route must use IP) +EXIT_IP="$EXIT_HOST" +if ! printf '%s' "$EXIT_IP" | grep -Eq '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$'; then + EXIT_IP=$(getent ahostsv4 "$EXIT_HOST" 2>/dev/null | awk '{{print $1; exit}}') +fi +GW=$(ip route | awk '/default/ {{print $3; exit}}') +DEV=$(ip route | awk '/default/ {{print $5; exit}}') +if [ -n "$EXIT_IP" ] && [ -n "$GW" ] && [ -n "$DEV" ]; then + ip route replace "$EXIT_IP/32" via "$GW" dev "$DEV" 2>/dev/null || true +fi + +"$QUICK" up "$CONF" + +ip route replace default dev "$IFACE" table 200 2>/dev/null || ip route add default dev "$IFACE" table 200 +ip rule del from "$SUBNET" table 200 2>/dev/null || true +ip rule add from "$SUBNET" table 200 priority 100 + +for SRC_IF in awg0 wg0; do + iptables -C FORWARD -i "$SRC_IF" -o "$IFACE" -j ACCEPT 2>/dev/null \\ + || iptables -I FORWARD 1 -i "$SRC_IF" -o "$IFACE" -j ACCEPT 2>/dev/null || true +done +iptables -C FORWARD -i "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null \\ + || iptables -I FORWARD 1 -i "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT +iptables -t nat -C POSTROUTING -s "$SUBNET" -o "$IFACE" -j MASQUERADE 2>/dev/null \\ + || iptables -t nat -A POSTROUTING -s "$SUBNET" -o "$IFACE" -j MASQUERADE + +echo CASCADE_UP_OK +""" + + self.ssh.run_sudo_command(f"docker exec {shlex.quote(container)} mkdir -p {shlex.quote(conf_dir)}") + self.ssh.upload_file(cascade_conf, '/tmp/_amnz_cascade.conf') + self.ssh.upload_file(up_script, '/tmp/_amnz_cascade_up.sh') + self.ssh.run_sudo_command( + f"docker cp /tmp/_amnz_cascade.conf {shlex.quote(container)}:{shlex.quote(cascade_conf_path)} && " + f"docker cp /tmp/_amnz_cascade_up.sh {shlex.quote(container)}:{shlex.quote(cascade_up_path)} && " + f"docker exec {shlex.quote(container)} chmod 644 {shlex.quote(cascade_conf_path)} && " + f"docker exec {shlex.quote(container)} chmod +x {shlex.quote(cascade_up_path)}" + ) + self.ssh.run_command('rm -f /tmp/_amnz_cascade.conf /tmp/_amnz_cascade_up.sh') + + self._ensure_start_hook(container, cascade_up_path) + + out, err, code = self.ssh.run_sudo_command( + f"docker exec {shlex.quote(container)} bash {shlex.quote(cascade_up_path)}", + timeout=90, + ) + if code != 0 or 'CASCADE_UP_OK' not in (out or ''): + return { + 'status': 'error', + 'message': (err or out or 'Failed to bring cascade interface up').strip()[:800], + } + + st = self.status(entry_protocol) + return { + 'status': 'success', + 'subnet': subnet, + 'endpoint': endpoint_host, + 'container': container, + 'up': bool(st.get('up')), + 'applied_at': datetime.now().isoformat(timespec='seconds'), + } + + def _ensure_start_hook(self, container: str, cascade_up_path: str) -> None: + marker = CASCADE_MARKER + installer = f"""#!/bin/bash +set -e +START=/opt/amnezia/start.sh +[ -f "$START" ] || exit 0 +grep -q '{marker}' "$START" 2>/dev/null && exit 0 +printf '\\n%s\\n' '{marker}' >> "$START" +printf '%s\\n' 'if [ -x {cascade_up_path} ]; then bash {cascade_up_path} || true; fi' >> "$START" +""" + self.ssh.upload_file(installer, '/tmp/_amnz_cascade_hook.sh') + self.ssh.run_sudo_command( + f"docker cp /tmp/_amnz_cascade_hook.sh {shlex.quote(container)}:/tmp/_hook.sh && " + f"docker exec {shlex.quote(container)} bash /tmp/_hook.sh && " + f"docker exec {shlex.quote(container)} rm -f /tmp/_hook.sh" + ) + self.ssh.run_command('rm -f /tmp/_amnz_cascade_hook.sh') + + def disable(self, entry_protocol: str) -> dict: + container = self._container_name(entry_protocol) + if not container: + return {'status': 'error', 'message': 'Unsupported entry protocol'} + conf = self._cascade_conf(entry_protocol) + up = self._cascade_up(entry_protocol) + quick_bin = self._quick_binary(entry_protocol) + # Avoid nested quotes issues: upload a small script + script = f"""#!/bin/bash +set +e +QUICK={shlex.quote(quick_bin)} +CONF={shlex.quote(conf)} +UP={shlex.quote(up)} +"$QUICK" down "$CONF" 2>/dev/null +ip link delete cascade 2>/dev/null +ip rule del table 200 2>/dev/null +ip rule del table 200 2>/dev/null +ip route flush table 200 2>/dev/null +rm -f "$CONF" "$UP" +echo CASCADE_DOWN_OK +""" + self.ssh.upload_file(script, '/tmp/_amnz_cascade_down.sh') + out, err, code = self.ssh.run_sudo_command( + f"docker cp /tmp/_amnz_cascade_down.sh {shlex.quote(container)}:/tmp/_cascade_down.sh && " + f"docker exec {shlex.quote(container)} bash /tmp/_cascade_down.sh && " + f"docker exec {shlex.quote(container)} rm -f /tmp/_cascade_down.sh", + timeout=60, + ) + self.ssh.run_command('rm -f /tmp/_amnz_cascade_down.sh') + if code != 0 and 'CASCADE_DOWN_OK' not in (out or ''): + return {'status': 'success', 'message': (err or out or 'Cascade cleared (best effort)').strip()[:400]} + return {'status': 'success'} diff --git a/templates/server.html b/templates/server.html index 8b55fe7..fe6043d 100644 --- a/templates/server.html +++ b/templates/server.html @@ -477,6 +477,67 @@ + +
+
+
+

{{ _('cascade_title') }}

+

{{ _('cascade_desc') }}

+
+ {{ _('cascade_off') }} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
{{ _('cascade_hint') }}
+
+ + + +
+ +
+
+