From c3286593a68a697598a005a3c618567cb792450b Mon Sep 17 00:00:00 2001 From: RoyirinRosmontis Date: Thu, 10 Sep 2026 21:14:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=20-=20IPv6=20?= =?UTF-8?q?=E4=B8=93=E7=94=A8=E8=8A=82=E7=82=B9=E8=AF=86=E5=88=AB=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=EF=BC=9A=E6=9C=8D=E5=8A=A1=E7=AB=AF=20subscription=3F?= =?UTF-8?q?method=3Drrpp=5Fclient=20=E5=90=90=E7=9A=84=E6=98=AF=E6=97=A7?= =?UTF-8?q?=E6=89=81=E5=B9=B3=E6=A0=BC=E5=BC=8F=EF=BC=8Cipv6=5Fonly=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E8=A2=AB=E5=90=8E=E7=AB=AF=E4=B8=A2=E6=8E=89?= =?UTF-8?q?=E3=80=81=E5=8F=AA=E5=9C=A8=20remark=20=E9=87=8C=E7=95=99=20"(I?= =?UTF-8?q?Pv6)"=E3=80=82=E5=88=A4=E5=AE=9A=E6=94=B9=E4=B8=BA=E4=B8=89?= =?UTF-8?q?=E7=BA=A7=EF=BC=9Aipv6=5Fonly=20=E5=AD=97=E6=AE=B5=20->=20remar?= =?UTF-8?q?k=20=E5=90=AB=20IPv6=20->=20=E5=9F=9F=E5=90=8D=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=85=9C=E5=BA=95=EF=BC=88=E8=A7=A3=E6=9E=90=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=8F=AA=E6=9C=89=20IPv6=20=E6=97=A0=20IPv4=EF=BC=89?= =?UTF-8?q?=20-=20=E5=8F=AF=E8=BE=BE=E6=80=A7=E6=8E=A2=E6=B5=8B=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E8=AF=A5=E8=8A=82=E7=82=B9=E8=87=AA=E5=B7=B1=E7=9A=84?= =?UTF-8?q?=20HTTP=20=E7=AB=AF=E5=8F=A3=EF=BC=88=E5=8E=9F=E6=9D=A5?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E6=8C=89=20443=20=E6=8E=A2=E6=B5=8B=EF=BC=89?= =?UTF-8?q?=EF=BC=8CIPv6=20=E5=9C=B0=E5=9D=80=E7=9B=B4=E6=8E=A5=E5=8F=96?= =?UTF-8?q?=E8=87=AA=E8=A7=A3=E6=9E=90=E7=BB=93=E6=9E=9C=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E7=AB=AF=E5=8F=A3=E4=B8=8D=E5=AF=B9=E9=80=A0=E6=88=90?= =?UTF-8?q?=E8=AF=AF=E5=88=A4=20-=20=E6=9C=AC=E6=9C=BA=E6=97=A0=E5=85=A8?= =?UTF-8?q?=E5=B1=80=20IPv6=20=E5=9C=B0=E5=9D=80=E6=97=B6=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E4=B8=8D=E5=8F=AF=E8=BE=BE=EF=BC=88=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E7=BD=AE=E7=81=B0=EF=BC=8C=E6=8F=90=E7=A4=BA=E3=80=8C=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=EF=BC=9A=E6=9C=AC=E6=9C=BA=E6=97=A0=20IPv6?= =?UTF-8?q?=E3=80=8D=EF=BC=89=EF=BC=8C=E6=9C=89=20IPv6=20=E6=97=B6?= =?UTF-8?q?=E5=8F=AA=E6=A0=87=E6=B3=A8=E4=B8=8D=E7=A6=81=E7=94=A8=20-=20?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=8F=AF=E8=BE=BE=E6=80=A7=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=89=93=E5=8D=B0=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lite/proxy_lite.py | 101 ++++++++++++++++++++++++++++++++------------- 1 file changed, 73 insertions(+), 28 deletions(-) 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 可用)")