diff --git a/install.sh b/install.sh
index cb2b203..6465731 100644
--- a/install.sh
+++ b/install.sh
@@ -436,7 +436,8 @@ def print_status():
print_line(format_line("出口 IP (出站)", proxy_ip))
print_line(format_line("本地代理延迟", f"{proxy_latency} ms" if proxy_latency else "检测中..."))
else:
- print_line(format_line("出口 IP (出站)", f"{red}[检测中/未就绪]{reset}"))
+ proxy_err = state.get("proxy_error") or "检测中/未就绪"
+ print_line(format_line("出口 IP (出站)", f"{red}[不可用 - {proxy_err}]{reset}"))
else:
print_line(format_line("节点状态", "无活动连接"))
print_line()
diff --git a/proxy_server.py b/proxy_server.py
index 1445953..b684a98 100644
--- a/proxy_server.py
+++ b/proxy_server.py
@@ -51,7 +51,11 @@ def resolve_dns_over_tun0(host: str, dns_server: str = "8.8.8.8", timeout: float
sock.settimeout(timeout)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, b"tun0")
- except OSError:
+ except OSError as e:
+ if "operation not permitted" in str(e).lower() or e.errno == 1:
+ print("[DNS 绑定失败] [错误代码 3006] DNS 解析绑定 tun0 权限不足,请确保程序以 root 权限运行!", flush=True)
+ elif "no such device" in str(e).lower() or e.errno == 19:
+ print("[DNS 绑定失败] [错误代码 3004] DNS 解析绑定 tun0 失败,网卡设备不存在,请检查 VPN 连接!", flush=True)
return None
sock.sendto(packet, (dns_server, 53))
resp, _ = sock.recvfrom(2048)
@@ -131,6 +135,10 @@ def create_connection(address: tuple[str, int], timeout: float = 20) -> socket.s
return sock
except OSError as e:
err = e
+ if "operation not permitted" in str(e).lower() or e.errno == 1:
+ err = OSError(f"[错误代码 3006] [ERR_PROXY_BIND_TUN_PERM_DENIED] 绑定虚拟网卡 tun0 失败,权限不足!必须以 root 权限运行,或者进程缺少 CAP_NET_RAW 权限。")
+ elif "no such device" in str(e).lower() or e.errno == 19:
+ err = OSError(f"[错误代码 3004] [ERR_ROUTE_DEV_NOT_FOUND] 绑定虚拟网卡 tun0 失败,找不到设备!这通常是因为 OpenVPN 核心未能成功连接或已被异常终止。")
if sock is not None:
sock.close()
if err is not None:
@@ -255,7 +263,10 @@ 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:
- print(f"[ERROR] Failed to start HTTP/SOCKS5 proxy on {host}:{port}: {e}", flush=True)
+ import vpn_utils
+ diag = vpn_utils.diagnose_local_obstructions(port)
+ diag_msg = diag[1] if diag else str(e)
+ print(f"[ERROR] Failed to start HTTP/SOCKS5 proxy on {host}:{port}: {diag_msg}", flush=True)
return
while True:
diff --git a/vpn_utils.py b/vpn_utils.py
index 236e9a8..b16e5a6 100644
--- a/vpn_utils.py
+++ b/vpn_utils.py
@@ -1,422 +1,592 @@
-#!/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 = ROOT_DIR / "vpngate_data"
-IP_CACHE_FILE = DATA_DIR / "ip_cache.json"
-
-ip_cache_lock = threading.RLock()
-
-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 get_upstream_proxy() -> tuple[str | None, str | None, int | None]:
- """
- Returns (proxy_type, host, port) from environment variables.
- proxy_type is 'socks' or 'http'.
- """
- socks_env = os.environ.get("OPENVPN_UPSTREAM_SOCKS")
- if socks_env:
- if "://" in socks_env:
- parsed = urllib.parse.urlsplit(socks_env)
- if parsed.hostname and parsed.port:
- return "socks", parsed.hostname, parsed.port
- else:
- parts = socks_env.split(":")
- if len(parts) == 2:
- return "socks", parts[0], int(parts[1])
- elif len(parts) == 1:
- return "socks", parts[0], 10808
-
- http_env = os.environ.get("OPENVPN_UPSTREAM_HTTP")
- if http_env:
- if "://" in http_env:
- parsed = urllib.parse.urlsplit(http_env)
- if parsed.hostname and parsed.port:
- return "http", parsed.hostname, parsed.port
- else:
- parts = http_env.split(":")
- if len(parts) == 2:
- return "http", parts[0], int(parts[1])
- elif len(parts) == 1:
- return "http", parts[0], 10808
-
- for env_name in ["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"]:
- val = os.environ.get(env_name)
- if not val:
- continue
- if "://" in val:
- parsed = urllib.parse.urlsplit(val)
- ptype = "socks" if parsed.scheme.startswith("socks") else "http"
- if parsed.hostname and parsed.port:
- return ptype, parsed.hostname, parsed.port
- else:
- parts = val.split(":")
- if len(parts) == 2:
- return "http", parts[0], int(parts[1])
- return None, 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
- return remote_host, remote_port, proto
-
-def get_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 via"):
- 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])
- 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 tcp_latency_ms(host: str, port: int, dev: str | None = None) -> int:
- started = time.time()
- s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- try:
- 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:
- 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 in WSL.
- If names fail but direct IP connections work, appends public DNS nameservers to /etc/resolv.conf.
- """
- try:
- socket.gethostbyname("www.vpngate.net")
- return
- except socket.gaierror:
- pass
-
- network_ok = False
- for ip in ["8.8.8.8", "1.1.1.1"]:
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- try:
- s.settimeout(2)
- s.connect((ip, 53))
- network_ok = True
- break
- except Exception:
- pass
- finally:
- 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)
- 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"))
- for item in data:
- 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("proxy"):
- ip_type = "proxy"
- elif item.get("hosting"):
- 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", "")
\ No newline at end of file
+#!/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 = ROOT_DIR / "vpngate_data"
+IP_CACHE_FILE = DATA_DIR / "ip_cache.json"
+
+ip_cache_lock = threading.RLock()
+
+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 get_upstream_proxy() -> tuple[str | None, str | None, int | None]:
+ """
+ Returns (proxy_type, host, port) from environment variables.
+ proxy_type is 'socks' or 'http'.
+ """
+ socks_env = os.environ.get("OPENVPN_UPSTREAM_SOCKS")
+ if socks_env:
+ if "://" in socks_env:
+ parsed = urllib.parse.urlsplit(socks_env)
+ if parsed.hostname and parsed.port:
+ return "socks", parsed.hostname, parsed.port
+ else:
+ parts = socks_env.split(":")
+ if len(parts) == 2:
+ return "socks", parts[0], int(parts[1])
+ elif len(parts) == 1:
+ return "socks", parts[0], 10808
+
+ http_env = os.environ.get("OPENVPN_UPSTREAM_HTTP")
+ if http_env:
+ if "://" in http_env:
+ parsed = urllib.parse.urlsplit(http_env)
+ if parsed.hostname and parsed.port:
+ return "http", parsed.hostname, parsed.port
+ else:
+ parts = http_env.split(":")
+ if len(parts) == 2:
+ return "http", parts[0], int(parts[1])
+ elif len(parts) == 1:
+ return "http", parts[0], 10808
+
+ for env_name in ["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"]:
+ val = os.environ.get(env_name)
+ if not val:
+ continue
+ if "://" in val:
+ parsed = urllib.parse.urlsplit(val)
+ ptype = "socks" if parsed.scheme.startswith("socks") else "http"
+ if parsed.hostname and parsed.port:
+ return ptype, parsed.hostname, parsed.port
+ else:
+ parts = val.split(":")
+ if len(parts) == 2:
+ return "http", parts[0], int(parts[1])
+ return None, 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
+ return remote_host, remote_port, proto
+
+def get_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 via"):
+ 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])
+ 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 tcp_latency_ms(host: str, port: int, dev: str | None = None) -> int:
+ started = time.time()
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ 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:
+ 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 in WSL.
+ If names fail but direct IP connections work, appends public DNS nameservers to /etc/resolv.conf.
+ """
+ try:
+ socket.gethostbyname("www.vpngate.net")
+ return
+ except socket.gaierror:
+ pass
+
+ network_ok = False
+ for ip in ["8.8.8.8", "1.1.1.1"]:
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ try:
+ s.settimeout(2)
+ s.connect((ip, 53))
+ network_ok = True
+ break
+ except Exception:
+ pass
+ finally:
+ 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)
+ 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"))
+ for item in data:
+ 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("proxy"):
+ ip_type = "proxy"
+ elif item.get("hosting"):
+ 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.gethostbyname(test_domain)
+ dns_ok = True
+ break
+ except Exception:
+ pass
+
+ # 2. 检查是否能解析 API 域名
+ api_dns_ok = False
+ api_ip = None
+ try:
+ api_ip = socket.gethostbyname(domain)
+ api_dns_ok = True
+ 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
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(4)
+ try:
+ s.connect((api_ip, port))
+ api_conn_ok = True
+ except Exception:
+ pass
+ finally:
+ try:
+ s.close()
+ except Exception:
+ pass
+
+ if not api_conn_ok:
+ ext_conn_ok = False
+ for test_ip, test_port in [("8.8.8.8", 53), ("1.1.1.1", 53)]:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(3)
+ try:
+ s.connect((test_ip, test_port))
+ ext_conn_ok = True
+ break
+ except Exception:
+ pass
+ finally:
+ 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 完全断网。原因: 任何外部测试连接均失败,请检查 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:
+ return 2009, "[ERR_OVPN_TUN_NOT_AVAILABLE] 无法创建虚拟网卡 (TUN 设备)。原因: 缺少 tun 内核模块,或当前容器(如 LXC/OpenVZ/Docker)未被宿主机授予网卡创建权限。请在 VPS 面板中启用 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 "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) -> tuple[int, str] | None:
+ import sys
+ # 1. 检查端口是否被占用
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ s.bind(("127.0.0.1", 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}' 检查占用进程。"
+ finally:
+ try:
+ s.close()
+ except Exception:
+ pass
+
+ if sys.platform.startswith("linux"):
+ # 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
+
+ return None
\ No newline at end of file
diff --git a/vpngate_manager.py b/vpngate_manager.py
index b34841f..1e8323c 100644
--- a/vpngate_manager.py
+++ b/vpngate_manager.py
@@ -333,8 +333,16 @@ def fetch_candidates() -> list[dict[str, Any]]:
print(f"[fetch_candidates] Fetch {i+1} failed: {e}", flush=True)
log_to_json("WARNING", "Main", f"第 {i+1} 次拉取 API 节点失败: {e}")
if i == max_attempts - 1 and not candidates:
- log_to_json("ERROR", "Main", f"获取官方 API 节点失败: {e}")
- raise
+ err_code, diag_msg = vpn_utils.diagnose_api_failure(API_URL)
+ full_err_msg = f"获取官方 API 节点失败: {e} | 诊断结果: {diag_msg}"
+ print(f"[错误代码 {err_code}] {full_err_msg}", flush=True)
+ log_to_json("ERROR", "Main", f"[错误代码 {err_code}] {full_err_msg}")
+ set_state(
+ last_fetch_status="error",
+ last_fetch_error_code=err_code,
+ last_fetch_message=diag_msg
+ )
+ raise RuntimeError(diag_msg) from e
set_state(
last_fetch_at=time.time(),
@@ -468,9 +476,9 @@ def run_openvpn_until_ready(config_file: str, keep_alive: bool, route_nopull: bo
cwd=str(ROOT_DIR),
)
except FileNotFoundError:
- return False, "openvpn command not found", None
+ return False, "[错误代码 2001] [ERR_OVPN_CMD_NOT_FOUND] 未找到 openvpn 命令。原因: 系统未安装 openvpn,或 PATH 环境变量不正确。", None
except OSError as exc:
- return False, f"openvpn start failed: {exc}", None
+ return False, f"[错误代码 2002] [ERR_OVPN_START_FAILED] openvpn 启动失败: {exc}。原因: 系统权限不足或配置冲突。", None
lines: queue.Queue[str | None] = queue.Queue()
startup_done = [False]
@@ -521,8 +529,9 @@ def run_openvpn_until_ready(config_file: str, keep_alive: bool, route_nopull: bo
else:
message = f"OpenVPN timeout after {limit}s."
- if not ok and tail:
- message = tail[-1][-220:]
+ if not ok:
+ err_code, diag_msg = vpn_utils.diagnose_openvpn_failure(tail)
+ message = f"[错误代码 {err_code}] {diag_msg} (原始日志尾部: {tail[-1][-100:] if tail else '无'})"
startup_done[0] = True
if not keep_alive or not ok:
stop_process(process)
@@ -975,7 +984,11 @@ def maintain_valid_nodes(force: bool = False) -> str:
candidates = fetch_candidates()
except Exception as exc:
vpn_utils.check_and_fix_dns()
- set_state(last_fetch_at=time.time(), last_fetch_status="error", last_fetch_message=str(exc))
+ diag_msg = str(exc)
+ if not any(token in diag_msg for token in ["[ERR_", "错误代码"]):
+ err_code, raw_diag = vpn_utils.diagnose_api_failure(API_URL)
+ diag_msg = f"[错误代码 {err_code}] 获取节点失败: {exc} | 诊断结果: {raw_diag}"
+ set_state(last_fetch_at=time.time(), last_fetch_status="error", last_fetch_message=diag_msg)
candidates = []
if not candidates:
@@ -2709,11 +2722,7 @@ function render(){
pBadge.className = "badge unavailable";
pBadge.textContent = "不可用";
pIpVal.textContent = "-";
- if (state.last_check_message) {
- pLatVal.innerHTML = `${esc(state.last_check_message)}`;
- } else {
- pLatVal.innerHTML = `${esc(state.proxy_error || "连接失败")}`;
- }
+ pLatVal.innerHTML = `${esc(state.proxy_error || "连接失败")}`;
}
} else {
pBadge.className = "badge not_checked";
@@ -3303,9 +3312,11 @@ def check_proxy_health() -> dict[str, Any]:
try:
s.connect(("127.0.0.1", LOCAL_PROXY_PORT))
except Exception as e:
+ diag = vpn_utils.diagnose_local_obstructions(LOCAL_PROXY_PORT)
+ diag_msg = diag[1] if diag else f"端口 {LOCAL_PROXY_PORT} 连接失败,原因: {e}"
return {
"ok": False,
- "error": f"代理服务未运行 (端口 {LOCAL_PROXY_PORT} 连接失败,原因: {e})"
+ "error": f"代理服务未运行 ({diag_msg})"
}
finally:
try:
@@ -3318,7 +3329,7 @@ def check_proxy_health() -> dict[str, Any]:
if sys.platform.startswith("linux") and not tun_path.exists():
return {
"ok": False,
- "error": "VPN 虚拟网卡 (tun0) 未启用,请确保当前已成功连接 VPN 节点"
+ "error": "[错误代码 3004] [ERR_ROUTE_DEV_NOT_FOUND] VPN 虚拟网卡 (tun0) 未启用,请确保当前已成功连接 VPN 节点"
}
# 3. 使用 curl 通过本地 SOCKS5 代理接口测试 IP 与实际延迟
@@ -3353,7 +3364,12 @@ def check_proxy_health() -> dict[str, Any]:
result = _curl_check_ip("http://api.ipify.org")
if result:
return result
- return {"ok": False, "error": "出口连接测试失败 (ip.sb 和 api.ipify.org 均无法连通)"}
+
+ diag = vpn_utils.diagnose_local_obstructions(LOCAL_PROXY_PORT)
+ if diag:
+ return {"ok": False, "error": f"出口连接测试失败 | 本机诊断结果: {diag[1]}"}
+
+ return {"ok": False, "error": "出口连接测试失败 (ip.sb 和 api.ipify.org 均无法连通,可能是节点已失效或 VPS 防火墙限制了 UDP/TCP 出站端口)"}
except Exception as e:
return {"ok": False, "error": f"出口连接测试异常: {e}"}