diff --git a/.gitignore b/.gitignore index 0f4c7f8..e80d447 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ lite/.claude/ lite/backups/ lite/build/ lite/dist/ +lite/RELEASE_WORKFLOW.md +*.bak +*.bak-* diff --git a/lite/config.json b/lite/config.json index 8004d5d..5b6764f 100644 --- a/lite/config.json +++ b/lite/config.json @@ -1,5 +1,5 @@ { - "version": "999909171702", + "version": "999909181435", "pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/", "nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client", "update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest" diff --git a/lite/main.py b/lite/main.py index e260cf7..58cd7ae 100644 --- a/lite/main.py +++ b/lite/main.py @@ -15,7 +15,7 @@ from rrpp_ui.theme import C_TITLE, C_GREEN, C_YELLOW, C_RED, C_BG, C_CARD, C_BOR from rrpp_ui.widgets import NodeLabel, StatusDot, ToggleSwitch from rrpp_ui.dialogs import ( LoginDialog, LoginServices, SettingsDialog, SettingsServices, ThirdPartyDialog, ThirdPartyServices, - WhitelistDialog, WhitelistServices, + WhitelistDialog, WhitelistServices, JumpDialog, JumpServices, ) from rrpp_core.routing_primitives import ( build_suffix_trie as _build_suffix_trie, @@ -185,6 +185,8 @@ def _tag_official_nodes(nodes): def _node_reference(node): """选中节点的稳定标识,避免不同订阅的同域名互相覆盖。""" + if isinstance(node, dict) and node.get("_rrpp_source_kind") == "jump": + return "jump|" + str(node.get("_jump_profile_id") or "") method = (node.get("methods") or {}).get("http") or {} port = method.get("port", node.get("port", "")) return "|".join(( @@ -210,7 +212,8 @@ def _load_nodes(): except Exception as error: log_red(f"官方节点配置读取失败: {error}") third_party = _subscription_nodes(_load_third_party_subscriptions()) - _NODES = official + third_party + base_nodes = official + third_party + _NODES = base_nodes + _jump_virtual_nodes(base_nodes) if not _SELECTED_RESTORED: _restore_selected_node(_NODES) if _NODES: @@ -348,6 +351,12 @@ def _restore_selected_node(nodes): if saved_ref and _node_reference(n) == saved_ref: _SELECTED_NODE = i return + if saved_ref.startswith("jump|"): + # 跳板已失效时不能按其出口域名回退,否则可能无提示地改为直连出口节点。 + _SELECTED_NODE = _first_reachable_index(nodes) + if nodes: + _save_current_node(nodes[_SELECTED_NODE]) + return for i, n in enumerate(nodes): if saved and str(n.get("domain") or "") == saved: _SELECTED_NODE = i @@ -361,6 +370,207 @@ def _save_current_node(node): data["current_node_ref"] = _node_reference(node or {}) _settings_save(data) + +_JUMP_MAX_HOPS = 3 + + +def _roman_number(value): + """将跳板序号转换为主页面使用的罗马数字。""" + try: + value = int(value) + except (TypeError, ValueError): + return str(value) + if value <= 0: + return str(value) + result = [] + for number, numeral in ( + (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), + (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), + (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"), + ): + count, value = divmod(value, number) + result.append(numeral * count) + return "".join(result) + + +def _jump_node_reference(node): + """跳板专用稳定标识;备注、国家和凭据更新不应改变节点身份。""" + source = str(node.get("_rrpp_source_id") or "official") + domain = str(node.get("domain") or "").strip().rstrip(".").lower() + return f"jump-node-v2|{source}|{domain}|{_node_http_port(node)}" + + +def _jump_reference_key(ref): + """兼容旧版 source|domain|port|country|remark 引用。""" + parts = str(ref or "").split("|", 4) + if parts[0] == "jump-node-v2": + if len(parts) != 4: + return None + source, domain, port = parts[1:] + elif len(parts) >= 3: + source, domain, port = parts[:3] + else: + return None + try: + port = int(port) if port else 443 + except ValueError: + return None + if not source or not domain or not 1 <= port <= 65535: + return None + return f"jump-node-v2|{source}|{domain.strip().rstrip('.').lower()}|{port}" + + +def _jump_profile_records(): + """读取跳板配置;只保存节点稳定标识,不保存节点凭据。""" + raw = _settings_load().get("jump_profiles") + if not isinstance(raw, list): + return [] + result = [] + for profile in raw: + if not isinstance(profile, dict): + continue + profile_id = str(profile.get("id") or "").strip() + refs = profile.get("node_refs") + if profile_id and isinstance(refs, list): + result.append({"id": profile_id, "node_refs": [str(ref) for ref in refs if str(ref).strip()]}) + return result + + +def _jump_profile_save(records): + data = _settings_load() + data["jump_profiles"] = [ + {"id": str(record["id"]), "node_refs": list(record["node_refs"])} + for record in records + ] + _settings_save(data) + + +def _jump_chain(node): + """返回虚拟跳板节点对应的实际节点链,普通节点返回空列表。""" + chain = (node or {}).get("_jump_chain") if isinstance(node, dict) else None + return list(chain) if isinstance(chain, list) else [] + + +def _terminal_node(node): + chain = _jump_chain(node) + return chain[-1] if chain else (node or {}) + + +def _jump_route_text(node): + chain = _jump_chain(node) + return "→".join(str(item.get("country") or "?") for item in chain) if chain else "" + + +def _jump_resolve_chain(refs, base_nodes): + """优先精确匹配旧引用;备注变更后仅在稳定标识唯一时回退匹配。""" + if not 2 <= len(refs) <= _JUMP_MAX_HOPS: + return None + by_old, by_stable = {}, {} + for node in base_nodes: + by_old.setdefault(_node_reference(node), []).append(node) + by_stable.setdefault(_jump_node_reference(node), []).append(node) + chain = [] + for ref in refs: + exact = by_old.get(ref, []) + candidates = exact if len(exact) == 1 else by_stable.get(_jump_reference_key(ref), []) + if len(candidates) != 1: + return None + chain.append(candidates[0]) + if len({_jump_node_reference(node) for node in chain}) != len(chain): + return None + return chain + + +def _jump_virtual_nodes(base_nodes): + """将设置中的有效跳板配置映射为可切换的虚拟节点。""" + result = [] + for index, profile in enumerate(_jump_profile_records(), 1): + chain = _jump_resolve_chain(profile["node_refs"], base_nodes) + if chain is None: + continue + final = chain[-1] + result.append({ + "_rrpp_source_id": f"jump:{profile['id']}", + "_rrpp_source_name": "代理跳板", + "_rrpp_source_kind": "jump", + "_jump_profile_id": profile["id"], + "_jump_name": f"自定义跳板 {_roman_number(index)}", + "_jump_chain": chain, + "domain": str(final.get("domain") or ""), + "port": final.get("port"), + "country": _jump_route_text({"_jump_chain": chain}), + "remark": "多跳链路", + }) + return result + + +def _jump_summaries(): + """供跳板管理 UI 使用,不暴露节点用户名或密码。""" + base_nodes = [node for node in _load_nodes() if node.get("_rrpp_source_kind") != "jump"] + result = [] + for index, profile in enumerate(_jump_profile_records(), 1): + chain = _jump_resolve_chain(profile["node_refs"], base_nodes) + available = chain is not None and all(_node_reachable(node) for node in chain) + route = "→".join(str(node.get("country") or "?") for node in chain) if chain else "" + result.append({ + "id": profile["id"], + "name": f"自定义跳板 {_roman_number(index)}", + "route": route or "节点已失效", + "available": available, + }) + return result + + +def _jump_available_nodes(): + """返回可供配置的真实节点摘要和稳定标识。""" + base_nodes = [node for node in _load_nodes() if node.get("_rrpp_source_kind") != "jump"] + counts = {} + for node in base_nodes: + ref = _jump_node_reference(node) + counts[ref] = counts.get(ref, 0) + 1 + result = [] + for node in base_nodes: + ref = _jump_node_reference(node) + if counts[ref] != 1 or not _node_reachable(node): + continue + result.append({ + "ref": ref, + "label": f"{node.get('country') or '?'} {node.get('domain') or '???'}:{_node_http_port(node)}", + }) + return result + + +def _add_jump_profile(node_refs): + refs = [str(ref).strip() for ref in node_refs if str(ref).strip()] + if not 2 <= len(refs) <= _JUMP_MAX_HOPS: + raise ValueError(f"跳板链路必须包含 2-{_JUMP_MAX_HOPS} 个节点") + refs = [_jump_reference_key(ref) for ref in refs] + if len(set(refs)) != len(refs): + raise ValueError("同一条链路不能重复使用节点") + available = {node["ref"] for node in _jump_available_nodes()} + if any(ref not in available for ref in refs): + raise ValueError("链路中包含当前不可用的节点") + records = _jump_profile_records() + if any([_jump_reference_key(ref) for ref in record["node_refs"]] == refs for record in records): + raise ValueError("该跳板链路已存在") + records.append({"id": uuid.uuid4().hex, "node_refs": refs}) + _jump_profile_save(records) + _reload_nodes() + log(f"代理跳板已添加: {'→'.join(refs)}") + + +def _remove_jump_profile(profile_id): + profile_id = str(profile_id or "") + if (_tls_forwarder and + _node_reference(_current_node() or {}) == f"jump|{profile_id}"): + raise ValueError("代理运行中,请先关闭代理再删除当前跳板") + records = _jump_profile_records() + remaining = [record for record in records if record["id"] != profile_id] + if len(remaining) != len(records): + _jump_profile_save(remaining) + _reload_nodes() + log("代理跳板已删除") + # ── IPv6-only 节点可达性(客户端标注用)───────────────────── # IPv6-only 节点的域名只有 AAAA 记录(无 A 记录),本机没有 IPv6 出口时连不上。 # 判定三级:ipv6_only 字段 → 服务端 remark 里的 "(IPv6)" 标记 → 域名解析兜底。 @@ -442,6 +652,8 @@ def _node_reachable(node): (按该节点自己的 HTTP 端口探测)。结果按域名缓存(探测一次)。""" if not isinstance(node, dict): return True + if node.get("_rrpp_source_kind") == "jump": + return all(_node_reachable(item) for item in _jump_chain(node)) domain = str(node.get("domain") or "").strip() if not _node_ipv6_only(node): return True @@ -472,11 +684,31 @@ def _node_reachable(node): def _reload_nodes(): """强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。""" - global _NODES + global _NODES, _SELECTED_NODE _NODES = None _IPV6_OK.clear() # 节点列表变了,IPv6 可达性/专用判定缓存作废 _IPV6_ONLY.clear() - return _load_nodes() + nodes = _load_nodes() + if not nodes: + _SELECTED_NODE = 0 + return nodes + # 跳板增删会改变虚拟节点下标;优先按稳定标识恢复,避免删除当前跳板后 + # 误把同一个下标解释成另一条链路。 + settings = _settings_load() + saved_ref = str(settings.get("current_node_ref") or "") + if saved_ref: + match = next((i for i, node in enumerate(nodes) if _node_reference(node) == saved_ref), None) + if match is not None: + _SELECTED_NODE = match + return nodes + # 原节点(包括被删除的跳板)已不存在,旧下标不可再用于新列表。 + _SELECTED_NODE = _first_reachable_index(nodes) + _save_current_node(nodes[_SELECTED_NODE]) + return nodes + if not 0 <= _SELECTED_NODE < len(nodes): + _SELECTED_NODE = 0 + _save_current_node(nodes[0]) + return nodes def _first_reachable_index(nodes): """第一个本机可达的节点下标;都不可达返回 0。供选中不可达节点时回退。""" @@ -541,7 +773,7 @@ def _node_val(key, default=""): return v if v not in (None, "") else default # 空字符串视为未填写 def _endpoint_domain(): - return _node_val("domain", "???") + return str(_terminal_node(_current_node()).get("domain") or "???") def _http_method(): """节点 HTTP 代理方法参数(新版 nodes.json 的 methods.http)。 @@ -549,7 +781,7 @@ def _http_method(): 返回 (node, get):get(key, default) 优先取 methods.http 下的键, 缺失时回退节点顶层——兼容旧扁平结构(port/username/password 直接挂在节点上)。 """ - node = _current_node() or {} + node = _terminal_node(_current_node()) m = (node.get("methods") or {}).get("http") or {} def get(key, default=""): @@ -569,11 +801,15 @@ def _endpoint_port(): return 443 def _endpoint_country(): - return _node_val("country", "") + node = _current_node() or {} + return _jump_route_text(node) or str(node.get("country") or "") def _endpoint_label(): """主界面节点副标题;无节点时给出可操作的提示。""" + node = _current_node() or {} + if node.get("_rrpp_source_kind") == "jump": + return f"{node.get('_jump_name') or '自定义跳板'} ({_endpoint_country()})" domain = _endpoint_domain() if domain == "???": return "无可用节点(请登录)" @@ -894,7 +1130,8 @@ def _traffic_rate(): def _traffic_snapshot(): return _TRAFFIC.snapshot() -def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, smart=False, quiet=False): +def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, smart=False, quiet=False, + jump_chain=None): """启动本地认证转发器:127.0.0.1: -> remote_host:remote_port(可选 TLS 包裹)。 smart=True 时启用 IP 智能分流(域名表/非中国 IP 走隧道,中国 IP 直连)。 @@ -935,7 +1172,11 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma while _tls_forwarder and _tls_forwarder.get("running"): try: client, addr = svr.accept() - threading.Thread(target=_tls_relay, args=(client, remote_host, remote_port, ctx, use_tls, bypass_lan, smart), daemon=True).start() + threading.Thread( + target=_tls_relay, + args=(client, remote_host, remote_port, ctx, use_tls, bypass_lan, smart, jump_chain), + daemon=True, + ).start() except socket.timeout: continue except Exception: @@ -944,7 +1185,9 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma t = threading.Thread(target=_run, daemon=True) t.start() if not quiet: - log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port})") + route = "→".join(str(node.get("country") or "?") for node in (jump_chain or [])) + suffix = f" [{route}]" if route else "" + log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port}{suffix})") def _inject_proxy_auth(data: bytes) -> bytes: """在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。 @@ -956,6 +1199,9 @@ def _inject_proxy_auth(data: bytes) -> bytes: Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行, 保证上游(取首个 Authorization)读到的是我们的认证。 """ + node, _ = _http_method() + if not any(_node_proxy_credentials(node)): + return data return _inject_proxy_auth_value(data, _proxy_auth_basic()) def _make_request_injector(): @@ -1574,8 +1820,183 @@ def _is_smart_china_direct(host: str, bypass_lan: bool, smart: bool) -> bool: and not (bypass_lan and _is_private_ip(host)) and _resolve_china_direct_ip(host) is not None) +def _node_proxy_credentials(node): + method = (node.get("methods") or {}).get("http") or {} + username = method.get("username") + password = method.get("password") + if username in (None, ""): + username = node.get("username", "") + if password in (None, ""): + password = node.get("password", "") + return str(username or ""), str(password or "") + + +def _resolve_chain_address(host, port): + """为跳板 CONNECT 解析 IP;CONNECT 行只使用 IP,不暴露节点域名。""" + import socket + infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM) + infos.sort(key=lambda item: 0 if item[0] == socket.AF_INET else 1) + if not infos: + raise OSError("跳板地址解析失败") + return infos[0][4] + + +def _http_connect_over(transport, proxy_node, target_host, target_port): + """在已有 TLS/分层 TLS 传输上建立 CONNECT,目标行仅含解析后的 IP。""" + import base64 + username, password = _node_proxy_credentials(proxy_node) + auth_header = "" + if username or password: + token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii") + auth_header = f"Authorization: Basic {token}\r\n" + target = _resolve_chain_address(target_host, target_port) + target_authority = f"[{target[0]}]:{target[1]}" if ":" in target[0] else f"{target[0]}:{target[1]}" + request = ( + f"CONNECT {target_authority} HTTP/1.1\r\n" + f"Host: {target_authority}\r\n" + f"{auth_header}" + "Connection: Keep-Alive\r\n\r\n" + ).encode("ascii") + transport.sendall(request) + response = b"" + while b"\r\n\r\n" not in response and len(response) < 65536: + chunk = transport.recv(4096) + if not chunk: + break + response += chunk + marker = b"\r\n\r\n" + end = response.find(marker) + first_line = response.split(b"\r\n", 1)[0].decode("latin-1", "replace") + if not (first_line.startswith("HTTP/1.1 200") or first_line.startswith("HTTP/1.0 200")): + raise RuntimeError(f"跳板 CONNECT 失败: {first_line[:80]}") + return response[end + len(marker):] if end >= 0 else b"" + + +class _LayeredTlsTransport: + """在已有 TLS 字节流上承载下一层 TLS,保留 socket-like 转发接口。""" + + def __init__(self, transport, ctx, server_hostname, initial=b""): + import ssl + self._transport = transport + self._incoming = ssl.MemoryBIO() + self._outgoing = ssl.MemoryBIO() + self._pending = initial + self._ssl = ctx.wrap_bio( + self._incoming, self._outgoing, server_side=False, + server_hostname=server_hostname) + self._handshake() + + def _flush(self): + data = self._outgoing.read() + if data: + self._transport.sendall(data) + + def _feed(self): + if self._pending: + data, self._pending = self._pending, b"" + else: + data = self._transport.recv(16384) + if not data: + raise ConnectionError("分层 TLS 下层连接已关闭") + self._incoming.write(data) + + def _handshake(self): + import ssl + while True: + try: + self._ssl.do_handshake() + self._flush() + return + except ssl.SSLWantReadError: + self._flush() + self._feed() + except ssl.SSLWantWriteError: + self._flush() + + def sendall(self, data): + import ssl + view = memoryview(data) + while view: + try: + written = self._ssl.write(view) + view = view[written:] + self._flush() + except ssl.SSLWantReadError: + self._flush() + self._feed() + except ssl.SSLWantWriteError: + self._flush() + + def recv(self, size): + import ssl + while True: + try: + return self._ssl.read(size) + except ssl.SSLWantReadError: + self._flush() + self._feed() + except ssl.SSLWantWriteError: + self._flush() + + def recv_buffered(self, size): + """只提取已在 TLS 各层缓冲的数据;不足一条记录时不等待网络。""" + import ssl + while True: + try: + return self._ssl.read(size) + except ssl.SSLWantReadError: + self._flush() + if self._pending: + data, self._pending = self._pending, b"" + elif isinstance(self._transport, _LayeredTlsTransport): + data = self._transport.recv_buffered(16384) + elif hasattr(self._transport, "pending") and self._transport.pending(): + data = self._transport.recv(16384) + else: + return None + if data is None: + return None + if not data: + return b"" + self._incoming.write(data) + except ssl.SSLWantWriteError: + self._flush() + + def settimeout(self, value): + self._transport.settimeout(value) + + def gettimeout(self): + return self._transport.gettimeout() + + def wait_socket(self): + return self._transport.wait_socket() if hasattr(self._transport, "wait_socket") else self._transport + + def close(self): + self._transport.close() + + +def _connect_via_jump(jump_chain, ctx): + """最多三跳建立最终节点 TLS;中间 CONNECT 目标只发送解析后的 IP。""" + import socket + chain = list(jump_chain or []) + if not 2 <= len(chain) <= _JUMP_MAX_HOPS: + raise RuntimeError(f"跳板链路必须包含 2-{_JUMP_MAX_HOPS} 个节点") + first = chain[0] + first_host = str(first.get("domain") or "") + first_port = _node_http_port(first) + first_address = _resolve_chain_address(first_host, first_port) + transport = socket.create_connection(first_address, 10) + transport = ctx.wrap_socket(transport, server_hostname=first_host) + for index, next_node in enumerate(chain[1:], 1): + next_host = str(next_node.get("domain") or "") + next_port = _node_http_port(next_node) + leftover = _http_connect_over(transport, chain[index - 1], next_host, next_port) + transport = _LayeredTlsTransport(transport, ctx, next_host, leftover) + return transport + + def _connect_route(direct_target, allow_direct_fallback, remote_host, remote_port, ctx, use_tls, - create_connection=None): + create_connection=None, jump_chain=None): """连接直连目标或上游隧道;智能中国直连失败时安全回退一次隧道。""" import socket create = create_connection or socket.create_connection @@ -1586,13 +2007,18 @@ def _connect_route(direct_target, allow_direct_fallback, remote_host, remote_por if not allow_direct_fallback: raise log(f"[转发器] 国内直连 {direct_target[0]} 失败,回退上游隧道: {e}") + if jump_chain: + if not use_tls: + raise RuntimeError("跳板模式仅支持 TLS 上游") + return _connect_via_jump(jump_chain, ctx), True remote = create((remote_host, remote_port), 10) if use_tls: remote = ctx.wrap_socket(remote, server_hostname=remote_host) return remote, True -def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, smart=False): +def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, smart=False, + jump_chain=None): """处理一条客户端连接:内网(bypass_lan)/中国IP(智能分流)目标直接连接;其余走远端隧道。""" import socket, select client.settimeout(2.0) @@ -1641,7 +2067,8 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, remote = None try: remote, via_tunnel = _connect_route( - direct_target, allow_direct_fallback, remote_host, remote_port, ctx, use_tls) + direct_target, allow_direct_fallback, remote_host, remote_port, ctx, use_tls, + jump_chain=jump_chain) count_traffic = via_tunnel if not via_tunnel: if is_connect: @@ -1678,7 +2105,18 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, idle_timeout = None if is_connect else 120 try: while True: - r, _, _ = select.select([client, remote], [], [], idle_timeout) + if isinstance(remote, _LayeredTlsTransport): + while True: + data = remote.recv_buffered(65536) + if data is None: + break + if not data: + return + client.sendall(data) + if count_traffic: + _traffic_add(down=len(data)) + remote_wait = remote.wait_socket() if hasattr(remote, "wait_socket") else remote + r, _, _ = select.select([client, remote_wait], [], [], idle_timeout) if not r: break if client in r: @@ -1691,7 +2129,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, remote.sendall(data) if count_traffic: _traffic_add(up=len(data)) - if remote in r: + if remote_wait in r: data = remote.recv(65536) if not data: break @@ -1725,7 +2163,8 @@ def _ensure_forwarder(): if _tls_forwarder: return False _tls_start(_endpoint_domain(), _endpoint_port(), use_tls=True, - bypass_lan=False, smart=False, quiet=True) + bypass_lan=False, smart=False, quiet=True, + jump_chain=_jump_chain(_current_node())) return True def _release_forwarder(started_here): @@ -1849,12 +2288,23 @@ def _settings_services(): set_update_via_proxy=_set_update_via_proxy, set_skip_update_check=_set_skip_update_check, open_whitelist=lambda parent: WhitelistDialog(_whitelist_services(), parent).exec_(), + open_jump=lambda parent: JumpDialog(_jump_services(), parent).exec_(), open_third_party=lambda parent: ThirdPartyDialog(_third_party_services(), parent).exec_(), has_login=lambda: _auth_load() is not None, open_login=_open_login_dialog, logout=_logout_user_data, ) + +def _jump_services(): + return JumpServices( + load_icon=load_icon, + load_profiles=_jump_summaries, + available_nodes=_jump_available_nodes, + add_profile=_add_jump_profile, + remove_profile=_remove_jump_profile, + ) + # ── 代理白名单弹窗 ───────────────────────────────────── # ── 主窗口 ──────────────────────────────────────────── @@ -2216,16 +2666,21 @@ class MainWindow(QWidget): # 当前选中节点是 IPv6 专用而本机没有 IPv6 出口:转发的流量全部连不上 raise RuntimeError("当前节点仅提供 IPv6 地址,本机没有 IPv6 出口,请在节点菜单切换其他节点") _traffic_reset() - log("TLS 直连模式") + current_node = _current_node() + jump_chain = _jump_chain(current_node) + log("TLS 多跳模式" if jump_chain else "TLS 直连模式") ep = _endpoint_domain() log(f" 目标域名: {ep}") + if jump_chain: + log(f" 跳板路径: {_jump_route_text(current_node)}") # PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流 if use_pac: _load_cn_ranges() _load_force_proxy_suffixes() _load_direct_domains() _raise_if_start_cancelled(cancel_event) - _tls_start(ep, _endpoint_port(), use_tls=True, bypass_lan=use_bypass_lan, smart=use_pac) + _tls_start(ep, _endpoint_port(), use_tls=True, bypass_lan=use_bypass_lan, smart=use_pac, + jump_chain=jump_chain) try: _raise_if_start_cancelled(cancel_event) if use_pac: @@ -2350,11 +2805,12 @@ class MainWindow(QWidget): """) cur = _selected_index() for i, n in enumerate(nodes): - domain = n.get("domain") or "???" - country = n.get("country") or "" + is_jump = n.get("_rrpp_source_kind") == "jump" + domain = n.get("_jump_name") if is_jump else (n.get("domain") or "???") + country = _jump_route_text(n) if is_jump else (n.get("country") or "") remark = n.get("remark") or "" text = f"{domain} ({country})" - if remark and remark != domain: + if remark and remark != domain and not is_jump: text += f" {remark}" source_name = n.get("_rrpp_source_name") or "官方订阅" text += f" [{source_name}]" @@ -2383,7 +2839,10 @@ class MainWindow(QWidget): return node = _current_node() self._refresh_subtitle() - log(f"已切换节点: {node.get('domain')} ({node.get('country')})") + if node.get("_rrpp_source_kind") == "jump": + log(f"已切换节点: {node.get('_jump_name')} ({_jump_route_text(node)})") + else: + log(f"已切换节点: {node.get('domain')} ({node.get('country')})") def _refresh_subtitle(self): """刷新节点副标题文字。""" diff --git a/lite/rrpp_ui/dialogs.py b/lite/rrpp_ui/dialogs.py index 9ad01ea..f7d6ce1 100644 --- a/lite/rrpp_ui/dialogs.py +++ b/lite/rrpp_ui/dialogs.py @@ -292,6 +292,205 @@ class WhitelistDialog(QDialog): self.accept() +@dataclass(frozen=True) +class JumpServices: + """代理跳板管理所需的节点摘要与配置回调。""" + + load_icon: object + load_profiles: object + available_nodes: object + add_profile: object + remove_profile: object + + +class JumpDialog(QDialog): + """配置最多三跳的节点链路;设置中只保存节点稳定标识。""" + + def __init__(self, services: JumpServices, parent=None): + super().__init__(parent) + self._services = services + self.setWindowTitle("代理跳板") + self.setWindowIcon(services.load_icon()) + self.setFixedSize(560, 520) + self.setStyleSheet(f""" + QDialog {{ background-color: {C_BG}; }} + QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} + QListWidget {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px; + padding: 4px; color: {C_TEXT}; font-size: 12px; }} + QListWidget::item {{ padding: 5px 7px; border-radius: 4px; }} + QListWidget::item:selected {{ background-color: #e3e8ee; color: {C_TEXT}; }} + QPushButton#btnOk {{ background-color: {C_GREEN}; color: #fff; border: none; + border-radius: 6px; padding: 5px 12px; font-size: 12px; font-weight: bold; }} + QPushButton#btnOk:hover {{ background-color: #5ed978; }} + QPushButton#btnDanger {{ background-color: {C_RED}; color: #fff; border: none; + border-radius: 6px; padding: 5px 12px; font-size: 12px; font-weight: bold; }} + QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER}; + border-radius: 6px; padding: 4px 10px; font-size: 11px; }} + QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }} + """) + layout = QVBoxLayout(self) + layout.setSpacing(8) + layout.setContentsMargins(18, 14, 18, 14) + + title = QLabel("代理跳板") + title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + hint = QLabel("按顺序加入节点,至少 2 个、最多 3 个;最后一个节点是出口") + hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") + hint.setAlignment(Qt.AlignCenter) + layout.addWidget(hint) + + saved_label = QLabel("已保存跳板") + saved_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") + layout.addWidget(saved_label) + self.profiles = QListWidget() + self.profiles.setFixedHeight(68) + self.profiles.setToolTip("选择已保存跳板后可删除;新链路在下方配置") + layout.addWidget(self.profiles) + + columns = QHBoxLayout() + columns.setSpacing(8) + left = QVBoxLayout() + left.addWidget(QLabel("当前可用节点")) + self.available = QListWidget() + self.available.setToolTip("选择节点后点击“加入链路”") + left.addWidget(self.available, 1) + columns.addLayout(left, 1) + + middle = QVBoxLayout() + middle.addStretch() + self.btn_add_node = QPushButton("加入链路 →") + self.btn_add_node.setObjectName("btnSmall") + self.btn_add_node.clicked.connect(self._add_node) + middle.addWidget(self.btn_add_node) + self.btn_remove_node = QPushButton("← 移除") + self.btn_remove_node.setObjectName("btnSmall") + self.btn_remove_node.clicked.connect(self._remove_node) + middle.addWidget(self.btn_remove_node) + middle.addStretch() + columns.addLayout(middle) + + right = QVBoxLayout() + right.addWidget(QLabel("新跳板链路")) + self.route = QListWidget() + self.route.setToolTip("顺序即连接顺序;可用上下按钮调整") + right.addWidget(self.route, 1) + reorder = QHBoxLayout() + self.btn_up = QPushButton("上移") + self.btn_up.setObjectName("btnSmall") + self.btn_up.clicked.connect(lambda: self._move_node(-1)) + reorder.addWidget(self.btn_up) + self.btn_down = QPushButton("下移") + self.btn_down.setObjectName("btnSmall") + self.btn_down.clicked.connect(lambda: self._move_node(1)) + reorder.addWidget(self.btn_down) + right.addLayout(reorder) + columns.addLayout(right, 1) + layout.addLayout(columns, 1) + + self.lb_status = QLabel("") + self.lb_status.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") + self.lb_status.setFixedHeight(16) + layout.addWidget(self.lb_status) + + actions = QHBoxLayout() + self.btn_add_profile = QPushButton("添加") + self.btn_add_profile.setObjectName("btnOk") + self.btn_add_profile.clicked.connect(self._add_profile) + actions.addWidget(self.btn_add_profile) + self.btn_delete_profile = QPushButton("删除选中跳板") + self.btn_delete_profile.setObjectName("btnDanger") + self.btn_delete_profile.clicked.connect(self._delete_profile) + actions.addWidget(self.btn_delete_profile) + actions.addStretch() + self.btn_close = QPushButton("关闭") + self.btn_close.setObjectName("btnSmall") + self.btn_close.clicked.connect(self.accept) + actions.addWidget(self.btn_close) + layout.addLayout(actions) + + self._reload() + + def _reload(self): + self.available.clear() + for node in self._services.available_nodes(): + item = QListWidgetItem(node["label"]) + item.setData(Qt.UserRole, node["ref"]) + self.available.addItem(item) + self.profiles.clear() + for profile in self._services.load_profiles(): + state = "可用" if profile.get("available") else "节点不可用" + item = QListWidgetItem(f"{profile['name']} · {profile['route']} · {state}") + item.setData(Qt.UserRole, profile["id"]) + self.profiles.addItem(item) + self.route.clear() + self.lb_status.setText("") + + def _add_node(self): + item = self.available.currentItem() + if item is None: + self._status("请先选择一个节点", error=True) + return + ref = item.data(Qt.UserRole) + refs = {self.route.item(i).data(Qt.UserRole) for i in range(self.route.count())} + if ref in refs: + self._status("同一条链路不能重复使用节点", error=True) + return + if self.route.count() >= 3: + self._status("最多只能配置 3 个节点", error=True) + return + copy = QListWidgetItem(item.text()) + copy.setData(Qt.UserRole, ref) + self.route.addItem(copy) + self._status(f"当前链路:{self.route.count()} 个节点") + + def _remove_node(self): + item = self.route.currentItem() + if item is not None: + self.route.takeItem(self.route.row(item)) + + def _move_node(self, offset): + row = self.route.currentRow() + new_row = row + offset + if row < 0 or not 0 <= new_row < self.route.count(): + return + item = self.route.takeItem(row) + self.route.insertItem(new_row, item) + self.route.setCurrentRow(new_row) + + def _add_profile(self): + if not 2 <= self.route.count() <= 3: + self._status("请先配置 2-3 个节点的链路", error=True) + return + refs = [self.route.item(i).data(Qt.UserRole) for i in range(self.route.count())] + try: + self._services.add_profile(refs) + except Exception as error: + self._status(str(error), error=True) + return + self._reload() + self._status("跳板已添加") + + def _delete_profile(self): + item = self.profiles.currentItem() + if item is None: + self._status("请先选择要删除的跳板", error=True) + return + try: + self._services.remove_profile(item.data(Qt.UserRole)) + except Exception as error: + self._status(str(error), error=True) + return + self._reload() + self._status("跳板已删除") + + def _status(self, message, error=False): + self.lb_status.setText(message) + color = C_RED if error else C_GREEN + self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;") + + @dataclass(frozen=True) class ThirdPartyServices: """第三方订阅管理框所需的显式业务服务。""" @@ -475,6 +674,7 @@ class SettingsServices: set_update_via_proxy: object set_skip_update_check: object open_whitelist: object + open_jump: object open_third_party: object has_login: object open_login: object @@ -489,7 +689,7 @@ class SettingsDialog(QDialog): self._services = services self.setWindowTitle("设置") self.setWindowIcon(services.load_icon()) - self.setFixedSize(300, 396) + self.setFixedSize(300, 430) self.setStyleSheet(f""" QDialog {{ background-color: {C_BG}; }} QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} @@ -544,6 +744,19 @@ class SettingsDialog(QDialog): whitelist_row.addWidget(self.btn_whitelist) layout.addLayout(whitelist_row) + jump_row = QHBoxLayout() + jump_label = QLabel("代理跳板") + jump_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") + jump_label.setToolTip("把当前可用节点串成 2-3 跳链路;最后一个节点作为对外出口") + jump_row.addWidget(jump_label) + jump_row.addStretch() + self.btn_jump = QPushButton("添加") + self.btn_jump.setCursor(Qt.PointingHandCursor) + self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet()) + self.btn_jump.clicked.connect(self._open_jump) + jump_row.addWidget(self.btn_jump) + layout.addLayout(jump_row) + third_party_row = QHBoxLayout() third_party_label = QLabel("第三方") third_party_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") @@ -636,6 +849,9 @@ class SettingsDialog(QDialog): def _open_whitelist(self): self._services.open_whitelist(self) + def _open_jump(self): + self._services.open_jump(self) + def _open_third_party(self): self._services.open_third_party(self)