From ff9d264f3cfc3cbe57ccd7fd09ff7815ca1ac819 Mon Sep 17 00:00:00 2001 From: baoweise-bot Date: Fri, 5 Jun 2026 00:54:16 +0800 Subject: [PATCH] refactor: improve socket handling, optimize node IP enrichment, and enforce thread safety in VPN management --- install.sh | 17 ++-- proxy_server.py | 159 ++++++++++++++++++++++-------------- vpn_utils.py | 93 +++++++++++++-------- vpngate_manager.py | 198 +++++++++++++++++++++++++-------------------- 4 files changed, 279 insertions(+), 188 deletions(-) diff --git a/install.sh b/install.sh index 845f0dc..fa8686d 100644 --- a/install.sh +++ b/install.sh @@ -955,9 +955,14 @@ chmod +x /usr/bin/ml AUTH_FILE="${INSTALL_DIR}/vpngate_data/ui_auth.json" mkdir -p "${INSTALL_DIR}/vpngate_data" +is_custom="n" if [ ! -f "$AUTH_FILE" ]; then - echo -e "\n${YELLOW}检测到是首次安装,是否需要自定义配置网页端参数(端口/安全后缀/登录账号密码)?${PLAIN}" - read -p "是否自定义配置?[y/N]: " is_custom + if [ -t 0 ]; then + echo -e "\n${YELLOW}检测到是首次安装,是否需要自定义配置网页端参数(端口/安全后缀/登录账号密码)?${PLAIN}" + read -p "是否自定义配置?[y/N]: " is_custom + else + echo -e "\n${YELLOW}检测到是非交互式/无TTY环境安装,已自动跳过网页端参数自定义配置,采用默认随机参数部署。${PLAIN}" + fi # Initialize defaults UI_PORT=8787 @@ -1070,13 +1075,13 @@ else fi sysctl -p >/dev/null 2>&1 || true fi -# Apply to currently active interfaces dynamically -sysctl -w net.ipv4.conf.all.rp_filter=2 >/dev/null 2>&1 || true -sysctl -w net.ipv4.conf.default.rp_filter=2 >/dev/null 2>&1 || true +# Apply to currently active interfaces dynamically (prefer native proc write for BusyBox/Alpine compatibility) +echo "2" > /proc/sys/net/ipv4/conf/all/rp_filter 2>/dev/null || sysctl -w net.ipv4.conf.all.rp_filter=2 >/dev/null 2>&1 || true +echo "2" > /proc/sys/net/ipv4/conf/default/rp_filter 2>/dev/null || sysctl -w net.ipv4.conf.default.rp_filter=2 >/dev/null 2>&1 || true if [ -d "/proc/sys/net/ipv4/conf" ]; then for dev_dir in /proc/sys/net/ipv4/conf/*; do dev_name=$(basename "$dev_dir") - sysctl -w net.ipv4.conf.${dev_name}.rp_filter=2 >/dev/null 2>&1 || true + echo "2" > "/proc/sys/net/ipv4/conf/${dev_name}/rp_filter" 2>/dev/null || sysctl -w net.ipv4.conf.${dev_name}.rp_filter=2 >/dev/null 2>&1 || true done fi diff --git a/proxy_server.py b/proxy_server.py index f3aba03..2983eef 100644 --- a/proxy_server.py +++ b/proxy_server.py @@ -35,24 +35,27 @@ def resolve_dns_over_tun0(host: str, dns_server: str = "8.8.8.8", timeout: float pass import random - tx_id = random.getrandbits(16).to_bytes(2, "big") - flags = b"\x01\x00" - questions = b"\x00\x01" - rrs = b"\x00\x00\x00\x00\x00\x00" - - qname = b"" - for part in host.split("."): - if not part: - continue - part_bytes = part.encode("idna") - qname += len(part_bytes).to_bytes(1, "big") + part_bytes - qname += b"\x00" - - qtype_qclass = b"\x00\x01\x00\x01" - packet = tx_id + flags + questions + rrs + qname + qtype_qclass - - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock = None try: + tx_id = random.getrandbits(16).to_bytes(2, "big") + flags = b"\x01\x00" + questions = b"\x00\x01" + rrs = b"\x00\x00\x00\x00\x00\x00" + + qname = b"" + for part in host.split("."): + if not part: + continue + part_bytes = part.encode("idna") + if len(part_bytes) > 63: + return None + qname += len(part_bytes).to_bytes(1, "big") + part_bytes + qname += b"\x00" + + qtype_qclass = b"\x00\x01\x00\x01" + packet = tx_id + flags + questions + rrs + qname + qtype_qclass + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(timeout) try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, b"tun0") @@ -67,37 +70,23 @@ def resolve_dns_over_tun0(host: str, dns_server: str = "8.8.8.8", timeout: float except Exception: return None finally: - sock.close() + if sock is not None: + try: + sock.close() + except Exception: + pass - if len(resp) < 12: - return None - if resp[:2] != tx_id: - return None + try: + if len(resp) < 12: + return None + if resp[:2] != tx_id: + return None - rcode = resp[3] & 0x0F - if rcode != 0: - return None + rcode = resp[3] & 0x0F + if rcode != 0: + return None - offset = 12 - while offset < len(resp): - length = resp[offset] - if length == 0: - offset += 1 - break - elif (length & 0xC0) == 0xC0: - offset += 2 - break - else: - offset += 1 + length - - offset += 4 - answers_count = int.from_bytes(resp[6:8], "big") - if answers_count == 0: - return None - - for _ in range(answers_count): - if offset >= len(resp): - break + offset = 12 while offset < len(resp): length = resp[offset] if length == 0: @@ -108,18 +97,39 @@ def resolve_dns_over_tun0(host: str, dns_server: str = "8.8.8.8", timeout: float break else: offset += 1 + length - if offset + 10 > len(resp): - break - atype = int.from_bytes(resp[offset : offset + 2], "big") - aclass = int.from_bytes(resp[offset + 2 : offset + 4], "big") - rdlength = int.from_bytes(resp[offset + 8 : offset + 10], "big") - offset += 10 - if offset + rdlength > len(resp): - break - if atype == 1 and aclass == 1 and rdlength == 4: - ip_bytes = resp[offset : offset + 4] - return socket.inet_ntoa(ip_bytes) - offset += rdlength + + offset += 4 + answers_count = int.from_bytes(resp[6:8], "big") + if answers_count == 0: + return None + + for _ in range(answers_count): + if offset >= len(resp): + break + while offset < len(resp): + length = resp[offset] + if length == 0: + offset += 1 + break + elif (length & 0xC0) == 0xC0: + offset += 2 + break + else: + offset += 1 + length + if offset + 10 > len(resp): + break + atype = int.from_bytes(resp[offset : offset + 2], "big") + aclass = int.from_bytes(resp[offset + 2 : offset + 4], "big") + rdlength = int.from_bytes(resp[offset + 8 : offset + 10], "big") + offset += 10 + if offset + rdlength > len(resp): + break + if atype == 1 and aclass == 1 and rdlength == 4: + ip_bytes = resp[offset : offset + 4] + return socket.inet_ntoa(ip_bytes) + offset += rdlength + except Exception: + return None return None def create_connection(address: tuple[str, int], timeout: float = 20) -> socket.socket: @@ -227,14 +237,35 @@ def http_client(client: socket.socket, first_byte: bytes) -> None: return parsed = urllib.parse.urlsplit(target) - if not parsed.hostname: + hostname = parsed.hostname + port = parsed.port + scheme = parsed.scheme + if not hostname: + # Fallback to Host header + for line in lines[1:]: + if line.lower().startswith("host:"): + host_val = line.split(":", 1)[1].strip() + if "[" in host_val and "]" in host_val: + host_part, _, port_part = host_val.rpartition("]") + hostname = host_part.lstrip("[") + if port_part.startswith(":"): + p_val = port_part.lstrip(":") + port = int(p_val) if p_val.isdigit() else None + else: + port = None + else: + host_part, _, port_part = host_val.partition(":") + hostname = host_part + port = int(port_part) if port_part.isdigit() else None + break + if not hostname: client.sendall(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n") return - port = parsed.port or (443 if parsed.scheme == "https" else 80) + port = port or (443 if scheme == "https" else 80) path = urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, "")) headers = [line for line in lines[1:] if not line.lower().startswith(("proxy-connection:", "connection:"))] request = f"{method} {path} {version}\r\n" + "\r\n".join(headers) + "\r\nConnection: close\r\n\r\n" - upstream = create_connection((parsed.hostname, port), timeout=20) + upstream = create_connection((hostname, port), timeout=20) upstream.sendall(request.encode("iso-8859-1") + rest) relay(client, upstream) except Exception as e: @@ -268,6 +299,7 @@ def proxy_client(client: socket.socket, address: tuple[str, int]) -> None: def start_proxy_server(host: str, port: int) -> None: is_ipv6 = ":" in host or host == "" af = socket.AF_INET6 if is_ipv6 else socket.AF_INET + server = None try: server = socket.socket(af, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -280,7 +312,12 @@ def start_proxy_server(host: str, port: int) -> None: server.listen(256) print(f"HTTP/SOCKS5 proxy listening on {host}:{port}", flush=True) except Exception as e: - if is_ipv6 and host == "::": + if server is not None: + try: + server.close() + except Exception: + pass + if is_ipv6 and host in ("::", ""): print(f"[警告] 绑定 IPv6 {host}:{port} 失败 ({e}),正在尝试回退至 IPv4 0.0.0.0 ...", flush=True) try: server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) diff --git a/vpn_utils.py b/vpn_utils.py index 735239b..c736f68 100644 --- a/vpn_utils.py +++ b/vpn_utils.py @@ -86,6 +86,12 @@ COUNTRY_TRANSLATIONS = { "Luxembourg": "卢森堡", } +def _safe_int(val: Any, default: int = 0) -> int: + try: + return int(val) + except (ValueError, TypeError): + return default + def get_upstream_proxy() -> tuple[str | None, str | None, int | None]: """ Returns (proxy_type, host, port) from environment variables. @@ -100,7 +106,7 @@ def get_upstream_proxy() -> tuple[str | None, str | None, int | None]: else: parts = socks_env.split(":") if len(parts) == 2: - return "socks", parts[0], int(parts[1]) + return "socks", parts[0], _safe_int(parts[1], 10808) elif len(parts) == 1: return "socks", parts[0], 10808 @@ -113,7 +119,7 @@ def get_upstream_proxy() -> tuple[str | None, str | None, int | None]: else: parts = http_env.split(":") if len(parts) == 2: - return "http", parts[0], int(parts[1]) + return "http", parts[0], _safe_int(parts[1], 10808) elif len(parts) == 1: return "http", parts[0], 10808 @@ -129,7 +135,7 @@ def get_upstream_proxy() -> tuple[str | None, str | None, int | None]: else: parts = val.split(":") if len(parts) == 2: - return "http", parts[0], int(parts[1]) + return "http", parts[0], _safe_int(parts[1], 10808) return None, None, None def is_config_tcp(config_text: str) -> bool: @@ -163,6 +169,8 @@ def parse_remote(config_text: str, fallback_ip: str = "") -> tuple[str, int, str 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 get_physical_interface() -> str | None: @@ -171,14 +179,14 @@ def get_physical_interface() -> str | None: if res.returncode == 0: routes = [] for line in res.stdout.splitlines(): - if line.startswith("default via"): + if line.startswith("default"): parts = line.split() try: - gw = parts[2] 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 @@ -196,8 +204,9 @@ 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 = socket.socket(af, socket.SOCK_STREAM) + s = None try: + s = socket.socket(af, socket.SOCK_STREAM) s.settimeout(5) if dev: try: @@ -209,10 +218,11 @@ def tcp_latency_ms(host: str, port: int, dev: str | None = None) -> int: except OSError: return 0 finally: - try: - s.close() - except Exception: - pass + 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() @@ -286,8 +296,9 @@ def check_and_fix_dns() -> None: ("2606:4700:4700::1111", 53, socket.AF_INET6), ] for ip, port, af in dns_targets: - s = socket.socket(af, socket.SOCK_DGRAM) + s = None try: + s = socket.socket(af, socket.SOCK_DGRAM) s.settimeout(2) s.connect((ip, port)) network_ok = True @@ -295,10 +306,11 @@ def check_and_fix_dns() -> None: except Exception: pass finally: - try: - s.close() - except Exception: - pass + if s is not None: + try: + s.close() + except Exception: + pass if not network_ok: return @@ -373,7 +385,11 @@ def enrich_ip_info(nodes: list[dict[str, Any]]) -> None: 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") @@ -471,18 +487,20 @@ def diagnose_api_failure(api_url: str = "https://www.vpngate.net/api/iphone/") - # 3. 检查 TCP 连接 API 域名 api_conn_ok = False api_af, api_ip = api_addr - s = socket.socket(api_af, socket.SOCK_STREAM) - s.settimeout(4) + 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: - try: - s.close() - except Exception: - pass + if s is not None: + try: + s.close() + except Exception: + pass if not api_conn_ok: ext_conn_ok = False @@ -494,19 +512,21 @@ def diagnose_api_failure(api_url: str = "https://www.vpngate.net/api/iphone/") - ("2606:4700:4700::1111", 53, socket.AF_INET6), ] for test_ip, test_port, af in ext_targets: - s = socket.socket(af, socket.SOCK_STREAM) - s.settimeout(3) + 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: - try: - s.close() - except Exception: - pass + 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: @@ -549,18 +569,21 @@ def diagnose_local_obstructions(proxy_port: int = 7928, host: str = "127.0.0.1") # 1. 检查端口是否被占用 is_ipv6 = ":" in host or host == "" af = socket.AF_INET6 if is_ipv6 else socket.AF_INET - s = socket.socket(af, socket.SOCK_STREAM) - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + 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(): - return 3005, f"[ERR_PORT_IN_USE] 本地代理端口 {proxy_port} 被占用。原因: 其他进程已抢占该端口,导致本系统代理网关启动失败。请运行 'lsof -i :{proxy_port}' 检查占用进程。" + 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: - try: - s.close() - except Exception: - pass + if s is not None: + try: + s.close() + except Exception: + pass if sys.platform.startswith("linux"): # 2. 检查 IPv4 转发是否开启 diff --git a/vpngate_manager.py b/vpngate_manager.py index e98e2ec..e387d03 100644 --- a/vpngate_manager.py +++ b/vpngate_manager.py @@ -329,9 +329,12 @@ def fetch_api_text_via_proxy(url: str, ptype: str, phost: str, pport: int, use_s if parsed.query: path += "?" + parsed.query - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(12) + is_ipv6 = ":" in phost + af = socket.AF_INET6 if is_ipv6 else socket.AF_INET + s = None try: + s = socket.socket(af, socket.SOCK_STREAM) + s.settimeout(12) s.connect((phost, pport)) if ptype == "socks": # SOCKS5 Handshake @@ -390,10 +393,11 @@ def fetch_api_text_via_proxy(url: str, ptype: str, phost: str, pport: int, use_s if len(response_data) > 10 * 1024 * 1024: # max 10MB safety guard break finally: - try: - s.close() - except Exception: - pass + if s is not None: + try: + s.close() + except Exception: + pass # Parse HTTP response header_end = response_data.find(b"\r\n\r\n") @@ -830,26 +834,27 @@ def cleanup_policy_routing() -> None: def stop_active_openvpn() -> None: global active_openvpn_process, active_openvpn_node_id - cleanup_policy_routing() - config_to_delete = None - if active_openvpn_node_id: - nodes = read_json(NODES_FILE, []) - node = next((item for item in nodes if item.get("id") == active_openvpn_node_id), None) - if node: - config_to_delete = node.get("config_file") - - stop_process(active_openvpn_process) - active_openvpn_process = None - active_openvpn_node_id = "" - kill_existing_openvpn_processes() - - if config_to_delete: - try: - path = Path(config_to_delete) - if path.exists(): - path.unlink() - except Exception: - pass + with lock: + cleanup_policy_routing() + config_to_delete = None + if active_openvpn_node_id: + nodes = read_json(NODES_FILE, []) + node = next((item for item in nodes if item.get("id") == active_openvpn_node_id), None) + if node: + config_to_delete = node.get("config_file") + + stop_process(active_openvpn_process) + active_openvpn_process = None + active_openvpn_node_id = "" + kill_existing_openvpn_processes() + + if config_to_delete: + try: + path = Path(config_to_delete) + if path.exists(): + path.unlink() + except Exception: + pass def active_openvpn_running() -> bool: return active_openvpn_process is not None and active_openvpn_process.poll() is None @@ -914,12 +919,11 @@ def test_node_by_id(node_id: str) -> dict[str, Any]: ok, message, _ = run_openvpn_until_ready(config_file, keep_alive=False, route_nopull=True, timeout=12, dev=f"tun{idx}") finally: release_test_index(idx) - - try: - if temp_path.exists(): - temp_path.unlink() - except Exception: - pass + try: + if temp_path.exists(): + temp_path.unlink() + except Exception: + pass temp_node = { "id": node_id, @@ -977,8 +981,20 @@ def test_multiple_nodes(node_ids: list[str]) -> list[dict[str, Any]]: try: CONFIG_DIR.mkdir(exist_ok=True, parents=True) temp_path.write_text(config_text, encoding="utf-8") - except Exception: - pass + except Exception as e: + return { + "id": node_id, + "latency_ms": 0, + "probe_status": "unavailable", + "probe_message": f"Failed to write configuration: {e}", + "probed_at": time.time(), + "owner": "", + "asn": "", + "as_name": "", + "location": "", + "ip_type": "", + "quality": "", + } latency = vpn_utils.ping_latency_ms(h, p, fallback_ping) tun_idx = get_free_test_index() @@ -987,15 +1003,17 @@ def test_multiple_nodes(node_ids: list[str]) -> list[dict[str, Any]]: ok, message, _ = run_openvpn_until_ready(config_file, keep_alive=False, route_nopull=True, timeout=12, dev=dev_name) finally: release_test_index(tun_idx) - - try: - if temp_path.exists(): - temp_path.unlink() - except Exception: - pass + try: + if temp_path.exists(): + temp_path.unlink() + except Exception: + pass temp_node = { "id": node_id, + "ip": n_info.get("ip") or h, + "remote_host": h, + "remote_port": p, "latency_ms": latency, "probe_status": "available" if ok else "unavailable", "probe_message": message, @@ -1007,19 +1025,6 @@ def test_multiple_nodes(node_ids: list[str]) -> list[dict[str, Any]]: "ip_type": "", "quality": "", } - if ok: - ip_to_enrich = { - "ip": n_info.get("ip"), - "remote_host": h, - "owner": "", - "asn": "", - "as_name": "", - "location": "", - "ip_type": "", - "quality": "", - } - vpn_utils.enrich_ip_info([ip_to_enrich]) - temp_node.update(ip_to_enrich) return temp_node updated_nodes_map = {} @@ -1039,6 +1044,14 @@ def test_multiple_nodes(node_ids: list[str]) -> list[dict[str, Any]]: "latency_ms": 0 } + # 批量查询并丰富可用节点的地理及 ISP 信息,防止并发时被定位 API 接口限流 + successful_nodes = [res for res in updated_nodes_map.values() if res.get("probe_status") == "available"] + if successful_nodes: + try: + vpn_utils.enrich_ip_info(successful_nodes) + except Exception as ee: + print(f"[test_multiple_nodes] 批量富化 IP 失败: {ee}", flush=True) + with lock: current_nodes = read_json(NODES_FILE, []) for n in current_nodes: @@ -1182,8 +1195,9 @@ def connect_node(node_id: str) -> str: active_openvpn_node_id = "" raise RuntimeError(message) - active_openvpn_process = process - active_openvpn_node_id = node_id + with lock: + active_openvpn_process = process + active_openvpn_node_id = node_id set_state(active_node_latency="配置路由", last_check_message="正在配置策略路由规则与流量转发...") setup_policy_routing("tun0") @@ -4099,9 +4113,10 @@ def check_proxy_health() -> dict[str, Any]: # 1. 检测代理服务端口是否在监听 is_ipv6 = ":" in LOCAL_PROXY_HOST af = socket.AF_INET6 if is_ipv6 else socket.AF_INET - s = socket.socket(af, socket.SOCK_STREAM) - s.settimeout(1.5) + s = None try: + s = socket.socket(af, socket.SOCK_STREAM) + s.settimeout(1.5) connect_host = LOCAL_PROXY_HOST if connect_host in ("::", "0.0.0.0", ""): connect_host = "::1" if is_ipv6 else "127.0.0.1" @@ -4123,10 +4138,11 @@ def check_proxy_health() -> dict[str, Any]: "error": f"代理服务未运行 ({diag_msg})" } finally: - try: - s.close() - except Exception: - pass + if s is not None: + try: + s.close() + except Exception: + pass # 2. 检测虚拟网卡 tun0 是否存在 (Linux 下) tun_path = Path("/sys/class/net/tun0") @@ -4157,20 +4173,20 @@ def check_proxy_health() -> dict[str, Any]: url, "--max-time", "5" ] - try: - res = subprocess.run(cmd, capture_output=True, text=True, timeout=6) - if res.returncode == 0: - lines = res.stdout.strip().splitlines() - if len(lines) >= 2: - ip = lines[0].strip() - time_info = lines[1].strip().split() - if len(time_info) == 2: - total_time_str, http_code = time_info - if http_code == "200" and ip: - latency_ms = int(float(total_time_str) * 1000) - return {"ok": True, "ip": ip, "latency_ms": latency_ms} - except Exception: - pass + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=6) + if res.returncode == 0: + lines = res.stdout.strip().splitlines() + if len(lines) >= 2: + ip = lines[0].strip() + time_info = lines[1].strip().split() + if len(time_info) == 2: + total_time_str, http_code = time_info + if http_code == "200" and ip: + latency_ms = int(float(total_time_str) * 1000) + return {"ok": True, "ip": ip, "latency_ms": latency_ms} + except Exception: + pass return None try: @@ -4404,9 +4420,10 @@ class Handler(BaseHTTPRequestHandler): proxy_err = "" is_ipv6 = ":" in LOCAL_PROXY_HOST af = socket.AF_INET6 if is_ipv6 else socket.AF_INET - s = socket.socket(af, socket.SOCK_STREAM) - s.settimeout(0.5) + s = None try: + s = socket.socket(af, socket.SOCK_STREAM) + s.settimeout(0.5) connect_host = LOCAL_PROXY_HOST if connect_host in ("::", "0.0.0.0", ""): connect_host = "::1" if is_ipv6 else "127.0.0.1" @@ -4426,10 +4443,11 @@ class Handler(BaseHTTPRequestHandler): diag = vpn_utils.diagnose_local_obstructions(LOCAL_PROXY_PORT, host=LOCAL_PROXY_HOST) proxy_err = diag[1] if diag else f"本地代理网关无法连通: {e}" finally: - try: - s.close() - except Exception: - pass + if s is not None: + try: + s.close() + except Exception: + pass proxy_gateway_status = { "name": "本地代理网关", "status": "running" if proxy_ok else "stopped", @@ -4797,6 +4815,12 @@ class Tee: self.stdout.flush() self.file.flush() + def isatty(self) -> bool: + return self.stdout.isatty() + + def __getattr__(self, attr: str) -> Any: + return getattr(self.stdout, attr) + def main() -> None: ensure_dirs() kill_existing_openvpn_processes() @@ -4830,8 +4854,9 @@ def main() -> None: is_ipv6 = ":" in LOCAL_PROXY_HOST af = socket.AF_INET6 if is_ipv6 else socket.AF_INET for _ in range(30): - s = socket.socket(af, socket.SOCK_STREAM) + s = None try: + s = socket.socket(af, socket.SOCK_STREAM) s.settimeout(0.5) connect_host = LOCAL_PROXY_HOST if connect_host in ("::", "0.0.0.0", ""): @@ -4855,10 +4880,11 @@ def main() -> None: except Exception: time.sleep(0.5) finally: - try: - s.close() - except Exception: - pass + if s is not None: + try: + s.close() + except Exception: + pass if gateway_ready: print("[网关] 代理网关已成功启动监听,启动同步与检测脚本...", flush=True)