diff --git a/README.md b/README.md index c1d8285..eaa396c 100644 --- a/README.md +++ b/README.md @@ -227,10 +227,14 @@ GitHub Actions workflows in `.github/workflows/`: ## 📋 Fix / changelog (this fork) +### 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** via cascade Address; subnet conflict check; exit `/32` return route with start.sh markers. +* 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**. ### v2.1.0 diff --git a/app.py b/app.py index bf3842b..833749c 100644 --- a/app.py +++ b/app.py @@ -102,7 +102,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.0" +CURRENT_VERSION = "v2.2.1" 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')) @@ -3476,7 +3476,12 @@ async def api_cascade_setup(request: Request, server_id: int, req: CascadeSetupR } async with DATA_LOCK: await asyncio.to_thread(save_data, data) - return {'status': 'success', 'cascade': entry['cascade'], 'applied': applied} + 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) diff --git a/managers/cascade_manager.py b/managers/cascade_manager.py index e5e3859..c339c22 100644 --- a/managers/cascade_manager.py +++ b/managers/cascade_manager.py @@ -261,6 +261,137 @@ class CascadeManager: '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() @@ -507,31 +638,28 @@ echo CASCADE_UP_OK 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): + 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 - detail = ((egress_err or egress_out) or 'empty response').strip()[:400] + 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 {cascade_peer_ip} → ifconfig.me). ' + 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], } @@ -544,6 +672,8 @@ echo CASCADE_UP_OK '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], diff --git a/templates/server.html b/templates/server.html index 7da581b..a9f3ca5 100644 --- a/templates/server.html +++ b/templates/server.html @@ -3274,6 +3274,9 @@ 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'); diff --git a/translations/en.json b/translations/en.json index d3aa81a..f7603f8 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1,4 +1,4 @@ -{ +{ "nav_servers": "Servers", "nav_users": "Users", "nav_invites": "Invite links", @@ -611,7 +611,7 @@ "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, curl ifconfig.me via the cascade Address; fail and roll back if empty.", + "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)", diff --git a/translations/fa.json b/translations/fa.json index b768b02..555295b 100644 --- a/translations/fa.json +++ b/translations/fa.json @@ -1,4 +1,4 @@ -{ +{ "nav_servers": "سرورها", "nav_users": "کاربران", "nav_invites": "لینک دعوت", @@ -554,7 +554,7 @@ "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, curl ifconfig.me via the cascade Address; fail and roll back if empty.", + "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)", diff --git a/translations/fr.json b/translations/fr.json index acb0b97..14fb321 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -1,4 +1,4 @@ -{ +{ "nav_servers": "Serveurs", "nav_users": "Utilisateurs", "nav_invites": "Liens d\u0027invitation", @@ -554,7 +554,7 @@ "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, curl ifconfig.me via the cascade Address; fail and roll back if empty.", + "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)", diff --git a/translations/ru.json b/translations/ru.json index 3c3db44..b74d954 100644 --- a/translations/ru.json +++ b/translations/ru.json @@ -1,4 +1,4 @@ -{ +{ "nav_servers": "Серверы", "nav_users": "Пользователи", "nav_invites": "Ссылки", @@ -611,7 +611,7 @@ "cascade_opt_wait_handshake": "Ждать handshake с выходом", "cascade_opt_wait_handshake_hint": "Не помечать каскад «активным», если выход не отвечает.", "cascade_verify_egress": "Проверять внешний IP", - "cascade_verify_egress_hint": "После handshake — curl ifconfig.me через Address каскада; при пустом ответе откат.", + "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 (сек)", diff --git a/translations/zh.json b/translations/zh.json index 3318bd5..62f4826 100644 --- a/translations/zh.json +++ b/translations/zh.json @@ -1,4 +1,4 @@ -{ +{ "nav_servers": "服务器", "nav_users": "用户列表", "nav_invites": "邀请链接", @@ -554,7 +554,7 @@ "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, curl ifconfig.me via the cascade Address; fail and roll back if empty.", + "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)",