"""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. Inspired by ryderams/amneziawg-cascade (AWG2 entry→exit), but executed via the panel SSH session — no remote curl|bash script download. """ from __future__ import annotations import ipaddress import re import shlex from datetime import datetime from typing import Optional WG_FAMILY = {'awg', 'awg2', 'awg_legacy', 'wireguard'} CASCADE_MARKER = '# amnezia-web-panel-cascade' EXIT_ROUTE_BEGIN = '# BEGIN awg-cascade-vps2' EXIT_ROUTE_END = '# END awg-cascade-vps2' DEFAULT_SETTINGS = { 'pin_exit_route': True, 'remove_eth_masquerade': True, 'force_forward': True, 'mss_clamp': True, 'wait_handshake': True, 'handshake_timeout_sec': 25, 'allowed_ips': '0.0.0.0/0', 'keep_exit_dns': False, 'vpn_subnet_override': '', 'table_id': 200, 'rule_priority': 100, 'verify_egress': True, } def normalize_settings(raw: Optional[dict]) -> dict: settings = dict(DEFAULT_SETTINGS) if isinstance(raw, dict): for key, default in DEFAULT_SETTINGS.items(): if key not in raw or raw[key] is None: continue if isinstance(default, bool): settings[key] = bool(raw[key]) elif isinstance(default, int): try: settings[key] = int(raw[key]) except (TypeError, ValueError): settings[key] = default else: settings[key] = str(raw[key]).strip() settings['handshake_timeout_sec'] = max(5, min(120, int(settings['handshake_timeout_sec']))) settings['table_id'] = max(1, min(252, int(settings['table_id']))) settings['rule_priority'] = max(1, min(32765, int(settings['rule_priority']))) if not settings.get('allowed_ips'): settings['allowed_ips'] = DEFAULT_SETTINGS['allowed_ips'] return settings 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 _server_iface(protocol: str) -> str: base = CascadeManager.proto_base(protocol) if base in ('wireguard', 'awg_legacy'): return 'wg0' return 'awg0' @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 '' @staticmethod def _extract_address_cidr(config_text: str) -> str: for line in (config_text or '').splitlines(): stripped = line.strip() if stripped.lower().startswith('address'): rhs = stripped.split('=', 1)[-1].strip() return rhs.split(',')[0].strip() return '' @staticmethod def _extract_address_ip(config_text: str) -> str: cidr = CascadeManager._extract_address_cidr(config_text) if not cidr: return '' return cidr.split('/', 1)[0].strip() @staticmethod def prepare_exit_client_config( raw_config: str, endpoint_host: str, *, keep_dns: bool = False, allowed_ips: str = '0.0.0.0/0', ) -> str: """Adapt a normal client config for use as an entry→exit cascade tunnel.""" effective_allowed = (allowed_ips or '').strip() or '0.0.0.0/0' if effective_allowed in ('0.0.0.0/0, ::/0', '::/0, 0.0.0.0/0'): effective_allowed = '0.0.0.0/0' lines = [] in_interface = False in_peer = False has_table = False has_mtu = False for raw in (raw_config or '').splitlines(): stripped = raw.strip() lower = stripped.lower() if stripped.startswith('[') and stripped.endswith(']'): in_interface = stripped.lower() == '[interface]' in_peer = stripped.lower() == '[peer]' lines.append(stripped) continue if in_interface and lower.startswith('dns') and not keep_dns: continue if in_interface and lower.startswith('table'): has_table = True lines.append('Table = off') continue if in_interface and lower.startswith('mtu'): has_mtu = True lines.append(stripped) 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 if in_peer and lower.startswith('allowedips'): lines.append(f'AllowedIPs = {effective_allowed}') continue lines.append(stripped) out = [] inserted_iface = False for line in lines: out.append(line) if not inserted_iface and line.strip().lower() == '[interface]': if not has_table: out.append('Table = off') if not has_mtu: out.append('MTU = 1280') inserted_iface = True return '\n'.join(out).strip() + '\n' @staticmethod def _subnet_contains_ip(subnet: str, ip: str) -> bool: try: net = ipaddress.ip_network(subnet, strict=False) return ipaddress.ip_address(ip) in net except ValueError: return False def status(self, entry_protocol: str) -> dict: container = self._container_name(entry_protocol) if not container: return {'enabled': False, 'up': False, 'handshake': 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"'echo HAS=$(test -f {shlex.quote(conf)} && echo 1 || echo 0); " f"echo IFACES=$({wg_bin} show interfaces 2>/dev/null); " f"echo HANDSHAKE=$({wg_bin} show cascade latest-handshakes 2>/dev/null | awk \"{{print \\$2}}\" | head -1); " f"echo TRANSFER=$({wg_bin} show cascade transfer 2>/dev/null | head -1); " f"ip rule show 2>/dev/null | head -20; " f"ip route show table 200 2>/dev/null | head -10'" ) text = out or '' has_conf = 'HAS=1' in text ifaces_line = '' handshake_ts = 0 for line in text.splitlines(): if line.startswith('IFACES='): ifaces_line = line.split('=', 1)[1].strip() if line.startswith('HANDSHAKE='): try: handshake_ts = int(line.split('=', 1)[1].strip() or '0') except ValueError: handshake_ts = 0 up = 'cascade' in ifaces_line.split() handshake_ok = handshake_ts > 0 return { 'enabled': has_conf, 'up': up, 'handshake': handshake_ok, 'handshake_ts': handshake_ts, 'raw': text.strip()[:3000], } def _vpn_subnet(self, entry_protocol: str, container: str, override: str = '') -> str: if override and re.match(r'^\d+\.\d+\.\d+\.\d+/\d+$', override.strip()): return override.strip() 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 ensure_exit_return_route(self, protocol: str, peer_ip: str) -> dict: """Add /32 return route for the cascade peer on the exit container (ryderams vps2).""" peer_ip = (peer_ip or '').strip().split('/', 1)[0] if not re.match(r'^\d+\.\d+\.\d+\.\d+$', peer_ip): return {'status': 'error', 'message': 'Invalid cascade peer IP'} container = self._container_name(protocol) if not container: return {'status': 'error', 'message': 'Unsupported exit protocol'} iface = self._server_iface(protocol) route_cmd = f'ip route replace {peer_ip}/32 dev {iface}' script = f"""#!/bin/bash set -e START=/opt/amnezia/start.sh {route_cmd} 2>/dev/null || true [ -f "$START" ] || {{ echo CASCADE_EXIT_ROUTE_OK; exit 0; }} if grep -q '{EXIT_ROUTE_BEGIN}' "$START" 2>/dev/null; then tmp=$(mktemp) awk -v b='{EXIT_ROUTE_BEGIN}' -v e='{EXIT_ROUTE_END}' ' $0==b {{skip=1; next}} $0==e {{skip=0; next}} !skip {{print}} ' "$START" > "$tmp" && mv "$tmp" "$START" fi printf '\\n%s\\n%s\\n%s\\n' '{EXIT_ROUTE_BEGIN}' '{route_cmd} || true' '{EXIT_ROUTE_END}' >> "$START" echo CASCADE_EXIT_ROUTE_OK """ self.ssh.upload_file(script, '/tmp/_amnz_cascade_exit_route.sh') out, err, code = self.ssh.run_sudo_command( f"docker cp /tmp/_amnz_cascade_exit_route.sh {shlex.quote(container)}:/tmp/_exit_route.sh && " f"docker exec {shlex.quote(container)} bash /tmp/_exit_route.sh && " f"docker exec {shlex.quote(container)} rm -f /tmp/_exit_route.sh", timeout=60, ) self.ssh.run_command('rm -f /tmp/_amnz_cascade_exit_route.sh') if code != 0 or 'CASCADE_EXIT_ROUTE_OK' not in (out or ''): return { 'status': 'error', 'message': ((err or out) or 'Failed to set exit return route').strip()[:400], } return {'status': 'success', 'peer_ip': peer_ip, 'iface': iface} def apply( self, entry_protocol: str, exit_client_config: str, exit_host: str, settings: Optional[dict] = None, ) -> dict: opts = normalize_settings(settings) 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, keep_dns=bool(opts['keep_exit_dns']), allowed_ips=str(opts['allowed_ips']), ) endpoint_host = self._extract_endpoint_host(cascade_conf) or exit_host cascade_addr_cidr = self._extract_address_cidr(cascade_conf) cascade_peer_ip = self._extract_address_ip(cascade_conf) subnet = self._vpn_subnet(entry_protocol, container, opts.get('vpn_subnet_override') or '') if cascade_peer_ip and self._subnet_contains_ip(subnet, cascade_peer_ip): return { 'status': 'error', 'message': ( f'Cascade tunnel Address {cascade_addr_cidr or cascade_peer_ip} is inside ' f'entry client subnet {subnet}. Use a different VPN subnet on the exit server ' f'(e.g. 10.99.0.0/24) as in ryderams/amneziawg-cascade.' ), } wg_bin = self._wg_binary(entry_protocol) quick_bin = self._quick_binary(entry_protocol) conf_dir = self._conf_dir(entry_protocol) table_id = int(opts['table_id']) prio = int(opts['rule_priority']) hs_timeout = int(opts['handshake_timeout_sec']) pin_block = '' if opts['pin_exit_route']: pin_block = f""" 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}}') if [ -z "$EXIT_IP" ]; then EXIT_IP=$(python3 - <<'PY' 2>/dev/null || true import socket print(socket.gethostbyname("{endpoint_host}")) PY ) fi 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 """ remove_masq = '' if opts['remove_eth_masquerade']: remove_masq = f""" for ETH in eth0 eth1 eth2 ens3 ens5 enp0s3 enp1s0; do while iptables -t nat -D POSTROUTING -s "$SUBNET" -o "$ETH" -j MASQUERADE 2>/dev/null; do :; done done """ forward_block = '' if opts['force_forward']: forward_block = f""" sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true 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 iptables -C FORWARD -i "$IFACE" -o "$SRC_IF" -j ACCEPT 2>/dev/null \\ || iptables -I FORWARD 1 -i "$IFACE" -o "$SRC_IF" -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 """ mss_block = '' if opts['mss_clamp']: mss_block = """ iptables -t mangle -C FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu 2>/dev/null \\ || iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu """ handshake_block = '' if opts['wait_handshake']: handshake_block = f""" OK=0 for i in $(seq 1 {hs_timeout}); do HS=$({shlex.quote(wg_bin)} show "$IFACE" latest-handshakes 2>/dev/null | awk '{{print $2}}' | head -1) if [ -n "$HS" ] && [ "$HS" != "0" ]; then OK=1; break; fi sleep 1 done if [ "$OK" != "1" ]; then echo "CASCADE_NO_HANDSHAKE" >&2 echo "Tunnel interface is up but exit peer did not handshake. Check exit server / UDP port / keys." >&2 exit 42 fi """ 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 TABLE={table_id} PRIO={prio} "$QUICK" down "$CONF" 2>/dev/null || true ip link delete "$IFACE" 2>/dev/null || true ip rule del from "$SUBNET" table "$TABLE" 2>/dev/null || true ip route flush table "$TABLE" 2>/dev/null || true {pin_block} "$QUICK" up "$CONF" ip route replace default dev "$IFACE" table "$TABLE" 2>/dev/null || ip route add default dev "$IFACE" table "$TABLE" ip rule del from "$SUBNET" table "$TABLE" 2>/dev/null || true ip rule add from "$SUBNET" table "$TABLE" priority "$PRIO" {remove_masq} {forward_block} 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 {mss_block} {handshake_block} 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=max(90, hs_timeout + 40), ) combined = ((out or '') + '\n' + (err or '')).strip() if code != 0 or 'CASCADE_UP_OK' not in (out or ''): msg = combined if 'CASCADE_NO_HANDSHAKE' in combined: msg = ( 'Туннель к серверу выхода поднят, но handshake не прошёл. ' 'Проверьте, что на выходе открыт UDP-порт, протокол совпадает, ' 'и входной сервер может достучаться до IP выхода.' ) return { 'status': 'error', 'message': (msg or 'Failed to bring cascade interface up').strip()[:900], 'log': combined[:2000], } st = self.status(entry_protocol) handshake_ok = bool(st.get('handshake')) if opts.get('verify_egress', True) and handshake_ok and cascade_peer_ip: curl_cmd = ( f"curl --interface {shlex.quote(cascade_peer_ip)} -4 -s --max-time 12 " f"https://ifconfig.me || true" ) egress_out, egress_err, _egress_code = self.ssh.run_sudo_command( f"docker exec {shlex.quote(container)} bash -lc {shlex.quote(curl_cmd)}", timeout=30, ) egress_ip = (egress_out or '').strip().splitlines()[-1].strip() if (egress_out or '').strip() else '' if not egress_ip or not re.match(r'^\d+\.\d+\.\d+\.\d+$', egress_ip): try: self.disable(entry_protocol, settings=opts) except Exception: pass detail = ((egress_err or egress_out) or 'empty response').strip()[:400] return { 'status': 'error', 'message': ( 'Cascade handshake OK but egress verify failed ' f'(curl --interface {cascade_peer_ip} → ifconfig.me). ' f'Detail: {detail}' ), 'handshake': True, 'up': bool(st.get('up')), 'log': combined[:1500], } return { 'status': 'success', 'subnet': subnet, 'endpoint': endpoint_host, 'container': container, 'cascade_peer_ip': cascade_peer_ip, 'cascade_address': cascade_addr_cidr, 'up': bool(st.get('up')), 'handshake': handshake_ok, 'settings': opts, 'applied_at': datetime.now().isoformat(timespec='seconds'), 'diagnostics': st.get('raw', '')[:1500], } 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, settings: Optional[dict] = None) -> dict: opts = normalize_settings(settings) 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) table_id = int(opts['table_id']) script = f"""#!/bin/bash set +e QUICK={shlex.quote(quick_bin)} CONF={shlex.quote(conf)} UP={shlex.quote(up)} TABLE={table_id} "$QUICK" down "$CONF" 2>/dev/null ip link delete cascade 2>/dev/null ip rule del table "$TABLE" 2>/dev/null ip rule del table "$TABLE" 2>/dev/null ip route flush table "$TABLE" 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'}