Fix Cascade egress verify to bind awg0 IP (v2.2.1).

Use client-subnet policy routing for curl checks instead of cascade peer Address; soft-warn when echo services fail but the route is OK.
This commit is contained in:
orohi
2026-07-27 04:27:00 +03:00
parent ac76a0e540
commit 80160d0ef4
9 changed files with 168 additions and 26 deletions
+5 -1
View File
@@ -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
+7 -2
View File
@@ -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)
+143 -13
View File
@@ -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],
+3
View File
@@ -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');
+2 -2
View File
@@ -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)",
+2 -2
View File
@@ -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)",
+2 -2
View File
@@ -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)",
+2 -2
View File
@@ -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 (сек)",
+2 -2
View File
@@ -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)",