修复:

- IPv6 专用节点识别不到:服务端 subscription?method=rrpp_client 吐的是旧扁平格式,ipv6_only 字段被后端丢掉、只在 remark 里留 "(IPv6)"。判定改为三级:ipv6_only 字段 -> remark 含 IPv6 -> 域名解析兜底(解析结果只有 IPv6 无 IPv4)
- 可达性探测改用该节点自己的 HTTP 端口(原来固定按 443 探测),IPv6 地址直接取自解析结果,避免端口不对造成误判
- 本机无全局 IPv6 地址时判定不可达(菜单置灰,提示「不可用:本机无 IPv6」),有 IPv6 时只标注不禁用
- 节点可达性判定不再打印日志
This commit is contained in:
RoyirinRosmontis
2026-09-10 21:14:59 +08:00
parent 9e766d8132
commit c3286593a6
+73 -28
View File
@@ -147,10 +147,13 @@ def _save_current_node(domain):
_settings_save(data)
# ── IPv6-only 节点可达性(客户端标注用)─────────────────────
# nodes.json 里 ipv6_only: true 的节点域名只有 AAAA 记录(无 A 记录),
# 本机没有 IPv6 出口时连不上。判定结果按域名缓存,避免每次开菜单都探测。
# IPv6-only 节点的域名只有 AAAA 记录(无 A 记录),本机没有 IPv6 出口时连不上。
# 判定三级:ipv6_only 字段 → 服务端 remark 里的 "(IPv6)" 标记 → 域名解析兜底。
# 服务端 subscription?method=rrpp_client 只吐旧扁平格式,ipv6_only 字段被丢掉,
# 只留 remark 里的 "(IPv6)"(见后端 countries_to_rrpp_client),故第 2 级是现网主路径。
_IPV6_OK = {} # domain -> 本机能否连上该节点的 IPv6 地址
_IPV6_ONLY = {} # domain -> 该域名是否只有 IPv6 地址(解析兜底)
_IPV6_PROBE_TIMEOUT = 4 # 单次 TCP 探测超时(秒)
def _local_has_ipv6():
@@ -172,50 +175,92 @@ def _local_has_ipv6():
return False
return False
def _domain_ipv6_only(domain: str) -> bool:
"""域名是否「只有 IPv6 地址」:解析结果里有 IPv6 且没有 IPv4 才算。
解析失败按 False(当普通节点处理,不误伤)。结果缓存。"""
if domain in _IPV6_ONLY:
return _IPV6_ONLY[domain]
import ipaddress
only = False
try:
addrs = _resolve_host_ips(domain)
if addrs:
has6 = any(ip.version == 6 for ip in addrs)
has4 = any(ip.version == 4 for ip in addrs)
only = has6 and not has4
except Exception:
only = False
_IPV6_ONLY[domain] = only
if only:
log(f"节点 {domain} 解析结果只有 IPv6 地址,按 IPv6 专用节点处理")
return only
def _node_ipv6_only(node) -> bool:
"""节点是否 IPv6 专用。
三级判定:
1. ipv6_only 字段(服务端新格式;rrpp_client 输出里没有)
2. remark 里的 "(IPv6)" 标记(rrpp_client 现网唯一的权威线索)
3. 域名解析兜底:解析结果只有 IPv6 地址、没有 IPv4
"""
if not isinstance(node, dict):
return False
if node.get("ipv6_only"):
return True
if "ipv6" in str(node.get("remark") or "").lower():
return True
return _domain_ipv6_only(str(node.get("domain") or "").strip())
def _node_http_port(node) -> int:
"""节点的 HTTP 代理端口(不依赖当前选中节点,供可达性探测用)。"""
m = (node.get("methods") or {}).get("http") or {}
for v in (m.get("port"), node.get("port")):
if v not in (None, ""):
try:
return int(v)
except (TypeError, ValueError):
pass
return 443
def _node_reachable(node):
"""节点在本机是否可达:普通节点恒为 True;ipv6_only 节点需真的能建立 IPv6 连接。
结果按域名缓存(探测一次)。"""
if not isinstance(node, dict) or not node.get("ipv6_only"):
"""节点在本机是否可达:普通节点恒为 True;IPv6 专用节点需真的能建立 IPv6 连接
(按该节点自己的 HTTP 端口探测)。结果按域名缓存(探测一次)。"""
if not isinstance(node, dict):
return True
domain = str(node.get("domain") or "").strip()
if not _node_ipv6_only(node):
return True
if not domain:
return False
if domain in _IPV6_OK:
return _IPV6_OK[domain]
port = _node_http_port(node)
ok = False
if _local_has_ipv6():
addrs = sorted((ip for ip in _resolve_host_ips(domain) if ip.version == 6), key=str)
if addrs and _local_has_ipv6():
import socket
try:
infos = socket.getaddrinfo(domain, None, socket.AF_INET6, socket.SOCK_STREAM)
for info in infos:
for ip in addrs:
try:
s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
s.settimeout(_IPV6_PROBE_TIMEOUT)
s.connect((str(ip), port))
s.close()
ok = True
break
except Exception:
try:
s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
s.settimeout(_IPV6_PROBE_TIMEOUT)
s.connect(info[4][:2])
s.close()
ok = True
break
except Exception:
try:
s.close()
except Exception:
pass
except Exception:
ok = False
pass
_IPV6_OK[domain] = ok
log(f"节点 {domain} 为 IPv6 专用:本机 IPv6 "
f"{'可用' if ok else '不可用'},{'可正常使用' if ok else '已标注为不可用'}")
return ok
def _node_ipv6_only(node):
"""节点是否标记为 IPv6 专用(可选字段,缺省视为 False)。"""
return bool(isinstance(node, dict) and node.get("ipv6_only"))
def _reload_nodes():
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
global _NODES
_NODES = None
_IPV6_OK.clear() # 节点列表变了,IPv6 可达性缓存作废
_IPV6_OK.clear() # 节点列表变了,IPv6 可达性/专用判定缓存作废
_IPV6_ONLY.clear()
return _load_nodes()
def _node_val(key, default=""):
@@ -2496,7 +2541,7 @@ class MainWindow(QWidget):
if not reachable:
# 本机无 IPv6 出口:置灰不可选,避免选中后连不上
act.setEnabled(False)
act.setToolTip("该节点仅提供 IPv6 地址,当前网络没有 IPv6 出口,无法连接")
act.setToolTip("不可用:本机无 IPv6")
else:
if ipv6_only:
act.setToolTip("该节点仅提供 IPv6 地址(本机 IPv6 可用)")