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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 (сек)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user