From 6206697e3b36f3e929577aed64cd6fe233e8a78d Mon Sep 17 00:00:00 2001 From: orohi Date: Mon, 27 Jul 2026 05:08:18 +0300 Subject: [PATCH] Remove Cascade double-VPN (v2.3.0). Drop cascade manager, API, server UI, and i18n keys after unstable egress/routing behavior. --- README.md | 16 +- app.py | 261 +------------ managers/cascade_manager.py | 733 ------------------------------------ templates/server.html | 324 ---------------- translations/en.json | 47 +-- translations/fa.json | 47 +-- translations/fr.json | 47 +-- translations/ru.json | 47 +-- translations/zh.json | 47 +-- 9 files changed, 13 insertions(+), 1556 deletions(-) delete mode 100644 managers/cascade_manager.py diff --git a/README.md b/README.md index eaa396c..10d3241 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,6 @@ Configuration panel for system parameters and preferences: * **Reboot** the server directly from the UI. * Strictly concurrent protocol status polling — all supported protocols/services checked in parallel for immediate feedback. * **Asynchronous Processing**: Resilient, non-blocking background architecture prevents the UI panel from freezing, even if remote endpoints hang. -* **🔁 Cascade (double VPN)**: - * Route clients through an **entry** AmneziaWG/WireGuard server to an **exit** server (country-block bypass). - * Approach inspired by [ryderams/amneziawg-cascade](https://github.com/ryderams/amneziawg-cascade) (AWG2 entry→exit); applied via panel SSH — **no remote curl|bash** script download. - * Handshake wait + optional egress verify (curl --interface → ifconfig.me); exit return route persisted like ryderams vps2. - * Users keep using the **ENTRY** client config only — do not share the exit cascade peer. * **🧩 Marketplace & Templates**: * Market templates provide quick presets for installing and configuring supported protocols and services (including **Hysteria 2**). * Multi-protocol management lets you run and control multiple protocol instances on the same server. @@ -227,15 +222,16 @@ GitHub Actions workflows in `.github/workflows/`: ## 📋 Fix / changelog (this fork) +### v2.3.0 +* **Cascade removed** — double-VPN feature dropped from the panel (manager, API, UI, i18n). + ### v2.2.1 * **Cascade egress verify fix**: bind curl to ENTRY `awg0`/`wg0` IP (client-subnet policy route), not cascade peer Address. * Soft-warn (no rollback) when policy route via `cascade` is OK but public-IP echo services fail. ### v2.2.0 -* **Cascade (double VPN) restored** — stable entry→exit tunnel for AmneziaWG / WireGuard. -* Inspired by [ryderams/amneziawg-cascade](https://github.com/ryderams/amneziawg-cascade); logic runs over panel SSH (**no remote curl|bash**). -* Safety: require exit **handshake** when enabled; optional **egress verify**; subnet conflict check; exit `/32` return route with start.sh markers. -* UI on the server page with per-step settings (pin exit route, MSS clamp, verify egress, …). Defaults prefer **awg2**. +* **Cascade (double VPN) restored** — later removed in **v2.3.0**. +* Inspired by [ryderams/amneziawg-cascade](https://github.com/ryderams/amneziawg-cascade); logic ran over panel SSH (**no remote curl|bash**). ### v2.1.0 * **NaiveProxy — stable**: production-ready install (Caddy + klzgrad/forwardproxy), share links always include `:443`. @@ -268,7 +264,7 @@ GitHub Actions workflows in `.github/workflows/`: ### v1.6.0 * **3x-ui multi-panel**: register several 3x-ui servers in Settings; pick a panel and load VLESS inbounds over its API when creating users/invites (share link comes from 3x-ui). * **Docker / CI**: refreshed `Dockerfile` + compose, `.env.example`, CI checks, GHCR image workflow. -* **Cascade** was temporarily removed in this release (redesigned and restored in **v2.2.0**). +* **Cascade** was temporarily removed in this release (briefly restored in v2.2.x, removed again in **v2.3.0**). * **WG/AWG backups**: ZIP export of client `.conf` + restore with loading feedback. * **API performance**: in-memory data cache, faster server check/stats. * **Share / guest**: one-tap “Copy key”. diff --git a/app.py b/app.py index 7632031..4ad4c0d 100644 --- a/app.py +++ b/app.py @@ -46,7 +46,6 @@ 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, normalize_settings, DEFAULT_SETTINGS as CASCADE_DEFAULT_SETTINGS import telegram_bot as tg_bot # Configure logging @@ -102,7 +101,7 @@ else: application_path = os.path.dirname(__file__) DATA_FILE = os.path.join(application_path, 'data.json') # legacy JSON; used only for one-shot import / export -CURRENT_VERSION = "v2.2.1" +CURRENT_VERSION = "v2.3.0" RELEASES_REPO_URL = "https://git.evilfox.cc/test2/Amnezia-Web-Panel-main" RELEASES_API_LATEST = "https://git.evilfox.cc/api/v1/repos/test2/Amnezia-Web-Panel-main/releases/latest" BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) @@ -1743,25 +1742,6 @@ class HysteriaSettingsRequest(BaseModel): email: Optional[str] = None renew_ssl: bool = False -class CascadeSetupRequest(BaseModel): - entry_protocol: str = 'awg2' - exit_server_id: Optional[int] = None - exit_protocol: str = 'awg2' - enabled: bool = True - # Per-step cascade tuning - pin_exit_route: Optional[bool] = True - remove_eth_masquerade: Optional[bool] = True - force_forward: Optional[bool] = True - mss_clamp: Optional[bool] = True - wait_handshake: Optional[bool] = True - handshake_timeout_sec: Optional[int] = 25 - allowed_ips: Optional[str] = '0.0.0.0/0' - keep_exit_dns: Optional[bool] = False - vpn_subnet_override: Optional[str] = '' - table_id: Optional[int] = 200 - rule_priority: Optional[int] = 100 - verify_egress: Optional[bool] = True - class ProtocolRequest(BaseModel): protocol: str = 'awg' @@ -2319,7 +2299,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, all_servers=data['servers']) + return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list) @app.get('/users', response_class=HTMLResponse, tags=["System Templates"]) @@ -3250,243 +3230,6 @@ async def api_hysteria_update_settings(request: Request, server_id: int, req: Hy return JSONResponse({'error': str(e)}, status_code=500) -@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 asyncio.to_thread(load_data) - 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, - 'defaults': CASCADE_DEFAULT_SETTINGS, - '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 asyncio.to_thread(load_data) - if server_id >= len(data['servers']): - return JSONResponse({'error': 'Server not found'}, status_code=404) - entry = data['servers'][server_id] - settings = normalize_settings({ - 'pin_exit_route': req.pin_exit_route, - 'remove_eth_masquerade': req.remove_eth_masquerade, - 'force_forward': req.force_forward, - 'mss_clamp': req.mss_clamp, - 'wait_handshake': req.wait_handshake, - 'handshake_timeout_sec': req.handshake_timeout_sec, - 'allowed_ips': req.allowed_ips, - 'keep_exit_dns': req.keep_exit_dns, - 'vpn_subnet_override': req.vpn_subnet_override, - 'table_id': req.table_id, - 'rule_priority': req.rule_priority, - 'verify_egress': req.verify_egress, - }) - if not req.enabled: - def _disable(): - ssh = get_ssh(entry) - ssh.connect() - try: - return CascadeManager(ssh).disable( - req.entry_protocol, - settings=dict((entry.get('cascade') or {}).get('settings') or settings), - ) - 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, - 'settings': settings, - 'updated_at': datetime.now().isoformat(timespec='seconds'), - } - async with DATA_LOCK: - await asyncio.to_thread(save_data, 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] - - 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'), - ) - 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 '', - settings=settings, - ) - finally: - entry_ssh.disconnect() - - if applied.get('status') != 'success': - raise RuntimeError(applied.get('message') or 'Failed to apply cascade on entry') - - if settings.get('wait_handshake') and not applied.get('handshake'): - try: - entry_ssh2 = get_ssh(entry) - entry_ssh2.connect() - try: - CascadeManager(entry_ssh2).disable(req.entry_protocol, settings=settings) - finally: - entry_ssh2.disconnect() - except Exception: - pass - raise RuntimeError( - applied.get('message') - or 'Cascade apply reported success but exit handshake is missing' - ) - - peer_ip = applied.get('cascade_peer_ip') or '' - if not peer_ip and conf: - peer_ip = CascadeManager._extract_address_ip(conf) - if peer_ip and hasattr(CascadeManager, 'ensure_exit_return_route'): - exit_ssh2 = get_ssh(exit_srv) - exit_ssh2.connect() - try: - route_res = CascadeManager(exit_ssh2).ensure_exit_return_route( - req.exit_protocol, peer_ip - ) - applied['exit_return_route'] = route_res - finally: - exit_ssh2.disconnect() - - return { - 'client_id': client_id, - 'client_name': cascade_name, - 'applied': applied, - } - - info = await asyncio.to_thread(_enable) - applied = info.get('applied') or {} - 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'], - 'settings': settings, - 'up': bool(applied.get('up')), - 'handshake': bool(applied.get('handshake')), - 'updated_at': datetime.now().isoformat(timespec='seconds'), - 'last_error': None, - 'diagnostics': applied.get('diagnostics') or '', - } - async with DATA_LOCK: - await asyncio.to_thread(save_data, data) - return { - 'status': 'success', - 'cascade': entry['cascade'], - 'applied': applied, - 'warning': applied.get('warning'), - } - except Exception as e: - logger.exception("Error setting up cascade") - return JSONResponse({'error': str(e)}, status_code=500) - - @app.post('/api/servers/{server_id}/uninstall', tags=["Protocols"]) async def api_uninstall_protocol(request: Request, server_id: int, req: ProtocolRequest): diff --git a/managers/cascade_manager.py b/managers/cascade_manager.py deleted file mode 100644 index c339c22..0000000 --- a/managers/cascade_manager.py +++ /dev/null @@ -1,733 +0,0 @@ -"""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_server_iface(self, entry_protocol: str) -> str: - base = self.proto_base(entry_protocol) - if base in ('awg_legacy', 'wireguard'): - return 'wg0' - return 'awg0' - - def _vpn_server_ip(self, entry_protocol: str, container: str) -> str: - """IPv4 on awg0/wg0 — traffic from this IP is policy-routed through cascade.""" - iface = self._vpn_server_iface(entry_protocol) - out, _, _ = self.ssh.run_sudo_command( - f"docker exec {shlex.quote(container)} sh -c " - f"{shlex.quote(f'ip -o -4 addr show dev {iface} 2>/dev/null')}", - timeout=15, - ) - for line in (out or '').splitlines(): - m = re.search(r'\binet\s+(\d+\.\d+\.\d+\.\d+)/\d+', line) - if m: - return m.group(1) - return '' - - @staticmethod - def _parse_public_ipv4(text: str) -> str: - raw = (text or '').strip() - if not raw: - return '' - for line in reversed(raw.splitlines()): - line = line.strip() - if re.fullmatch(r'\d+\.\d+\.\d+\.\d+', line): - return line - m = re.search(r'\b(\d{1,3}(?:\.\d{1,3}){3})\b', raw) - return m.group(1) if m else '' - - def _verify_egress(self, container: str, entry_protocol: str, subnet: str) -> dict: - """Confirm client-subnet traffic exits via cascade (ryderams-style). - - Bind curl to the VPN server IP on awg0/wg0 (NOT the cascade peer Address). - Policy routing only applies to the client subnet — cascade Address must - not be used as --interface. - """ - server_ip = self._vpn_server_ip(entry_protocol, container) - iface = self._vpn_server_iface(entry_protocol) - if not server_ip: - return { - 'ok': False, - 'skipped': False, - 'detail': f'Could not read IPv4 on {iface} for egress verify', - 'bind': '', - } - - route_out, _, _ = self.ssh.run_sudo_command( - f"docker exec {shlex.quote(container)} sh -c " - f"{shlex.quote(f'ip route get 1.1.1.1 from {server_ip} 2>/dev/null | head -1')}", - timeout=15, - ) - route_line = (route_out or '').strip() - route_via_cascade = bool(re.search(r'\bdev\s+cascade\b', route_line)) - - if not route_via_cascade: - return { - 'ok': False, - 'skipped': False, - 'detail': ( - f'Policy route from {server_ip} does not use cascade iface. ' - f'Got: {route_line or "empty"}' - ), - 'bind': server_ip, - 'route': route_line, - 'subnet': subnet, - } - - has_curl, _, _ = self.ssh.run_sudo_command( - f"docker exec {shlex.quote(container)} sh -c " - f"{shlex.quote('command -v curl >/dev/null 2>&1 && echo YES')}", - timeout=15, - ) - if 'YES' not in (has_curl or ''): - return { - 'ok': True, - 'skipped': True, - 'warning': True, - 'detail': 'curl not installed in container — egress IP check skipped (policy route OK)', - 'bind': server_ip, - 'route': route_line, - } - - urls = ( - 'https://api.ipify.org', - 'https://icanhazip.com', - 'https://ifconfig.me/ip', - 'http://api.ipify.org', - 'http://icanhazip.com', - ) - binds = [server_ip, 'cascade'] - last_detail = 'empty response' - for bind in binds: - for url in urls: - inner = ( - f'curl --interface {shlex.quote(bind)} -4 -sS --max-time 12 ' - f'{shlex.quote(url)} 2>&1 || true' - ) - egress_out, _, _ = self.ssh.run_sudo_command( - f"docker exec {shlex.quote(container)} sh -c {shlex.quote(inner)}", - timeout=25, - ) - egress_ip = self._parse_public_ipv4(egress_out or '') - if egress_ip: - return { - 'ok': True, - 'skipped': False, - 'egress_ip': egress_ip, - 'bind': bind, - 'url': url, - 'route': route_line, - 'detail': f'{egress_ip} via {bind}', - } - last_detail = ((egress_out or '') or 'empty response').strip()[:300] - - # Handshake + policy route OK; echo services often fail from VPS — keep cascade up. - return { - 'ok': True, - 'skipped': False, - 'warning': True, - 'detail': ( - f'Policy route OK via cascade, but public-IP curl failed ' - f'(bind {server_ip}). Last: {last_detail or "empty response"}' - ), - 'bind': server_ip, - 'route': route_line, - 'subnet': subnet, - } - - 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')) - - egress_info = {} - if opts.get('verify_egress', True) and handshake_ok: - egress_info = self._verify_egress(container, entry_protocol, subnet) - if not egress_info.get('ok'): - try: - self.disable(entry_protocol, settings=opts) - except Exception: - pass - bind = egress_info.get('bind') or 'awg0' - detail = egress_info.get('detail') or 'empty response' - route = egress_info.get('route') or '' - return { - 'status': 'error', - 'message': ( - 'Cascade handshake OK but egress verify failed ' - f'(curl --interface {bind} via client subnet {subnet}). ' - f'Detail: {detail}' - + (f' Route: {route}' if route else '') - ), - 'handshake': True, - 'up': bool(st.get('up')), - 'egress': egress_info, - '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, - 'egress': egress_info or None, - 'warning': (egress_info or {}).get('detail') if (egress_info or {}).get('warning') else None, - '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'} diff --git a/templates/server.html b/templates/server.html index a9f3ca5..e931444 100644 --- a/templates/server.html +++ b/templates/server.html @@ -157,153 +157,6 @@ - -
-
-
-

{{ _('cascade_title') }}

-

{{ _('cascade_desc') }}

-
- {{ _('cascade_off') }} -
-
-
-
- - -
-
- - -
-
- - -
-
-
{{ _('cascade_hint') }}
-
{{ _('cascade_client_note') }}
-
{{ _('cascade_security_note') }}
- -
- {{ _('cascade_settings_title') }} -

{{ _('cascade_settings_desc') }}

-
- - - - - - - -
-
-
- - -
-
- - -
-
- - -
-
-
-
- - - {{ _('cascade_opt_allowed_ips_hint') }} -
-
- - - {{ _('cascade_opt_vpn_subnet_hint') }} -
-
-
- - - -
- - - -
- -
-
@@ -1576,7 +1429,6 @@ } updateStatusCounts(data.protocols || {}); showToast(_('server_check_complete'), 'success'); - loadCascade(); } catch (err) { showToast(_('error') + ': ' + err.message, 'error'); const statusDiv = document.getElementById('serverStatus'); @@ -3114,184 +2966,8 @@ - // ========== Cascade (double VPN) ========== - const CASCADE_PROTOS = ['awg2', 'awg', 'awg_legacy', 'wireguard']; - - function fillCascadeExitProtocols() { - const sel = document.getElementById('cascadeExitServer'); - const opt = sel.options[sel.selectedIndex]; - const protoSel = document.getElementById('cascadeExitProtocol'); - const raw = (opt && opt.dataset.protocols) ? opt.dataset.protocols.split(',').filter(Boolean) : []; - const available = raw.filter(p => CASCADE_PROTOS.includes(protoBase(p))); - const prev = protoSel.value; - protoSel.innerHTML = ''; - const list = available.length ? available : CASCADE_PROTOS; - list.forEach(p => { - const o = document.createElement('option'); - o.value = p; - o.textContent = getProtoTitle(p); - protoSel.appendChild(o); - }); - if (list.includes(prev)) protoSel.value = prev; - else if (list.includes('awg2')) protoSel.value = 'awg2'; - } - - function setCascadeBusy(on, text) { - const busy = document.getElementById('cascadeBusy'); - const busyText = document.getElementById('cascadeBusyText'); - const en = document.getElementById('cascadeEnableBtn'); - const dis = document.getElementById('cascadeDisableBtn'); - if (busyText && text) busyText.textContent = text; - if (busy) { - busy.classList.toggle('hidden', !on); - busy.style.display = on ? 'flex' : 'none'; - } - if (en) en.disabled = !!on; - if (dis) dis.disabled = !!on; - } - - function cascadeBool(id, fallback) { - const el = document.getElementById(id); - return el ? !!el.checked : !!fallback; - } - - function cascadeNum(id, fallback) { - const el = document.getElementById(id); - const n = el ? parseInt(el.value, 10) : NaN; - return Number.isFinite(n) ? n : fallback; - } - - function collectCascadeSettings() { - return { - pin_exit_route: cascadeBool('cascadePinExitRoute', true), - remove_eth_masquerade: cascadeBool('cascadeRemoveEthMasq', true), - force_forward: cascadeBool('cascadeForceForward', true), - mss_clamp: cascadeBool('cascadeMssClamp', true), - wait_handshake: cascadeBool('cascadeWaitHandshake', true), - verify_egress: cascadeBool('cascadeVerifyEgress', true), - keep_exit_dns: cascadeBool('cascadeKeepExitDns', false), - handshake_timeout_sec: cascadeNum('cascadeHandshakeTimeout', 25), - table_id: cascadeNum('cascadeTableId', 200), - rule_priority: cascadeNum('cascadeRulePriority', 100), - allowed_ips: (document.getElementById('cascadeAllowedIps') || {}).value || '0.0.0.0/0', - vpn_subnet_override: (document.getElementById('cascadeVpnSubnet') || {}).value || '', - }; - } - - function applyCascadeSettingsToForm(settings, defaults) { - const s = Object.assign({}, defaults || {}, settings || {}); - const setCheck = (id, val) => { const el = document.getElementById(id); if (el) el.checked = !!val; }; - const setVal = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined && val !== null) el.value = val; }; - setCheck('cascadePinExitRoute', s.pin_exit_route !== false); - setCheck('cascadeRemoveEthMasq', s.remove_eth_masquerade !== false); - setCheck('cascadeForceForward', s.force_forward !== false); - setCheck('cascadeMssClamp', s.mss_clamp !== false); - setCheck('cascadeWaitHandshake', s.wait_handshake !== false); - setCheck('cascadeVerifyEgress', s.verify_egress !== false); - setCheck('cascadeKeepExitDns', !!s.keep_exit_dns); - setVal('cascadeHandshakeTimeout', s.handshake_timeout_sec ?? 25); - setVal('cascadeTableId', s.table_id ?? 200); - setVal('cascadeRulePriority', s.rule_priority ?? 100); - setVal('cascadeAllowedIps', s.allowed_ips || '0.0.0.0/0'); - setVal('cascadeVpnSubnet', s.vpn_subnet_override || ''); - } - - function updateCascadeDiag(cascade, live) { - const box = document.getElementById('cascadeDiagBox'); - if (!box) return; - const parts = []; - if (cascade && cascade.enabled) { - parts.push('handshake: ' + ((live && live.handshake) || cascade.handshake ? 'ok' : 'NONE')); - if (cascade.diagnostics) parts.push(String(cascade.diagnostics).trim()); - else if (live && live.raw) parts.push(String(live.raw).trim()); - if (cascade.last_error) parts.push('error: ' + cascade.last_error); - } - const text = parts.filter(Boolean).join('\n\n'); - box.textContent = text; - box.classList.toggle('hidden', !text); - } - - function updateCascadeBadge(cascade, live) { - const badge = document.getElementById('cascadeStatusBadge'); - if (!badge) return; - const enabled = !!(cascade && cascade.enabled); - const up = !!(live && live.up); - const hs = !!(live && live.handshake) || !!(cascade && cascade.handshake); - badge.className = 'badge ' + (enabled && up && hs ? 'badge-success' : (enabled ? 'badge-warn' : 'badge-warn')); - if (!enabled) badge.textContent = _('cascade_off'); - else if (up && hs) badge.textContent = _('cascade_on'); - else if (up && !hs) badge.textContent = _('cascade_no_handshake'); - else badge.textContent = _('cascade_configured_down'); - updateCascadeDiag(cascade, live); - } - - async function loadCascade() { - try { - const res = await apiCall(`/api/servers/${SERVER_ID}/cascade`, 'POST', {}); - const c = res.cascade || {}; - if (c.entry_protocol) document.getElementById('cascadeEntryProtocol').value = c.entry_protocol; - if (c.exit_server_id !== undefined && c.exit_server_id !== null && c.exit_server_id !== '') { - document.getElementById('cascadeExitServer').value = String(c.exit_server_id); - fillCascadeExitProtocols(); - } - if (c.exit_protocol) { - const exitProto = document.getElementById('cascadeExitProtocol'); - if ([...exitProto.options].some(o => o.value === c.exit_protocol)) { - exitProto.value = c.exit_protocol; - } - } - applyCascadeSettingsToForm(c.settings || {}, res.defaults || {}); - updateCascadeBadge(c, res.live || {}); - } catch (err) { - console.error(err); - } - } - - async function setupCascade(enable) { - const entryProtocol = document.getElementById('cascadeEntryProtocol').value; - const exitServerId = document.getElementById('cascadeExitServer').value; - const exitProtocol = document.getElementById('cascadeExitProtocol').value; - if (enable && exitServerId === '') { - showToast(_('cascade_exit_required'), 'error'); - return; - } - if (enable && !confirm(_('cascade_enable_confirm'))) return; - if (!enable && !confirm(_('cascade_disable_confirm'))) return; - - setCascadeBusy(true, enable ? _('cascade_working') : _('cascade_disabling')); - showToast(enable ? _('cascade_working') : _('cascade_disabling'), 'info'); - try { - const settings = collectCascadeSettings(); - const res = await apiCall(`/api/servers/${SERVER_ID}/cascade/setup`, 'POST', { - enabled: !!enable, - entry_protocol: entryProtocol, - exit_server_id: parseInt(exitServerId || '0', 10), - exit_protocol: exitProtocol, - ...settings, - }); - updateCascadeBadge(res.cascade || {}, { - up: !!(res.cascade && res.cascade.up), - handshake: !!(res.cascade && res.cascade.handshake), - }); - showToast(enable ? _('cascade_enabled') : _('cascade_disabled'), 'success'); - if (enable && res.warning) { - showToast(res.warning, 'info'); - } - await loadCascade(); - } catch (err) { - showToast(_('error') + ': ' + err.message, 'error'); - } finally { - setCascadeBusy(false); - } - } - - function enableCascade() { return setupCascade(true); } - function disableCascade() { return setupCascade(false); } - // ========== Init ========== applyInstalledAppsVisibility(); - fillCascadeExitProtocols(); - loadCascade(); checkServer(); loadServerStats(); diff --git a/translations/en.json b/translations/en.json index 71739b0..e931a17 100644 --- a/translations/en.json +++ b/translations/en.json @@ -575,50 +575,5 @@ "xui_server_name_hint": "Shown in invites and user forms (e.g. US, NL, Home).", "xui_server_select_label": "3x-ui server", "xui_server_select_hint": "Pick a 3x-ui panel, then an inbound from the list that panel returns via API.", - "xui_server_delete_confirm": "Delete this 3x-ui server from the panel?", - "cascade_title": "Cascade (double VPN)", - "cascade_desc": "Clients connect to this entry server; internet exits through another server — to bypass country blocks.", - "cascade_hint": "ryderams-style AWG2 cascade: entry and exit must both have AmneziaWG/WireGuard. Clients keep using the ENTRY config only.", - "cascade_client_note": "Users connect only to the ENTRY server. Do not give them the exit cascade peer.", - "cascade_security_note": "Logic runs via panel SSH (no remote curl|bash). Based on ryderams/amneziawg-cascade approach.", - "cascade_entry_protocol": "Protocol on this server (entry)", - "cascade_exit_server": "Exit server", - "cascade_exit_server_none": "— Select server —", - "cascade_exit_protocol": "Protocol on exit server", - "cascade_enable": "Enable cascade", - "cascade_disable": "Disable cascade", - "cascade_working": "Setting up cascade…", - "cascade_disabling": "Disabling cascade…", - "cascade_enabled": "Cascade enabled", - "cascade_disabled": "Cascade disabled", - "cascade_off": "Off", - "cascade_on": "Active", - "cascade_configured_down": "Configured, tunnel down", - "cascade_exit_required": "Select an exit server", - "cascade_enable_confirm": "Enable cascade? A service client will be created on the exit server and a tunnel will be set up on this entry server.", - "cascade_disable_confirm": "Disable cascade on this server?", - "cascade_no_handshake": "Up, no handshake", - "cascade_settings_title": "Cascade settings (per step)", - "cascade_settings_desc": "If pages spin forever with no internet — keep all options on and click Enable cascade again.", - "cascade_opt_pin_exit": "Pin route to exit server", - "cascade_opt_pin_exit_hint": "Keeps the exit tunnel from looping through the VPN itself.", - "cascade_opt_remove_eth_masq": "Remove client NAT on eth0", - "cascade_opt_remove_eth_masq_hint": "Clients must not egress directly from the entry server.", - "cascade_opt_force_forward": "Force FORWARD between interfaces", - "cascade_opt_force_forward_hint": "Client traffic → cascade → exit.", - "cascade_opt_mss_clamp": "TCP MSS clamp", - "cascade_opt_mss_clamp_hint": "Fixes endless page loads on double VPN.", - "cascade_opt_wait_handshake": "Wait for exit handshake", - "cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.", - "cascade_verify_egress": "Verify egress IP", - "cascade_verify_egress_hint": "After handshake, verify policy route from awg0 IP via cascade, then curl public-IP services. Soft-warn if curl fails but route is OK.", - "cascade_opt_keep_dns": "Keep DNS from exit config", - "cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.", - "cascade_opt_handshake_timeout": "Handshake timeout (sec)", - "cascade_opt_table_id": "Routing table id", - "cascade_opt_rule_priority": "ip rule priority", - "cascade_opt_allowed_ips": "AllowedIPs for entry→exit tunnel", - "cascade_opt_allowed_ips_hint": "Prefer 0.0.0.0/0 (IPv4); ::/0 often breaks double VPN.", - "cascade_opt_vpn_subnet": "Client subnet override", - "cascade_opt_vpn_subnet_hint": "Empty = auto-detect from server Address" + "xui_server_delete_confirm": "Delete this 3x-ui server from the panel?" } diff --git a/translations/fa.json b/translations/fa.json index 0f2623d..60923f1 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -518,50 +518,5 @@ "xui_server_name_hint": "در دعوت‌ها نمایش داده می‌شود (مثل US، NL).", "xui_server_select_label": "سرور 3x-ui", "xui_server_select_hint": "پنل 3x-ui که URL اشتراک می‌دهد را انتخاب کنید.", - "xui_server_delete_confirm": "این سرور 3x-ui حذف شود؟", - "cascade_title": "Cascade (double VPN)", - "cascade_desc": "Clients connect to this entry server; internet exits through another server — to bypass country blocks.", - "cascade_hint": "ryderams-style AWG2 cascade: entry and exit must both have AmneziaWG/WireGuard. Clients keep using the ENTRY config only.", - "cascade_client_note": "Users connect only to the ENTRY server. Do not give them the exit cascade peer.", - "cascade_security_note": "Logic runs via panel SSH (no remote curl|bash). Based on ryderams/amneziawg-cascade approach.", - "cascade_entry_protocol": "Protocol on this server (entry)", - "cascade_exit_server": "Exit server", - "cascade_exit_server_none": "— Select server —", - "cascade_exit_protocol": "Protocol on exit server", - "cascade_enable": "Enable cascade", - "cascade_disable": "Disable cascade", - "cascade_working": "Setting up cascade…", - "cascade_disabling": "Disabling cascade…", - "cascade_enabled": "Cascade enabled", - "cascade_disabled": "Cascade disabled", - "cascade_off": "Off", - "cascade_on": "Active", - "cascade_configured_down": "Configured, tunnel down", - "cascade_exit_required": "Select an exit server", - "cascade_enable_confirm": "Enable cascade? A service client will be created on the exit server and a tunnel will be set up on this entry server.", - "cascade_disable_confirm": "Disable cascade on this server?", - "cascade_no_handshake": "Up, no handshake", - "cascade_settings_title": "Cascade settings (per step)", - "cascade_settings_desc": "If pages spin forever with no internet — keep all options on and click Enable cascade again.", - "cascade_opt_pin_exit": "Pin route to exit server", - "cascade_opt_pin_exit_hint": "Keeps the exit tunnel from looping through the VPN itself.", - "cascade_opt_remove_eth_masq": "Remove client NAT on eth0", - "cascade_opt_remove_eth_masq_hint": "Clients must not egress directly from the entry server.", - "cascade_opt_force_forward": "Force FORWARD between interfaces", - "cascade_opt_force_forward_hint": "Client traffic → cascade → exit.", - "cascade_opt_mss_clamp": "TCP MSS clamp", - "cascade_opt_mss_clamp_hint": "Fixes endless page loads on double VPN.", - "cascade_opt_wait_handshake": "Wait for exit handshake", - "cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.", - "cascade_verify_egress": "Verify egress IP", - "cascade_verify_egress_hint": "After handshake, verify policy route from awg0 IP via cascade, then curl public-IP services. Soft-warn if curl fails but route is OK.", - "cascade_opt_keep_dns": "Keep DNS from exit config", - "cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.", - "cascade_opt_handshake_timeout": "Handshake timeout (sec)", - "cascade_opt_table_id": "Routing table id", - "cascade_opt_rule_priority": "ip rule priority", - "cascade_opt_allowed_ips": "AllowedIPs for entry→exit tunnel", - "cascade_opt_allowed_ips_hint": "Prefer 0.0.0.0/0 (IPv4); ::/0 often breaks double VPN.", - "cascade_opt_vpn_subnet": "Client subnet override", - "cascade_opt_vpn_subnet_hint": "Empty = auto-detect from server Address" + "xui_server_delete_confirm": "این سرور 3x-ui حذف شود؟" } diff --git a/translations/fr.json b/translations/fr.json index 4d1c088..49e3f54 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -518,50 +518,5 @@ "xui_server_name_hint": "Affiché dans les invitations (ex: US, NL).", "xui_server_select_label": "Serveur 3x-ui", "xui_server_select_hint": "Choisissez le panneau 3x-ui qui émettra l\u0027URL d\u0027abonnement.", - "xui_server_delete_confirm": "Supprimer ce serveur 3x-ui ?", - "cascade_title": "Cascade (double VPN)", - "cascade_desc": "Clients connect to this entry server; internet exits through another server — to bypass country blocks.", - "cascade_hint": "ryderams-style AWG2 cascade: entry and exit must both have AmneziaWG/WireGuard. Clients keep using the ENTRY config only.", - "cascade_client_note": "Users connect only to the ENTRY server. Do not give them the exit cascade peer.", - "cascade_security_note": "Logic runs via panel SSH (no remote curl|bash). Based on ryderams/amneziawg-cascade approach.", - "cascade_entry_protocol": "Protocol on this server (entry)", - "cascade_exit_server": "Exit server", - "cascade_exit_server_none": "— Select server —", - "cascade_exit_protocol": "Protocol on exit server", - "cascade_enable": "Enable cascade", - "cascade_disable": "Disable cascade", - "cascade_working": "Setting up cascade…", - "cascade_disabling": "Disabling cascade…", - "cascade_enabled": "Cascade enabled", - "cascade_disabled": "Cascade disabled", - "cascade_off": "Off", - "cascade_on": "Active", - "cascade_configured_down": "Configured, tunnel down", - "cascade_exit_required": "Select an exit server", - "cascade_enable_confirm": "Enable cascade? A service client will be created on the exit server and a tunnel will be set up on this entry server.", - "cascade_disable_confirm": "Disable cascade on this server?", - "cascade_no_handshake": "Up, no handshake", - "cascade_settings_title": "Cascade settings (per step)", - "cascade_settings_desc": "If pages spin forever with no internet — keep all options on and click Enable cascade again.", - "cascade_opt_pin_exit": "Pin route to exit server", - "cascade_opt_pin_exit_hint": "Keeps the exit tunnel from looping through the VPN itself.", - "cascade_opt_remove_eth_masq": "Remove client NAT on eth0", - "cascade_opt_remove_eth_masq_hint": "Clients must not egress directly from the entry server.", - "cascade_opt_force_forward": "Force FORWARD between interfaces", - "cascade_opt_force_forward_hint": "Client traffic → cascade → exit.", - "cascade_opt_mss_clamp": "TCP MSS clamp", - "cascade_opt_mss_clamp_hint": "Fixes endless page loads on double VPN.", - "cascade_opt_wait_handshake": "Wait for exit handshake", - "cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.", - "cascade_verify_egress": "Verify egress IP", - "cascade_verify_egress_hint": "After handshake, verify policy route from awg0 IP via cascade, then curl public-IP services. Soft-warn if curl fails but route is OK.", - "cascade_opt_keep_dns": "Keep DNS from exit config", - "cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.", - "cascade_opt_handshake_timeout": "Handshake timeout (sec)", - "cascade_opt_table_id": "Routing table id", - "cascade_opt_rule_priority": "ip rule priority", - "cascade_opt_allowed_ips": "AllowedIPs for entry→exit tunnel", - "cascade_opt_allowed_ips_hint": "Prefer 0.0.0.0/0 (IPv4); ::/0 often breaks double VPN.", - "cascade_opt_vpn_subnet": "Client subnet override", - "cascade_opt_vpn_subnet_hint": "Empty = auto-detect from server Address" + "xui_server_delete_confirm": "Supprimer ce serveur 3x-ui ?" } diff --git a/translations/ru.json b/translations/ru.json index 0671182..0d3bf7e 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -575,50 +575,5 @@ "xui_server_name_hint": "Так сервер будет отображаться в инвайтах и у пользователей (например: US, NL, Home).", "xui_server_select_label": "Сервер 3x-ui", "xui_server_select_hint": "Выберите панель 3x-ui, затем inbound из списка, который она отдаёт по API.", - "xui_server_delete_confirm": "Удалить этот сервер 3x-ui из панели?", - "cascade_title": "Каскад (двойной VPN)", - "cascade_desc": "Клиенты подключаются к этому серверу (вход), а интернет выходит через другой сервер (выход) — чтобы обойти блокировку страны.", - "cascade_hint": "Каскад в стиле ryderams (AWG2): на входе и выходе должен быть AmneziaWG/WireGuard. Клиенты продолжают использовать только конфиг ВХОДНОГО сервера.", - "cascade_client_note": "Пользователи подключаются только к ВХОДНОМУ серверу. Не выдавайте им служебный peer выхода.", - "cascade_security_note": "Логика выполняется через SSH панели (без удалённого curl|bash). Подход на основе ryderams/amneziawg-cascade.", - "cascade_entry_protocol": "Протокол на этом сервере (вход)", - "cascade_exit_server": "Сервер выхода", - "cascade_exit_server_none": "— Выберите сервер —", - "cascade_exit_protocol": "Протокол на сервере выхода", - "cascade_enable": "Включить каскад", - "cascade_disable": "Отключить каскад", - "cascade_working": "Настраиваю каскад…", - "cascade_disabling": "Отключаю каскад…", - "cascade_enabled": "Каскад включён", - "cascade_disabled": "Каскад отключён", - "cascade_off": "Выключен", - "cascade_on": "Активен", - "cascade_configured_down": "Настроен, туннель вниз", - "cascade_exit_required": "Выберите сервер выхода", - "cascade_enable_confirm": "Включить каскад? На выходном сервере будет создан служебный клиент, а на этом — туннель к нему.", - "cascade_disable_confirm": "Отключить каскад на этом сервере?", - "cascade_no_handshake": "Туннель вверх, нет handshake", - "cascade_settings_title": "Настройки каскада (по шагам)", - "cascade_settings_desc": "Если страницы «крутятся», а интернета нет — оставьте все галочки включёнными и снова нажмите «Включить каскад».", - "cascade_opt_pin_exit": "Закрепить маршрут к серверу выхода", - "cascade_opt_pin_exit_hint": "Чтобы туннель к выходу не ушёл в петлю через сам VPN.", - "cascade_opt_remove_eth_masq": "Убрать NAT клиентов в eth0", - "cascade_opt_remove_eth_masq_hint": "Клиенты не должны выходить в интернет напрямую с входного сервера.", - "cascade_opt_force_forward": "Разрешить FORWARD между интерфейсами", - "cascade_opt_force_forward_hint": "Трафик клиентов → cascade → выход.", - "cascade_opt_mss_clamp": "TCP MSS clamp", - "cascade_opt_mss_clamp_hint": "Чинит «вечную загрузку» сайтов при двойном VPN.", - "cascade_opt_wait_handshake": "Ждать handshake с выходом", - "cascade_opt_wait_handshake_hint": "Не помечать каскад «активным», если выход не отвечает.", - "cascade_verify_egress": "Проверять внешний IP", - "cascade_verify_egress_hint": "После handshake — проверка policy route с IP awg0 через cascade, затем curl. При сбое curl (при OK маршруте) — предупреждение, не откат.", - "cascade_opt_keep_dns": "Оставить DNS из конфига выхода", - "cascade_opt_keep_dns_hint": "Обычно выключено — DNS берёт клиент сам.", - "cascade_opt_handshake_timeout": "Таймаут handshake (сек)", - "cascade_opt_table_id": "Таблица маршрутов", - "cascade_opt_rule_priority": "Приоритет ip rule", - "cascade_opt_allowed_ips": "AllowedIPs туннеля вход→выход", - "cascade_opt_allowed_ips_hint": "Лучше 0.0.0.0/0 (IPv4); ::/0 часто ломает двойной VPN.", - "cascade_opt_vpn_subnet": "Подсеть клиентов (override)", - "cascade_opt_vpn_subnet_hint": "Пусто = определить автоматически из Address сервера" + "xui_server_delete_confirm": "Удалить этот сервер 3x-ui из панели?" } diff --git a/translations/zh.json b/translations/zh.json index 28214b8..6412008 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -518,50 +518,5 @@ "xui_server_name_hint": "显示在邀请与用户表单中(如 US、NL)。", "xui_server_select_label": "3x-ui 服务器", "xui_server_select_hint": "选择签发订阅 URL 的 3x-ui 面板。", - "xui_server_delete_confirm": "从面板删除此 3x-ui 服务器?", - "cascade_title": "Cascade (double VPN)", - "cascade_desc": "Clients connect to this entry server; internet exits through another server — to bypass country blocks.", - "cascade_hint": "ryderams-style AWG2 cascade: entry and exit must both have AmneziaWG/WireGuard. Clients keep using the ENTRY config only.", - "cascade_client_note": "Users connect only to the ENTRY server. Do not give them the exit cascade peer.", - "cascade_security_note": "Logic runs via panel SSH (no remote curl|bash). Based on ryderams/amneziawg-cascade approach.", - "cascade_entry_protocol": "Protocol on this server (entry)", - "cascade_exit_server": "Exit server", - "cascade_exit_server_none": "— Select server —", - "cascade_exit_protocol": "Protocol on exit server", - "cascade_enable": "Enable cascade", - "cascade_disable": "Disable cascade", - "cascade_working": "Setting up cascade…", - "cascade_disabling": "Disabling cascade…", - "cascade_enabled": "Cascade enabled", - "cascade_disabled": "Cascade disabled", - "cascade_off": "Off", - "cascade_on": "Active", - "cascade_configured_down": "Configured, tunnel down", - "cascade_exit_required": "Select an exit server", - "cascade_enable_confirm": "Enable cascade? A service client will be created on the exit server and a tunnel will be set up on this entry server.", - "cascade_disable_confirm": "Disable cascade on this server?", - "cascade_no_handshake": "Up, no handshake", - "cascade_settings_title": "Cascade settings (per step)", - "cascade_settings_desc": "If pages spin forever with no internet — keep all options on and click Enable cascade again.", - "cascade_opt_pin_exit": "Pin route to exit server", - "cascade_opt_pin_exit_hint": "Keeps the exit tunnel from looping through the VPN itself.", - "cascade_opt_remove_eth_masq": "Remove client NAT on eth0", - "cascade_opt_remove_eth_masq_hint": "Clients must not egress directly from the entry server.", - "cascade_opt_force_forward": "Force FORWARD between interfaces", - "cascade_opt_force_forward_hint": "Client traffic → cascade → exit.", - "cascade_opt_mss_clamp": "TCP MSS clamp", - "cascade_opt_mss_clamp_hint": "Fixes endless page loads on double VPN.", - "cascade_opt_wait_handshake": "Wait for exit handshake", - "cascade_opt_wait_handshake_hint": "Do not mark cascade active if the exit peer never answers.", - "cascade_verify_egress": "Verify egress IP", - "cascade_verify_egress_hint": "After handshake, verify policy route from awg0 IP via cascade, then curl public-IP services. Soft-warn if curl fails but route is OK.", - "cascade_opt_keep_dns": "Keep DNS from exit config", - "cascade_opt_keep_dns_hint": "Usually off — the client sets DNS itself.", - "cascade_opt_handshake_timeout": "Handshake timeout (sec)", - "cascade_opt_table_id": "Routing table id", - "cascade_opt_rule_priority": "ip rule priority", - "cascade_opt_allowed_ips": "AllowedIPs for entry→exit tunnel", - "cascade_opt_allowed_ips_hint": "Prefer 0.0.0.0/0 (IPv4); ::/0 often breaks double VPN.", - "cascade_opt_vpn_subnet": "Client subnet override", - "cascade_opt_vpn_subnet_hint": "Empty = auto-detect from server Address" + "xui_server_delete_confirm": "从面板删除此 3x-ui 服务器?" }