Template
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:
+143
-13
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user