From 9e766d81322fd4c7f209fc4543b6a150bea39bd4 Mon Sep 17 00:00:00 2001 From: RoyirinRosmontis Date: Thu, 10 Sep 2026 21:01:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?=20-=20=E8=8A=82=E7=82=B9=E8=8F=9C=E5=8D=95=E6=A0=87=E6=B3=A8?= =?UTF-8?q?=20IPv6=20=E4=B8=93=E7=94=A8=E8=8A=82=E7=82=B9=EF=BC=88nodes.js?= =?UTF-8?q?on=20=E7=9A=84=20ipv6=5Fonly=20=E5=AD=97=E6=AE=B5=EF=BC=89?= =?UTF-8?q?=EF=BC=9A=E6=9C=AC=E6=9C=BA=E6=9C=89=20IPv6=20=E5=87=BA?= =?UTF-8?q?=E5=8F=A3=E6=97=B6=E6=A0=87=20[IPv6]=EF=BC=8C=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E6=97=B6=E6=A0=87=20[IPv6=20=E4=B8=93=E7=94=A8=20=C2=B7=20?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=E4=B8=8D=E5=8F=AF=E7=94=A8]=20=E5=B9=B6?= =?UTF-8?q?=E7=BD=AE=E7=81=B0=E4=B8=8D=E5=8F=AF=E9=80=89=EF=BC=88=E5=B8=A6?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=20tooltip=EF=BC=89=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E9=80=89=E4=B8=AD=E5=90=8E=E8=BF=9E=E4=B8=8D=E4=B8=8A?= =?UTF-8?q?=20-=20=E6=96=B0=E5=A2=9E=20=5Flocal=5Fhas=5Fipv6()=EF=BC=88?= =?UTF-8?q?=E6=9F=A5=E6=9C=AC=E6=9C=BA=E5=85=A8=E5=B1=80=20IPv6=20?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=EF=BC=89/=5Fnode=5Freachable()=EF=BC=88ipv6?= =?UTF-8?q?=5Fonly=20=E8=8A=82=E7=82=B9=E7=9C=9F=E5=AE=9E=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=20IPv6=20=E8=BF=9E=E9=80=9A=E6=80=A7=EF=BC=8C?= =?UTF-8?q?=E6=8C=89=E5=9F=9F=E5=90=8D=E7=BC=93=E5=AD=98=EF=BC=89/=5Fnode?= =?UTF-8?q?=5Fipv6=5Fonly()=EF=BC=9B=5Freload=5Fnodes()=20=E6=97=B6?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E5=8F=AF=E8=BE=BE=E6=80=A7=E7=BC=93=E5=AD=98?= =?UTF-8?q?=20-=20=E5=BC=80=E5=90=AF=E4=BB=A3=E7=90=86=E6=97=B6=E8=8B=A5?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E8=8A=82=E7=82=B9=20IPv6=20=E4=B8=93?= =?UTF-8?q?=E7=94=A8=E4=B8=94=E6=9C=AC=E6=9C=BA=E6=97=A0=20IPv6=20?= =?UTF-8?q?=E5=87=BA=E5=8F=A3=EF=BC=8C=E7=9B=B4=E6=8E=A5=E6=8A=A5=E9=94=99?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E5=88=87=E6=8D=A2=E8=8A=82=E7=82=B9=EF=BC=88?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E5=A4=B1=E8=B4=A5=E6=96=AD?= =?UTF-8?q?=E7=BD=91=EF=BC=89=20-=20=E8=8A=82=E7=82=B9=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E6=8B=86=E5=87=BA=20=5Fbuild=5Fnode=5Fmenu()?= =?UTF-8?q?=20=E4=BE=BF=E4=BA=8E=E7=A6=BB=E5=B1=8F=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lite/proxy_lite.py | 92 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/lite/proxy_lite.py b/lite/proxy_lite.py index faff0ae..86b3d25 100644 --- a/lite/proxy_lite.py +++ b/lite/proxy_lite.py @@ -146,10 +146,76 @@ def _save_current_node(domain): data["current_node"] = str(domain or "") _settings_save(data) +# ── IPv6-only 节点可达性(客户端标注用)───────────────────── +# nodes.json 里 ipv6_only: true 的节点域名只有 AAAA 记录(无 A 记录), +# 本机没有 IPv6 出口时连不上。判定结果按域名缓存,避免每次开菜单都探测。 + +_IPV6_OK = {} # domain -> 本机能否连上该节点的 IPv6 地址 +_IPV6_PROBE_TIMEOUT = 4 # 单次 TCP 探测超时(秒) + +def _local_has_ipv6(): + """本机是否有全局 IPv6 地址(排除回环/链路本地/唯一本地地址)。 + 没有则不可能连上 IPv6-only 节点,直接短路,省去探测。""" + try: + import socket + import ipaddress + for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET6): + addr = info[4][0].split("%")[0] # 去掉 zone id(fe80::1%eth0) + try: + ip = ipaddress.ip_address(addr) + except ValueError: + continue + if ip.is_loopback or ip.is_link_local or ip.is_private: + continue + return True + except Exception: + return False + return False + +def _node_reachable(node): + """节点在本机是否可达:普通节点恒为 True;ipv6_only 节点需真的能建立 IPv6 连接。 + 结果按域名缓存(探测一次)。""" + if not isinstance(node, dict) or not node.get("ipv6_only"): + return True + domain = str(node.get("domain") or "").strip() + if not domain: + return False + if domain in _IPV6_OK: + return _IPV6_OK[domain] + ok = False + if _local_has_ipv6(): + import socket + try: + infos = socket.getaddrinfo(domain, None, socket.AF_INET6, socket.SOCK_STREAM) + for info in infos: + 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 + _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 可达性缓存作废 return _load_nodes() def _node_val(key, default=""): @@ -2289,6 +2355,9 @@ class MainWindow(QWidget): if _endpoint_domain() == "???": # 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网 raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置") + if not _node_reachable(_current_node()): + # 当前选中节点是 IPv6 专用而本机没有 IPv6 出口:转发的流量全部连不上 + raise RuntimeError("当前节点仅提供 IPv6 地址,本机没有 IPv6 出口,请在节点菜单切换其他节点") _traffic_reset() log("TLS 直连模式") ep = _endpoint_domain() @@ -2386,6 +2455,11 @@ class MainWindow(QWidget): "nodes.json 中没有可用节点。\n" "可在右下角「设置」中退出登录后重新登录获取节点配置。") return + menu = self._build_node_menu(nodes) + menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft())) + + def _build_node_menu(self, nodes): + """构建节点切换菜单:IPv6 专用节点加标注,本机无 IPv6 出口时置灰不可选。""" menu = QMenu(self) menu.setStyleSheet(f""" QMenu {{ @@ -2412,11 +2486,23 @@ class MainWindow(QWidget): text = f"{domain} ({country})" if remark and remark != domain: text += f" {remark}" + ipv6_only = _node_ipv6_only(n) + reachable = _node_reachable(n) + if ipv6_only: + text += " [IPv6]" if reachable else " [IPv6 专用 · 本机不可用]" if i == cur: text = "✓ " + text - menu.addAction(text).triggered.connect( - lambda _=False, idx=i: self._on_node_selected(idx)) - menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft())) + act = menu.addAction(text) + if not reachable: + # 本机无 IPv6 出口:置灰不可选,避免选中后连不上 + act.setEnabled(False) + act.setToolTip("该节点仅提供 IPv6 地址,当前网络没有 IPv6 出口,无法连接") + else: + if ipv6_only: + act.setToolTip("该节点仅提供 IPv6 地址(本机 IPv6 可用)") + act.triggered.connect( + lambda _=False, idx=i: self._on_node_selected(idx)) + return menu def _on_node_selected(self, idx): """切换节点:更新标题。"""