diff --git a/lite/proxy_lite.py b/lite/proxy_lite.py index 86b3d25..5fa20ba 100644 --- a/lite/proxy_lite.py +++ b/lite/proxy_lite.py @@ -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 可用)")