refactor: improve socket handling, optimize node IP enrichment, and enforce thread safety in VPN management

This commit is contained in:
baoweise-bot
2026-06-05 00:54:16 +08:00
parent 0c2f3a84f0
commit ff9d264f3c
4 changed files with 279 additions and 188 deletions
+11 -6
View File
@@ -955,9 +955,14 @@ chmod +x /usr/bin/ml
AUTH_FILE="${INSTALL_DIR}/vpngate_data/ui_auth.json" AUTH_FILE="${INSTALL_DIR}/vpngate_data/ui_auth.json"
mkdir -p "${INSTALL_DIR}/vpngate_data" mkdir -p "${INSTALL_DIR}/vpngate_data"
is_custom="n"
if [ ! -f "$AUTH_FILE" ]; then if [ ! -f "$AUTH_FILE" ]; then
echo -e "\n${YELLOW}检测到是首次安装,是否需要自定义配置网页端参数(端口/安全后缀/登录账号密码)?${PLAIN}" if [ -t 0 ]; then
read -p "是否自定义配置?[y/N]: " is_custom echo -e "\n${YELLOW}检测到是首次安装,是否需要自定义配置网页端参数(端口/安全后缀/登录账号密码)?${PLAIN}"
read -p "是否自定义配置?[y/N]: " is_custom
else
echo -e "\n${YELLOW}检测到是非交互式/无TTY环境安装,已自动跳过网页端参数自定义配置,采用默认随机参数部署。${PLAIN}"
fi
# Initialize defaults # Initialize defaults
UI_PORT=8787 UI_PORT=8787
@@ -1070,13 +1075,13 @@ else
fi fi
sysctl -p >/dev/null 2>&1 || true sysctl -p >/dev/null 2>&1 || true
fi fi
# Apply to currently active interfaces dynamically # Apply to currently active interfaces dynamically (prefer native proc write for BusyBox/Alpine compatibility)
sysctl -w net.ipv4.conf.all.rp_filter=2 >/dev/null 2>&1 || true 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
sysctl -w net.ipv4.conf.default.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 if [ -d "/proc/sys/net/ipv4/conf" ]; then
for dev_dir in /proc/sys/net/ipv4/conf/*; do for dev_dir in /proc/sys/net/ipv4/conf/*; do
dev_name=$(basename "$dev_dir") 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 done
fi fi
+98 -61
View File
@@ -35,24 +35,27 @@ def resolve_dns_over_tun0(host: str, dns_server: str = "8.8.8.8", timeout: float
pass pass
import random import random
tx_id = random.getrandbits(16).to_bytes(2, "big") sock = None
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)
try: 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) sock.settimeout(timeout)
try: try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, b"tun0") 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: except Exception:
return None return None
finally: finally:
sock.close() if sock is not None:
try:
sock.close()
except Exception:
pass
if len(resp) < 12: try:
return None if len(resp) < 12:
if resp[:2] != tx_id: return None
return None if resp[:2] != tx_id:
return None
rcode = resp[3] & 0x0F rcode = resp[3] & 0x0F
if rcode != 0: if rcode != 0:
return None return None
offset = 12 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
while offset < len(resp): while offset < len(resp):
length = resp[offset] length = resp[offset]
if length == 0: if length == 0:
@@ -108,18 +97,39 @@ def resolve_dns_over_tun0(host: str, dns_server: str = "8.8.8.8", timeout: float
break break
else: else:
offset += 1 + length offset += 1 + length
if offset + 10 > len(resp):
break offset += 4
atype = int.from_bytes(resp[offset : offset + 2], "big") answers_count = int.from_bytes(resp[6:8], "big")
aclass = int.from_bytes(resp[offset + 2 : offset + 4], "big") if answers_count == 0:
rdlength = int.from_bytes(resp[offset + 8 : offset + 10], "big") return None
offset += 10
if offset + rdlength > len(resp): for _ in range(answers_count):
break if offset >= len(resp):
if atype == 1 and aclass == 1 and rdlength == 4: break
ip_bytes = resp[offset : offset + 4] while offset < len(resp):
return socket.inet_ntoa(ip_bytes) length = resp[offset]
offset += rdlength 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 return None
def create_connection(address: tuple[str, int], timeout: float = 20) -> socket.socket: 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 return
parsed = urllib.parse.urlsplit(target) 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") client.sendall(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n")
return 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, "")) path = urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, ""))
headers = [line for line in lines[1:] if not line.lower().startswith(("proxy-connection:", "connection:"))] 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" 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) upstream.sendall(request.encode("iso-8859-1") + rest)
relay(client, upstream) relay(client, upstream)
except Exception as e: 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: def start_proxy_server(host: str, port: int) -> None:
is_ipv6 = ":" in host or host == "" is_ipv6 = ":" in host or host == ""
af = socket.AF_INET6 if is_ipv6 else socket.AF_INET af = socket.AF_INET6 if is_ipv6 else socket.AF_INET
server = None
try: try:
server = socket.socket(af, socket.SOCK_STREAM) server = socket.socket(af, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 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) server.listen(256)
print(f"HTTP/SOCKS5 proxy listening on {host}:{port}", flush=True) print(f"HTTP/SOCKS5 proxy listening on {host}:{port}", flush=True)
except Exception as e: 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) print(f"[警告] 绑定 IPv6 {host}:{port} 失败 ({e}),正在尝试回退至 IPv4 0.0.0.0 ...", flush=True)
try: try:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+58 -35
View File
@@ -86,6 +86,12 @@ COUNTRY_TRANSLATIONS = {
"Luxembourg": "卢森堡", "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]: def get_upstream_proxy() -> tuple[str | None, str | None, int | None]:
""" """
Returns (proxy_type, host, port) from environment variables. Returns (proxy_type, host, port) from environment variables.
@@ -100,7 +106,7 @@ def get_upstream_proxy() -> tuple[str | None, str | None, int | None]:
else: else:
parts = socks_env.split(":") parts = socks_env.split(":")
if len(parts) == 2: if len(parts) == 2:
return "socks", parts[0], int(parts[1]) return "socks", parts[0], _safe_int(parts[1], 10808)
elif len(parts) == 1: elif len(parts) == 1:
return "socks", parts[0], 10808 return "socks", parts[0], 10808
@@ -113,7 +119,7 @@ def get_upstream_proxy() -> tuple[str | None, str | None, int | None]:
else: else:
parts = http_env.split(":") parts = http_env.split(":")
if len(parts) == 2: if len(parts) == 2:
return "http", parts[0], int(parts[1]) return "http", parts[0], _safe_int(parts[1], 10808)
elif len(parts) == 1: elif len(parts) == 1:
return "http", parts[0], 10808 return "http", parts[0], 10808
@@ -129,7 +135,7 @@ def get_upstream_proxy() -> tuple[str | None, str | None, int | None]:
else: else:
parts = val.split(":") parts = val.split(":")
if len(parts) == 2: if len(parts) == 2:
return "http", parts[0], int(parts[1]) return "http", parts[0], _safe_int(parts[1], 10808)
return None, None, None return None, None, None
def is_config_tcp(config_text: str) -> bool: 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: elif parts[0].lower() == "remote" and len(parts) >= 3:
remote_host = parts[1] remote_host = parts[1]
remote_port = int(parts[2]) if parts[2].isdigit() else 0 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 return remote_host, remote_port, proto
def get_physical_interface() -> str | None: def get_physical_interface() -> str | None:
@@ -171,14 +179,14 @@ def get_physical_interface() -> str | None:
if res.returncode == 0: if res.returncode == 0:
routes = [] routes = []
for line in res.stdout.splitlines(): for line in res.stdout.splitlines():
if line.startswith("default via"): if line.startswith("default"):
parts = line.split() parts = line.split()
try: try:
gw = parts[2]
dev = parts[parts.index("dev") + 1] dev = parts[parts.index("dev") + 1]
metric = 0 metric = 0
if "metric" in parts: if "metric" in parts:
metric = int(parts[parts.index("metric") + 1]) metric = int(parts[parts.index("metric") + 1])
gw = parts[parts.index("via") + 1] if "via" in parts else ""
routes.append((gw, dev, metric)) routes.append((gw, dev, metric))
except (ValueError, IndexError): except (ValueError, IndexError):
continue continue
@@ -196,8 +204,9 @@ def tcp_latency_ms(host: str, port: int, dev: str | None = None) -> int:
started = time.time() started = time.time()
# Auto-detect address family based on host address # Auto-detect address family based on host address
af = socket.AF_INET6 if ":" in host else socket.AF_INET af = socket.AF_INET6 if ":" in host else socket.AF_INET
s = socket.socket(af, socket.SOCK_STREAM) s = None
try: try:
s = socket.socket(af, socket.SOCK_STREAM)
s.settimeout(5) s.settimeout(5)
if dev: if dev:
try: try:
@@ -209,10 +218,11 @@ def tcp_latency_ms(host: str, port: int, dev: str | None = None) -> int:
except OSError: except OSError:
return 0 return 0
finally: finally:
try: if s is not None:
s.close() try:
except Exception: s.close()
pass except Exception:
pass
def ping_latency_ms(host: str, port: int, fallback_ping: int = 0) -> int: def ping_latency_ms(host: str, port: int, fallback_ping: int = 0) -> int:
dev = get_physical_interface() dev = get_physical_interface()
@@ -286,8 +296,9 @@ def check_and_fix_dns() -> None:
("2606:4700:4700::1111", 53, socket.AF_INET6), ("2606:4700:4700::1111", 53, socket.AF_INET6),
] ]
for ip, port, af in dns_targets: for ip, port, af in dns_targets:
s = socket.socket(af, socket.SOCK_DGRAM) s = None
try: try:
s = socket.socket(af, socket.SOCK_DGRAM)
s.settimeout(2) s.settimeout(2)
s.connect((ip, port)) s.connect((ip, port))
network_ok = True network_ok = True
@@ -295,10 +306,11 @@ def check_and_fix_dns() -> None:
except Exception: except Exception:
pass pass
finally: finally:
try: if s is not None:
s.close() try:
except Exception: s.close()
pass except Exception:
pass
if not network_ok: if not network_ok:
return return
@@ -373,7 +385,11 @@ def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
try: try:
with urllib.request.urlopen(request, timeout=15) as response: with urllib.request.urlopen(request, timeout=15) as response:
data = json.loads(response.read().decode("utf-8", errors="replace")) data = json.loads(response.read().decode("utf-8", errors="replace"))
if not isinstance(data, list):
continue
for item in data: for item in data:
if not isinstance(item, dict):
continue
if item.get("status") != "success": if item.get("status") != "success":
continue continue
query_ip = item.get("query") 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 域名 # 3. 检查 TCP 连接 API 域名
api_conn_ok = False api_conn_ok = False
api_af, api_ip = api_addr api_af, api_ip = api_addr
s = socket.socket(api_af, socket.SOCK_STREAM) s = None
s.settimeout(4)
try: try:
s = socket.socket(api_af, socket.SOCK_STREAM)
s.settimeout(4)
s.connect((api_ip, port)) s.connect((api_ip, port))
api_conn_ok = True api_conn_ok = True
except Exception: except Exception:
pass pass
finally: finally:
try: if s is not None:
s.close() try:
except Exception: s.close()
pass except Exception:
pass
if not api_conn_ok: if not api_conn_ok:
ext_conn_ok = False 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), ("2606:4700:4700::1111", 53, socket.AF_INET6),
] ]
for test_ip, test_port, af in ext_targets: for test_ip, test_port, af in ext_targets:
s = socket.socket(af, socket.SOCK_STREAM) s = None
s.settimeout(3)
try: try:
s = socket.socket(af, socket.SOCK_STREAM)
s.settimeout(3)
s.connect((test_ip, test_port)) s.connect((test_ip, test_port))
ext_conn_ok = True ext_conn_ok = True
break break
except Exception: except Exception:
pass pass
finally: finally:
try: if s is not None:
s.close() try:
except Exception: s.close()
pass except Exception:
pass
if ext_conn_ok: if ext_conn_ok:
return 1008, f"[ERR_API_IP_BLOCKED_OR_DOWN] 连接 API 服务器失败。原因: 外部网络连接通畅,但无法建立到 {domain} ({api_ip}:{port}) 的连接,可能是由于官方 IP 遭 GFW/防火墙 IP 阻断封锁或官方服务器宕机。" return 1008, f"[ERR_API_IP_BLOCKED_OR_DOWN] 连接 API 服务器失败。原因: 外部网络连接通畅,但无法建立到 {domain} ({api_ip}:{port}) 的连接,可能是由于官方 IP 遭 GFW/防火墙 IP 阻断封锁或官方服务器宕机。"
else: else:
@@ -549,18 +569,21 @@ def diagnose_local_obstructions(proxy_port: int = 7928, host: str = "127.0.0.1")
# 1. 检查端口是否被占用 # 1. 检查端口是否被占用
is_ipv6 = ":" in host or host == "" is_ipv6 = ":" in host or host == ""
af = socket.AF_INET6 if is_ipv6 else socket.AF_INET af = socket.AF_INET6 if is_ipv6 else socket.AF_INET
s = socket.socket(af, socket.SOCK_STREAM) s = None
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try: try:
s = socket.socket(af, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, proxy_port)) s.bind((host, proxy_port))
except OSError as e: except OSError as e:
if e.errno == 98 or e.errno == 10048 or "already in use" in str(e).lower(): if e.errno == 98 or e.errno == 10048 or "already in use" in str(e).lower() or "not supported" in str(e).lower():
return 3005, f"[ERR_PORT_IN_USE] 本地代理端口 {proxy_port} 被占用。原因: 其他进程已抢占该端口,导致本系统代理网关启动失败。请运行 'lsof -i :{proxy_port}' 检查占用进程。" 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: finally:
try: if s is not None:
s.close() try:
except Exception: s.close()
pass except Exception:
pass
if sys.platform.startswith("linux"): if sys.platform.startswith("linux"):
# 2. 检查 IPv4 转发是否开启 # 2. 检查 IPv4 转发是否开启
+112 -86
View File
@@ -329,9 +329,12 @@ def fetch_api_text_via_proxy(url: str, ptype: str, phost: str, pport: int, use_s
if parsed.query: if parsed.query:
path += "?" + parsed.query path += "?" + parsed.query
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) is_ipv6 = ":" in phost
s.settimeout(12) af = socket.AF_INET6 if is_ipv6 else socket.AF_INET
s = None
try: try:
s = socket.socket(af, socket.SOCK_STREAM)
s.settimeout(12)
s.connect((phost, pport)) s.connect((phost, pport))
if ptype == "socks": if ptype == "socks":
# SOCKS5 Handshake # 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 if len(response_data) > 10 * 1024 * 1024: # max 10MB safety guard
break break
finally: finally:
try: if s is not None:
s.close() try:
except Exception: s.close()
pass except Exception:
pass
# Parse HTTP response # Parse HTTP response
header_end = response_data.find(b"\r\n\r\n") header_end = response_data.find(b"\r\n\r\n")
@@ -830,26 +834,27 @@ def cleanup_policy_routing() -> None:
def stop_active_openvpn() -> None: def stop_active_openvpn() -> None:
global active_openvpn_process, active_openvpn_node_id global active_openvpn_process, active_openvpn_node_id
cleanup_policy_routing() with lock:
config_to_delete = None cleanup_policy_routing()
if active_openvpn_node_id: config_to_delete = None
nodes = read_json(NODES_FILE, []) if active_openvpn_node_id:
node = next((item for item in nodes if item.get("id") == active_openvpn_node_id), None) nodes = read_json(NODES_FILE, [])
if node: node = next((item for item in nodes if item.get("id") == active_openvpn_node_id), None)
config_to_delete = node.get("config_file") if node:
config_to_delete = node.get("config_file")
stop_process(active_openvpn_process)
active_openvpn_process = None stop_process(active_openvpn_process)
active_openvpn_node_id = "" active_openvpn_process = None
kill_existing_openvpn_processes() active_openvpn_node_id = ""
kill_existing_openvpn_processes()
if config_to_delete:
try: if config_to_delete:
path = Path(config_to_delete) try:
if path.exists(): path = Path(config_to_delete)
path.unlink() if path.exists():
except Exception: path.unlink()
pass except Exception:
pass
def active_openvpn_running() -> bool: def active_openvpn_running() -> bool:
return active_openvpn_process is not None and active_openvpn_process.poll() is None 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}") ok, message, _ = run_openvpn_until_ready(config_file, keep_alive=False, route_nopull=True, timeout=12, dev=f"tun{idx}")
finally: finally:
release_test_index(idx) release_test_index(idx)
try:
try: if temp_path.exists():
if temp_path.exists(): temp_path.unlink()
temp_path.unlink() except Exception:
except Exception: pass
pass
temp_node = { temp_node = {
"id": node_id, "id": node_id,
@@ -977,8 +981,20 @@ def test_multiple_nodes(node_ids: list[str]) -> list[dict[str, Any]]:
try: try:
CONFIG_DIR.mkdir(exist_ok=True, parents=True) CONFIG_DIR.mkdir(exist_ok=True, parents=True)
temp_path.write_text(config_text, encoding="utf-8") temp_path.write_text(config_text, encoding="utf-8")
except Exception: except Exception as e:
pass 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) latency = vpn_utils.ping_latency_ms(h, p, fallback_ping)
tun_idx = get_free_test_index() 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) ok, message, _ = run_openvpn_until_ready(config_file, keep_alive=False, route_nopull=True, timeout=12, dev=dev_name)
finally: finally:
release_test_index(tun_idx) release_test_index(tun_idx)
try:
try: if temp_path.exists():
if temp_path.exists(): temp_path.unlink()
temp_path.unlink() except Exception:
except Exception: pass
pass
temp_node = { temp_node = {
"id": node_id, "id": node_id,
"ip": n_info.get("ip") or h,
"remote_host": h,
"remote_port": p,
"latency_ms": latency, "latency_ms": latency,
"probe_status": "available" if ok else "unavailable", "probe_status": "available" if ok else "unavailable",
"probe_message": message, "probe_message": message,
@@ -1007,19 +1025,6 @@ def test_multiple_nodes(node_ids: list[str]) -> list[dict[str, Any]]:
"ip_type": "", "ip_type": "",
"quality": "", "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 return temp_node
updated_nodes_map = {} updated_nodes_map = {}
@@ -1039,6 +1044,14 @@ def test_multiple_nodes(node_ids: list[str]) -> list[dict[str, Any]]:
"latency_ms": 0 "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: with lock:
current_nodes = read_json(NODES_FILE, []) current_nodes = read_json(NODES_FILE, [])
for n in current_nodes: for n in current_nodes:
@@ -1182,8 +1195,9 @@ def connect_node(node_id: str) -> str:
active_openvpn_node_id = "" active_openvpn_node_id = ""
raise RuntimeError(message) raise RuntimeError(message)
active_openvpn_process = process with lock:
active_openvpn_node_id = node_id active_openvpn_process = process
active_openvpn_node_id = node_id
set_state(active_node_latency="配置路由", last_check_message="正在配置策略路由规则与流量转发...") set_state(active_node_latency="配置路由", last_check_message="正在配置策略路由规则与流量转发...")
setup_policy_routing("tun0") setup_policy_routing("tun0")
@@ -4099,9 +4113,10 @@ def check_proxy_health() -> dict[str, Any]:
# 1. 检测代理服务端口是否在监听 # 1. 检测代理服务端口是否在监听
is_ipv6 = ":" in LOCAL_PROXY_HOST is_ipv6 = ":" in LOCAL_PROXY_HOST
af = socket.AF_INET6 if is_ipv6 else socket.AF_INET af = socket.AF_INET6 if is_ipv6 else socket.AF_INET
s = socket.socket(af, socket.SOCK_STREAM) s = None
s.settimeout(1.5)
try: try:
s = socket.socket(af, socket.SOCK_STREAM)
s.settimeout(1.5)
connect_host = LOCAL_PROXY_HOST connect_host = LOCAL_PROXY_HOST
if connect_host in ("::", "0.0.0.0", ""): if connect_host in ("::", "0.0.0.0", ""):
connect_host = "::1" if is_ipv6 else "127.0.0.1" 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})" "error": f"代理服务未运行 ({diag_msg})"
} }
finally: finally:
try: if s is not None:
s.close() try:
except Exception: s.close()
pass except Exception:
pass
# 2. 检测虚拟网卡 tun0 是否存在 (Linux 下) # 2. 检测虚拟网卡 tun0 是否存在 (Linux 下)
tun_path = Path("/sys/class/net/tun0") tun_path = Path("/sys/class/net/tun0")
@@ -4157,20 +4173,20 @@ def check_proxy_health() -> dict[str, Any]:
url, url,
"--max-time", "5" "--max-time", "5"
] ]
try: try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=6) res = subprocess.run(cmd, capture_output=True, text=True, timeout=6)
if res.returncode == 0: if res.returncode == 0:
lines = res.stdout.strip().splitlines() lines = res.stdout.strip().splitlines()
if len(lines) >= 2: if len(lines) >= 2:
ip = lines[0].strip() ip = lines[0].strip()
time_info = lines[1].strip().split() time_info = lines[1].strip().split()
if len(time_info) == 2: if len(time_info) == 2:
total_time_str, http_code = time_info total_time_str, http_code = time_info
if http_code == "200" and ip: if http_code == "200" and ip:
latency_ms = int(float(total_time_str) * 1000) latency_ms = int(float(total_time_str) * 1000)
return {"ok": True, "ip": ip, "latency_ms": latency_ms} return {"ok": True, "ip": ip, "latency_ms": latency_ms}
except Exception: except Exception:
pass pass
return None return None
try: try:
@@ -4404,9 +4420,10 @@ class Handler(BaseHTTPRequestHandler):
proxy_err = "" proxy_err = ""
is_ipv6 = ":" in LOCAL_PROXY_HOST is_ipv6 = ":" in LOCAL_PROXY_HOST
af = socket.AF_INET6 if is_ipv6 else socket.AF_INET af = socket.AF_INET6 if is_ipv6 else socket.AF_INET
s = socket.socket(af, socket.SOCK_STREAM) s = None
s.settimeout(0.5)
try: try:
s = socket.socket(af, socket.SOCK_STREAM)
s.settimeout(0.5)
connect_host = LOCAL_PROXY_HOST connect_host = LOCAL_PROXY_HOST
if connect_host in ("::", "0.0.0.0", ""): if connect_host in ("::", "0.0.0.0", ""):
connect_host = "::1" if is_ipv6 else "127.0.0.1" 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) diag = vpn_utils.diagnose_local_obstructions(LOCAL_PROXY_PORT, host=LOCAL_PROXY_HOST)
proxy_err = diag[1] if diag else f"本地代理网关无法连通: {e}" proxy_err = diag[1] if diag else f"本地代理网关无法连通: {e}"
finally: finally:
try: if s is not None:
s.close() try:
except Exception: s.close()
pass except Exception:
pass
proxy_gateway_status = { proxy_gateway_status = {
"name": "本地代理网关", "name": "本地代理网关",
"status": "running" if proxy_ok else "stopped", "status": "running" if proxy_ok else "stopped",
@@ -4797,6 +4815,12 @@ class Tee:
self.stdout.flush() self.stdout.flush()
self.file.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: def main() -> None:
ensure_dirs() ensure_dirs()
kill_existing_openvpn_processes() kill_existing_openvpn_processes()
@@ -4830,8 +4854,9 @@ def main() -> None:
is_ipv6 = ":" in LOCAL_PROXY_HOST is_ipv6 = ":" in LOCAL_PROXY_HOST
af = socket.AF_INET6 if is_ipv6 else socket.AF_INET af = socket.AF_INET6 if is_ipv6 else socket.AF_INET
for _ in range(30): for _ in range(30):
s = socket.socket(af, socket.SOCK_STREAM) s = None
try: try:
s = socket.socket(af, socket.SOCK_STREAM)
s.settimeout(0.5) s.settimeout(0.5)
connect_host = LOCAL_PROXY_HOST connect_host = LOCAL_PROXY_HOST
if connect_host in ("::", "0.0.0.0", ""): if connect_host in ("::", "0.0.0.0", ""):
@@ -4855,10 +4880,11 @@ def main() -> None:
except Exception: except Exception:
time.sleep(0.5) time.sleep(0.5)
finally: finally:
try: if s is not None:
s.close() try:
except Exception: s.close()
pass except Exception:
pass
if gateway_ready: if gateway_ready:
print("[网关] 代理网关已成功启动监听,启动同步与检测脚本...", flush=True) print("[网关] 代理网关已成功启动监听,启动同步与检测脚本...", flush=True)