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_desc') }}{{ _('cascade_title') }}
+