feat: implement comprehensive error diagnostics and structured error codes for VPN connectivity, API fetching, and proxy initialization.

This commit is contained in:
baoweise-bot
2026-05-30 14:02:39 +08:00
parent 531beca616
commit 7b9248c4df
4 changed files with 638 additions and 440 deletions
+2 -1
View File
@@ -436,7 +436,8 @@ def print_status():
print_line(format_line("出口 IP (出站)", proxy_ip)) print_line(format_line("出口 IP (出站)", proxy_ip))
print_line(format_line("本地代理延迟", f"{proxy_latency} ms" if proxy_latency else "检测中...")) print_line(format_line("本地代理延迟", f"{proxy_latency} ms" if proxy_latency else "检测中..."))
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: else:
print_line(format_line("节点状态", "无活动连接")) print_line(format_line("节点状态", "无活动连接"))
print_line() print_line()
+13 -2
View File
@@ -51,7 +51,11 @@ def resolve_dns_over_tun0(host: str, dns_server: str = "8.8.8.8", timeout: float
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")
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 return None
sock.sendto(packet, (dns_server, 53)) sock.sendto(packet, (dns_server, 53))
resp, _ = sock.recvfrom(2048) resp, _ = sock.recvfrom(2048)
@@ -131,6 +135,10 @@ def create_connection(address: tuple[str, int], timeout: float = 20) -> socket.s
return sock return sock
except OSError as e: except OSError as e:
err = 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: if sock is not None:
sock.close() sock.close()
if err is not None: if err is not None:
@@ -255,7 +263,10 @@ 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:
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 return
while True: while True:
+170
View File
@@ -420,3 +420,173 @@ def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
node["location"] = cached.get("location", "") node["location"] = cached.get("location", "")
node["ip_type"] = cached.get("ip_type", "") node["ip_type"] = cached.get("ip_type", "")
node["quality"] = cached.get("quality", "") 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
+31 -15
View File
@@ -333,8 +333,16 @@ def fetch_candidates() -> list[dict[str, Any]]:
print(f"[fetch_candidates] Fetch {i+1} failed: {e}", flush=True) print(f"[fetch_candidates] Fetch {i+1} failed: {e}", flush=True)
log_to_json("WARNING", "Main", f"{i+1} 次拉取 API 节点失败: {e}") log_to_json("WARNING", "Main", f"{i+1} 次拉取 API 节点失败: {e}")
if i == max_attempts - 1 and not candidates: if i == max_attempts - 1 and not candidates:
log_to_json("ERROR", "Main", f"获取官方 API 节点失败: {e}") err_code, diag_msg = vpn_utils.diagnose_api_failure(API_URL)
raise 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( set_state(
last_fetch_at=time.time(), 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), cwd=str(ROOT_DIR),
) )
except FileNotFoundError: 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: 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() lines: queue.Queue[str | None] = queue.Queue()
startup_done = [False] startup_done = [False]
@@ -521,8 +529,9 @@ def run_openvpn_until_ready(config_file: str, keep_alive: bool, route_nopull: bo
else: else:
message = f"OpenVPN timeout after {limit}s." message = f"OpenVPN timeout after {limit}s."
if not ok and tail: if not ok:
message = tail[-1][-220:] 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 startup_done[0] = True
if not keep_alive or not ok: if not keep_alive or not ok:
stop_process(process) stop_process(process)
@@ -975,7 +984,11 @@ def maintain_valid_nodes(force: bool = False) -> str:
candidates = fetch_candidates() candidates = fetch_candidates()
except Exception as exc: except Exception as exc:
vpn_utils.check_and_fix_dns() 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 = [] candidates = []
if not candidates: if not candidates:
@@ -2709,11 +2722,7 @@ function render(){
pBadge.className = "badge unavailable"; pBadge.className = "badge unavailable";
pBadge.textContent = "不可用"; pBadge.textContent = "不可用";
pIpVal.textContent = "-"; pIpVal.textContent = "-";
if (state.last_check_message) { pLatVal.innerHTML = `<span class="latency-val latency-poor" style="margin-left:8px; font-size:11px; max-width: 450px; display: inline-block; white-space: normal; line-height: 1.4; text-align: left;" title="${esc(state.proxy_error)}">${esc(state.proxy_error || "连接失败")}</span>`;
pLatVal.innerHTML = `<span style="color: var(--text-secondary); font-size: 12px;">${esc(state.last_check_message)}</span>`;
} else {
pLatVal.innerHTML = `<span class="latency-val latency-poor" style="margin-left:8px; font-size:11px;" title="${esc(state.proxy_error)}">${esc(state.proxy_error || "连接失败")}</span>`;
}
} }
} else { } else {
pBadge.className = "badge not_checked"; pBadge.className = "badge not_checked";
@@ -3303,9 +3312,11 @@ def check_proxy_health() -> dict[str, Any]:
try: try:
s.connect(("127.0.0.1", LOCAL_PROXY_PORT)) s.connect(("127.0.0.1", LOCAL_PROXY_PORT))
except Exception as e: 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 { return {
"ok": False, "ok": False,
"error": f"代理服务未运行 (端口 {LOCAL_PROXY_PORT} 连接失败,原因: {e})" "error": f"代理服务未运行 ({diag_msg})"
} }
finally: finally:
try: try:
@@ -3318,7 +3329,7 @@ def check_proxy_health() -> dict[str, Any]:
if sys.platform.startswith("linux") and not tun_path.exists(): if sys.platform.startswith("linux") and not tun_path.exists():
return { return {
"ok": False, "ok": False,
"error": "VPN 虚拟网卡 (tun0) 未启用,请确保当前已成功连接 VPN 节点" "error": "[错误代码 3004] [ERR_ROUTE_DEV_NOT_FOUND] VPN 虚拟网卡 (tun0) 未启用,请确保当前已成功连接 VPN 节点"
} }
# 3. 使用 curl 通过本地 SOCKS5 代理接口测试 IP 与实际延迟 # 3. 使用 curl 通过本地 SOCKS5 代理接口测试 IP 与实际延迟
@@ -3353,7 +3364,12 @@ def check_proxy_health() -> dict[str, Any]:
result = _curl_check_ip("http://api.ipify.org") result = _curl_check_ip("http://api.ipify.org")
if result: if result:
return 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: except Exception as e:
return {"ok": False, "error": f"出口连接测试异常: {e}"} return {"ok": False, "error": f"出口连接测试异常: {e}"}