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
+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],