新增功能:
- 节点菜单标注 IPv6 专用节点(nodes.json 的 ipv6_only 字段):本机有 IPv6 出口时标 [IPv6],没有时标 [IPv6 专用 · 本机不可用] 并置灰不可选(带原因 tooltip),避免选中后连不上 - 新增 _local_has_ipv6()(查本机全局 IPv6 地址)/_node_reachable()(ipv6_only 节点真实探测 IPv6 连通性,按域名缓存)/_node_ipv6_only();_reload_nodes() 时清空可达性缓存 - 开启代理时若当前节点 IPv6 专用且本机无 IPv6 出口,直接报错提示切换节点(不再静默失败断网) - 节点菜单构建拆出 _build_node_menu() 便于离屏测试
This commit is contained in:
+88
-2
@@ -146,10 +146,76 @@ def _save_current_node(domain):
|
|||||||
data["current_node"] = str(domain or "")
|
data["current_node"] = str(domain or "")
|
||||||
_settings_save(data)
|
_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():
|
def _reload_nodes():
|
||||||
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
|
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
|
||||||
global _NODES
|
global _NODES
|
||||||
_NODES = None
|
_NODES = None
|
||||||
|
_IPV6_OK.clear() # 节点列表变了,IPv6 可达性缓存作废
|
||||||
return _load_nodes()
|
return _load_nodes()
|
||||||
|
|
||||||
def _node_val(key, default=""):
|
def _node_val(key, default=""):
|
||||||
@@ -2289,6 +2355,9 @@ class MainWindow(QWidget):
|
|||||||
if _endpoint_domain() == "???":
|
if _endpoint_domain() == "???":
|
||||||
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
|
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
|
||||||
raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置")
|
raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置")
|
||||||
|
if not _node_reachable(_current_node()):
|
||||||
|
# 当前选中节点是 IPv6 专用而本机没有 IPv6 出口:转发的流量全部连不上
|
||||||
|
raise RuntimeError("当前节点仅提供 IPv6 地址,本机没有 IPv6 出口,请在节点菜单切换其他节点")
|
||||||
_traffic_reset()
|
_traffic_reset()
|
||||||
log("TLS 直连模式")
|
log("TLS 直连模式")
|
||||||
ep = _endpoint_domain()
|
ep = _endpoint_domain()
|
||||||
@@ -2386,6 +2455,11 @@ class MainWindow(QWidget):
|
|||||||
"nodes.json 中没有可用节点。\n"
|
"nodes.json 中没有可用节点。\n"
|
||||||
"可在右下角「设置」中退出登录后重新登录获取节点配置。")
|
"可在右下角「设置」中退出登录后重新登录获取节点配置。")
|
||||||
return
|
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 = QMenu(self)
|
||||||
menu.setStyleSheet(f"""
|
menu.setStyleSheet(f"""
|
||||||
QMenu {{
|
QMenu {{
|
||||||
@@ -2412,11 +2486,23 @@ class MainWindow(QWidget):
|
|||||||
text = f"{domain} ({country})"
|
text = f"{domain} ({country})"
|
||||||
if remark and remark != domain:
|
if remark and remark != domain:
|
||||||
text += f" {remark}"
|
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:
|
if i == cur:
|
||||||
text = "✓ " + text
|
text = "✓ " + text
|
||||||
menu.addAction(text).triggered.connect(
|
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))
|
lambda _=False, idx=i: self._on_node_selected(idx))
|
||||||
menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft()))
|
return menu
|
||||||
|
|
||||||
def _on_node_selected(self, idx):
|
def _on_node_selected(self, idx):
|
||||||
"""切换节点:更新标题。"""
|
"""切换节点:更新标题。"""
|
||||||
|
|||||||
Reference in New Issue
Block a user