#!/usr/bin/env python3 from __future__ import annotations import json import os import re import socket import subprocess import time import urllib.parse import urllib.request import threading from pathlib import Path from typing import Any ROOT_DIR = Path(__file__).resolve().parent DATA_DIR = Path(os.environ["VPNGATE_DATA_DIR"]).resolve() if os.environ.get("VPNGATE_DATA_DIR") else ROOT_DIR / "vpngate_data" IP_CACHE_FILE = DATA_DIR / "ip_cache.json" ip_cache_lock = threading.RLock() physical_interface_lock = threading.Lock() physical_interface_cache: tuple[str | None, float] = (None, 0.0) COUNTRY_TRANSLATIONS = { "Japan": "日本", "Korea Republic of": "韩国", "Korea": "韩国", "Republic of Korea": "韩国", "Thailand": "泰国", "United States": "美国", "United Kingdom": "英国", "Russian Federation": "俄罗斯", "Russian": "俄罗斯", "Viet Nam": "越南", "Vietnam": "越南", "China": "中国", "Taiwan": "台湾", "Taiwan Province of China": "台湾", "Hong Kong": "香港", "Singapore": "新加坡", "Malaysia": "马来西亚", "Indonesia": "印度尼西亚", "India": "印度", "Philippines": "菲律宾", "Australia": "澳大利亚", "New Zealand": "新西兰", "Canada": "加拿大", "Ukraine": "乌克兰", "France": "法国", "Germany": "德国", "Netherlands": "荷兰", "Sweden": "瑞典", "Norway": "挪威", "Spain": "西班牙", "Turkey": "土耳其", "South Africa": "南非", "Brazil": "巴西", "Argentina": "阿根廷", "Chile": "智利", "Mexico": "墨西哥", "Egypt": "埃及", "Romania": "罗马尼亚", "Poland": "波兰", "Kazakhstan": "哈萨克斯坦", "Georgia": "格鲁吉亚", "Mongolia": "蒙古", "Saudi Arabia": "沙特阿拉伯", "Iran": "伊朗", "Iraq": "伊拉克", "Colombia": "哥伦比亚", "Cambodia": "柬埔寨", "Ireland": "爱尔兰", "Italy": "意大利", "Switzerland": "瑞士", "Belgium": "比利时", "Austria": "奥地利", "Denmark": "丹麦", "Finland": "芬兰", "Portugal": "葡萄牙", "Greece": "希腊", "Czech Republic": "捷克", "Hungary": "匈牙利", "Israel": "以色列", "United Arab Emirates": "阿联酋", "UAE": "阿联酋", "Macao": "澳门", "Macau": "澳门", "Iceland": "冰岛", "Luxembourg": "卢森堡", } def _safe_int(val: Any, default: int = 0) -> int: try: return int(val) except (ValueError, TypeError): return default def parse_proxy_endpoint(value: str, default_port: int) -> tuple[str | None, int | None]: value = value.strip() if not value: return None, None if "://" in value: parsed = urllib.parse.urlsplit(value) if parsed.hostname: return parsed.hostname, parsed.port or default_port return None, None if value.startswith("["): host_part, sep, rest = value.partition("]") host = host_part.lstrip("[") port = default_port if sep and rest.startswith(":"): port = _safe_int(rest[1:], default_port) return host or None, port if value.count(":") == 1: host, _, port_text = value.rpartition(":") return host or None, _safe_int(port_text, default_port) return value, default_port def _proxy_config_from_env(env_name: str, forced_type: str | None = None) -> tuple[str, str, int, str | None, str | None] | None: val = os.environ.get(env_name) if not val: return None if "://" in val: try: parsed = urllib.parse.urlsplit(val) except Exception: return None if not parsed.hostname: return None ptype = forced_type or ("socks" if parsed.scheme.startswith("socks") else "http") username = urllib.parse.unquote(parsed.username) if parsed.username is not None else None password = urllib.parse.unquote(parsed.password or "") if parsed.username is not None else None return ptype, parsed.hostname, parsed.port or 10808, username, password host, port = parse_proxy_endpoint(val, 10808) if host and port: return forced_type or "http", host, port, None, None return None def get_upstream_proxy_config() -> tuple[str | None, str | None, int | None, str | None, str | None]: for env_name, forced_type in [ ("OPENVPN_UPSTREAM_SOCKS", "socks"), ("OPENVPN_UPSTREAM_HTTP", "http"), ("http_proxy", None), ("HTTP_PROXY", None), ("https_proxy", None), ("HTTPS_PROXY", None), ]: cfg = _proxy_config_from_env(env_name, forced_type) if cfg: ptype, host, port, username, password = cfg return ptype, host, port, username, password return None, None, None, None, None def get_upstream_proxy() -> tuple[str | None, str | None, int | None]: """ Returns (proxy_type, host, port) from environment variables. proxy_type is 'socks' or 'http'. """ ptype, host, port, _, _ = get_upstream_proxy_config() return ptype, host, port def get_upstream_proxy_auth() -> tuple[str | None, str | None]: """ Returns optional (username, password) for the configured upstream proxy. Supports credentials embedded in proxy URLs and explicit env vars. """ _, _, _, username, password = get_upstream_proxy_config() if username is not None: return username, password or "" user = os.environ.get("OPENVPN_UPSTREAM_USER") or os.environ.get("OPENVPN_UPSTREAM_USERNAME") password = os.environ.get("OPENVPN_UPSTREAM_PASS") or os.environ.get("OPENVPN_UPSTREAM_PASSWORD") if user is not None: return user, password or "" return None, None def is_config_tcp(config_text: str) -> bool: try: for line in config_text.splitlines(): line = line.strip() if not line or line.startswith(("#", ";")): continue parts = line.split() if parts[0].lower() == "proto" and len(parts) >= 2: if "tcp" in parts[1].lower(): return True elif parts[0].lower() == "remote" and len(parts) >= 4: if "tcp" in parts[3].lower(): return True except Exception: pass return False def parse_remote(config_text: str, fallback_ip: str = "") -> tuple[str, int, str]: remote_host = fallback_ip remote_port = 0 proto = "unknown" for raw_line in config_text.splitlines(): line = raw_line.strip() if not line or line.startswith(("#", ";")): continue parts = line.split() if parts[0].lower() == "proto" and len(parts) >= 2: proto = parts[1].lower() elif parts[0].lower() == "remote" and len(parts) >= 3: remote_host = parts[1] remote_port = int(parts[2]) if parts[2].isdigit() else 0 if len(parts) >= 4: proto = parts[3].lower() return remote_host, remote_port, proto def _detect_physical_interface() -> str | None: try: res = subprocess.run(["ip", "route"], capture_output=True, text=True, timeout=2) if res.returncode == 0: routes = [] for line in res.stdout.splitlines(): if line.startswith("default"): parts = line.split() try: dev = parts[parts.index("dev") + 1] metric = 0 if "metric" in parts: metric = int(parts[parts.index("metric") + 1]) gw = parts[parts.index("via") + 1] if "via" in parts else "" routes.append((gw, dev, metric)) except (ValueError, IndexError): continue if routes: routes.sort(key=lambda x: x[2]) for gw, dev, metric in routes: if not dev.startswith(("tun", "tap", "wg", "ppp")): return dev return routes[0][1] except Exception: pass return None def get_physical_interface() -> str | None: global physical_interface_cache now = time.monotonic() with physical_interface_lock: cached_interface, cached_at = physical_interface_cache if cached_at > 0 and now - cached_at < 30: return cached_interface detected = _detect_physical_interface() physical_interface_cache = (detected, now) return detected def tcp_latency_ms(host: str, port: int, dev: str | None = None) -> int: started = time.time() # Auto-detect address family based on host address af = socket.AF_INET6 if ":" in host else socket.AF_INET s = None try: s = socket.socket(af, socket.SOCK_STREAM) s.settimeout(5) if dev: try: s.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, dev.encode("utf-8")) except OSError: pass s.connect((host, port)) return max(1, int((time.time() - started) * 1000)) except OSError: return 0 finally: if s is not None: try: s.close() except Exception: pass def ping_latency_ms(host: str, port: int, fallback_ping: int = 0) -> int: dev = get_physical_interface() # 1. Try ping with interface binding if dev: try: cmd = ["ping", "-c", "1", "-W", "2", "-I", dev, host] res = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=2 ) if res.returncode == 0: match = re.search(r"time=([\d.]+)\s*ms", res.stdout) if match: val = int(float(match.group(1))) if val > 0: return val except Exception: pass # 2. Try ping without interface binding try: cmd = ["ping", "-c", "1", "-W", "2", host] res = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=2 ) if res.returncode == 0: match = re.search(r"time=([\d.]+)\s*ms", res.stdout) if match: val = int(float(match.group(1))) if val > 0: return val except Exception: pass # 3. Try TCP latency check tcp_val = tcp_latency_ms(host, port, dev) if tcp_val > 0: return tcp_val # 4. Fallback if fallback_ping > 0: return fallback_ping return 0 def check_and_fix_dns() -> None: """ Checks if DNS resolution is broken. If names fail but direct IP connections work, appends public DNS nameservers to /etc/resolv.conf. Supports both IPv4 and IPv6 network environments. """ try: socket.getaddrinfo("www.vpngate.net", 443) return except (socket.gaierror, OSError): pass network_ok = False # Test IPv4 DNS servers first, then IPv6 dns_targets = [ ("8.8.8.8", 53, socket.AF_INET), ("1.1.1.1", 53, socket.AF_INET), ("2001:4860:4860::8888", 53, socket.AF_INET6), ("2606:4700:4700::1111", 53, socket.AF_INET6), ] for ip, port, af in dns_targets: s = None try: s = socket.socket(af, socket.SOCK_DGRAM) s.settimeout(2) s.connect((ip, port)) network_ok = True break except Exception: pass finally: if s is not None: try: s.close() except Exception: pass if not network_ok: return resolv_file = Path("/etc/resolv.conf") if resolv_file.exists(): try: content = resolv_file.read_text(encoding="utf-8", errors="replace") if "nameserver 1.1.1.1" not in content and "nameserver 8.8.8.8" not in content: print("[dns_heal] Resolving names failed, but IP network is OK. Appending public DNS to /etc/resolv.conf...", flush=True) with open("/etc/resolv.conf", "a", encoding="utf-8") as f: f.write("\nnameserver 1.1.1.1\nnameserver 8.8.8.8\n") except Exception as e: print(f"[dns_heal] Failed to write DNS fallback: {e}", flush=True) def load_ip_cache() -> dict[str, dict[str, Any]]: with ip_cache_lock: try: if IP_CACHE_FILE.exists(): return json.loads(IP_CACHE_FILE.read_text(encoding="utf-8")) except Exception: pass return {} def save_ip_cache(cache: dict[str, dict[str, Any]]) -> None: with ip_cache_lock: try: DATA_DIR.mkdir(exist_ok=True, parents=True) IP_CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8") except Exception: pass def enrich_ip_info(nodes: list[dict[str, Any]]) -> None: # 1. Read cache thread-safely with ip_cache_lock: cache = load_ip_cache() ips_to_query = [] now = time.time() for node in nodes: ip = node.get("ip") or node.get("remote_host") if not ip: continue if ip in cache and now - cache[ip].get("cached_at", 0) < 7 * 24 * 3600: cached = cache[ip] node["owner"] = cached.get("owner", "") node["asn"] = cached.get("asn", "") node["as_name"] = cached.get("as_name", "") node["location"] = cached.get("location", "") node["ip_type"] = cached.get("ip_type", "") node["quality"] = cached.get("quality", "") else: if ip not in ips_to_query: ips_to_query.append(ip) if not ips_to_query: return # 2. Perform HTTP query outside lock new_entries = {} chunk_size = 100 for i in range(0, len(ips_to_query), chunk_size): chunk = ips_to_query[i : i + chunk_size] payload = json.dumps(chunk).encode("utf-8") request = urllib.request.Request( "http://ip-api.com/batch?lang=zh-CN&fields=status,message,query,country,regionName,city,isp,org,as,asname,proxy,hosting,mobile", data=payload, headers={"Content-Type": "application/json", "User-Agent": "vpngate-manager/2.2"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=15) as response: data = json.loads(response.read().decode("utf-8", errors="replace")) if not isinstance(data, list): continue for item in data: if not isinstance(item, dict): continue if item.get("status") != "success": continue query_ip = item.get("query") if not query_ip: continue ip_type = "residential" if item.get("mobile"): ip_type = "mobile" elif item.get("hosting") or item.get("proxy"): ip_type = "hosting" quality = "normal" if item.get("proxy"): quality = "proxy" elif item.get("hosting"): quality = "datacenter" elif item.get("mobile"): quality = "mobile" loc = " ".join(part for part in [item.get("country"), item.get("regionName"), item.get("city")] if part) new_entries[query_ip] = { "owner": item.get("org") or item.get("isp") or "", "asn": item.get("as") or "", "as_name": item.get("asname") or "", "location": loc, "ip_type": ip_type, "quality": quality, "cached_at": now, } except Exception as e: print(f"[enrich_ip_info] Query failed: {e}", flush=True) if not new_entries: return # 3. Save cache thread-safely (reload & update to avoid overwrite of concurrent queries) with ip_cache_lock: cache = load_ip_cache() cache.update(new_entries) save_ip_cache(cache) # 4. Enrich nodes with newly queried info for node in nodes: ip = node.get("ip") or node.get("remote_host") if ip in new_entries: cached = new_entries[ip] node["owner"] = cached.get("owner", "") node["asn"] = cached.get("asn", "") node["as_name"] = cached.get("as_name", "") node["location"] = cached.get("location", "") node["ip_type"] = cached.get("ip_type", "") node["quality"] = cached.get("quality", "") def diagnose_api_failure(api_url: str = "https://www.vpngate.net/api/iphone/") -> tuple[int, str]: try: parsed = urllib.parse.urlsplit(api_url) domain = parsed.hostname or "www.vpngate.net" port = parsed.port or (443 if parsed.scheme == "https" else 80) except Exception: domain = "www.vpngate.net" port = 443 # 1. 检查本地 DNS 解析是否完全失效 dns_ok = False for test_domain in ["api.ipify.org", "dns.google", "one.one.one.one"]: try: socket.getaddrinfo(test_domain, 443) dns_ok = True break except Exception: pass # 2. 检查是否能解析 API 域名 api_dns_ok = False api_addr = None # (af, ip) tuple try: results = socket.getaddrinfo(domain, port, 0, socket.SOCK_STREAM) if results: api_dns_ok = True api_addr = (results[0][0], results[0][4][0]) # (address_family, ip) except Exception: pass if not api_dns_ok: if not dns_ok: return 1006, "[ERR_LOCAL_DNS_BROKEN] 本地 DNS 解析器完全失效。原因: 无法解析任何外部域名,请检查系统 DNS 配置(如 /etc/resolv.conf)及外网连接。" else: return 1007, f"[ERR_API_DOMAIN_BLOCKED] 解析 API 域名 {domain} 失败。原因: 其他外部域名解析正常,确认该官方 API 域名遭 DNS 污染或本地防火墙拦截。" # 3. 检查 TCP 连接 API 域名 api_conn_ok = False api_af, api_ip = api_addr s = None try: s = socket.socket(api_af, socket.SOCK_STREAM) s.settimeout(4) s.connect((api_ip, port)) api_conn_ok = True except Exception: pass finally: if s is not None: try: s.close() except Exception: pass if not api_conn_ok: ext_conn_ok = False # Test both IPv4 and IPv6 external connectivity ext_targets = [ ("8.8.8.8", 53, socket.AF_INET), ("1.1.1.1", 53, socket.AF_INET), ("2001:4860:4860::8888", 53, socket.AF_INET6), ("2606:4700:4700::1111", 53, socket.AF_INET6), ] for test_ip, test_port, af in ext_targets: s = None try: s = socket.socket(af, socket.SOCK_STREAM) s.settimeout(3) s.connect((test_ip, test_port)) ext_conn_ok = True break except Exception: pass finally: if s is not None: try: s.close() except Exception: pass if ext_conn_ok: return 1008, f"[ERR_API_IP_BLOCKED_OR_DOWN] 连接 API 服务器失败。原因: 外部网络连接通畅,但无法建立到 {domain} ({api_ip}:{port}) 的连接,可能是由于官方 IP 遭 GFW/防火墙 IP 阻断封锁或官方服务器宕机。" else: return 1009, "[ERR_VPS_OUTBOUND_BLOCKED] VPS 完全断网。原因: 任何外部测试连接均失败(IPv4 和 IPv6 均不可达),请检查 VPS 网卡和宿主机连接。" return 1010, f"[ERR_API_TLS_INTERFERENCE] HTTPS/TLS 握手被干扰。原因: 可以建立 TCP 连接但请求超时,通常是由于防火墙通过 SNI 阻断了 TLS 握手流。" def diagnose_openvpn_failure(log_tail: list[str]) -> tuple[int, str]: joined_log = "\n".join(log_tail).lower() if "command not found" in joined_log or "no such file or directory" in joined_log: return 2001, "[ERR_OVPN_CMD_NOT_FOUND] 未找到 openvpn 命令。原因: 系统中未安装 OpenVPN 软件,或环境变量 PATH 不正确。" if "cannot allocate tun" in joined_log or "cannot open tun/tap dev" in joined_log or "cannot ioctl" in joined_log or "cannot allocate tun/tap dev" in joined_log or "dev/net/tun" in joined_log or "operation not permitted" in joined_log: return 2009, "[ERR_OVPN_TUN_NOT_AVAILABLE] 无法创建或访问虚拟网卡 (TUN 设备)。原因: ① 缺少 tun 内核模块;② 当前运行在容器(如 LXC/OpenVZ/Docker)中且宿主机未授予网卡创建权限/未启用 CAP_NET_ADMIN 权限;③ `/dev/net/tun` 文件权限不足;④ 未使用 root 用户运行。如果是 Docker,请添加 `--cap-add=NET_ADMIN` 和 `--device=/dev/net/tun` 参数重新运行。" if "auth_failed" in joined_log or "authentication failed" in joined_log: return 2005, "[ERR_OVPN_AUTH_FAILED] OpenVPN 身份验证失败。原因: 节点配置的用户名密码不正确,或者该免费节点已失效/限制连接。" if "cannot resolve host address" in joined_log or "resolve: host name" in joined_log: return 2003, "[ERR_OVPN_DNS_RESOLVE] 节点服务器域名解析失败。原因: 本地 DNS 解析异常,或者节点域名已失效。" if "tls error: tls key negotiation failed" in joined_log or "tls error: tls handshake failed" in joined_log: return 2006, "[ERR_OVPN_TLS_BLOCKED] TLS 握手超时/失败。原因: 可能是由于物理链路极差导致握手包丢失,或者受 VPS 防火墙规则/网络监管(如 GFW)深度包检测拦截了 OpenVPN 协议流量。" if "connection timed out" in joined_log or "timeout" in joined_log: return 2004, "[ERR_OVPN_NODE_UNREACHABLE] 节点连接超时。原因: 远程节点已关机、VPS 本身出站流量被本地防火墙拦截,或者目的 IP:端口遭 ISP/GFW 屏蔽拦截。" if "connection refused" in joined_log: return 2004, "[ERR_OVPN_NODE_UNREACHABLE] 节点连接被拒绝。原因: 目的服务器未在指定端口监听,或者主动拒绝了连接。" if "permission denied" in joined_log or "root privileges" in joined_log or "need root" in joined_log: return 2002, "[ERR_OVPN_PERMISSION_DENIED] 权限不足。原因: 运行 OpenVPN 需要 root 权限,请确保以 root 用户身份或使用 sudo 运行本系统。" if "options error" in joined_log: return 2007, "[ERR_OVPN_ROUTE_NOPULL] 获取/解析 PUSH 配置参数冲突。原因: 某些推送选项在当前版本的客户端或配置环境中不可用。" return 2010, "[ERR_OVPN_UNKNOWN] OpenVPN 其他运行时异常。原因: 连接握手期间发生其他协议错误,详细信息请查看日志尾部。" def diagnose_local_obstructions(proxy_port: int = 7928, host: str = "127.0.0.1") -> tuple[int, str] | None: import sys # 1. 检查端口是否被占用 is_ipv6 = ":" in host or host == "" af = socket.AF_INET6 if is_ipv6 else socket.AF_INET s = None try: s = socket.socket(af, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, proxy_port)) except OSError as e: if e.errno == 98 or e.errno == 10048 or "already in use" in str(e).lower() or "not supported" in str(e).lower(): if e.errno in (98, 10048) or "already in use" in str(e).lower(): return 3005, f"[ERR_PORT_IN_USE] 本地代理端口 {proxy_port} 被占用。原因: 其他进程已抢占该端口,导致本系统代理网关启动失败。请运行 'lsof -i :{proxy_port}' 检查占用进程。" finally: if s is not None: try: s.close() except Exception: pass if sys.platform.startswith("linux"): # 1.5 检查 /dev/net/tun 虚拟网卡接口是否可用与具备权限 tun_path = Path("/dev/net/tun") if not tun_path.exists(): return 3009, "[ERR_TUN_DEV_NOT_FOUND] 系统中不存在虚拟网卡设备节点 `/dev/net/tun`。原因: 内核未加载 tun 模块,或宿主机禁用了 TUN 设备创建权限。请尝试运行 `modprobe tun` 加载模块,或在 VPS 控制面板中开启 TUN 支持。" try: with open(tun_path, "r+b") as f: pass except PermissionError: return 3010, "[ERR_TUN_PERMISSION_DENIED] 无权访问虚拟网卡设备节点 `/dev/net/tun`。原因: 当前用户对该节点没有读写权限。请确保使用 root 权限运行,或者运行 `chmod 666 /dev/net/tun` 赋予读写权限。" except Exception: pass # 2. 检查 IPv4 转发是否开启 ip_forward_path = Path("/proc/sys/net/ipv4/ip_forward") if ip_forward_path.exists(): try: val = ip_forward_path.read_text(encoding="utf-8").strip() if val == "0": return 3001, "[ERR_ROUTE_FORWARD_DISABLED] 系统未开启 IPv4 流量转发。原因: /proc/sys/net/ipv4/ip_forward 值为 0,会导致 VPN 隧道内的流量无法进行正常的网络转发。" except Exception: pass # 3. 检查本机防火墙策略 # 检查 UFW try: res = subprocess.run(["ufw", "status"], capture_output=True, text=True, timeout=2) if res.returncode == 0 and "Status: active" in res.stdout: if str(proxy_port) not in res.stdout: return 3007, f"[ERR_FIREWALL_BLOCKING_FORWARD] 本机 UFW 防火墙处于激活状态,但未在规则中允许代理端口 {proxy_port}。这可能会阻断客户端的连接。" except Exception: pass # 检查 Firewalld try: res = subprocess.run(["systemctl", "is-active", "firewalld"], capture_output=True, text=True, timeout=2) if res.returncode == 0 and res.stdout.strip() == "active": return 3007, "[ERR_FIREWALL_BLOCKING_FORWARD] 本机 Firewalld 防火墙正在运行。请确保您已将代理端口及 VPN 网卡(tun0)加入信任区域以避免流量被拦截。" except Exception: pass # 检查 iptables 默认策略 try: res = subprocess.run(["iptables", "-S"], capture_output=True, text=True, timeout=2) if res.returncode == 0: lines = res.stdout.splitlines() has_output_drop = False has_forward_drop = False for line in lines: if line.startswith("-P OUTPUT DROP"): has_output_drop = True elif line.startswith("-P FORWARD DROP"): has_forward_drop = True if has_output_drop: return 3007, "[ERR_FIREWALL_BLOCKING_FORWARD] 本机 iptables OUTPUT 默认策略被设为 DROP。这会导致 VPS 出站数据包被静默丢弃,从而彻底阻碍网关运行。" if has_forward_drop: return 3007, "[ERR_FIREWALL_BLOCKING_FORWARD] 本机 iptables FORWARD 默认策略被设为 DROP。且未配置相应的转发规则,这通常会拦截 VPN 网卡的流量穿透。" except Exception: pass # 4. 检查系统反向路径过滤 (rp_filter) 设置 rp_all_path = Path("/proc/sys/net/ipv4/conf/all/rp_filter") if rp_all_path.exists(): try: val = rp_all_path.read_text(encoding="utf-8").strip() if val == "1": return 3008, "[ERR_ROUTE_RP_FILTER_STRICT] 系统启用了严格的反向路径过滤(rp_filter=1)。原因: 在启用策略路由时,严格的路径过滤会导致通过虚拟网卡 tun0 的回包被内核静默丢弃,导致连接超时。请将 net.ipv4.conf.all.rp_filter 设置为 2 或 0。" except Exception: pass return None