Compare commits

...
Author SHA1 Message Date
RoyirinRosmontis 8063b6d4e8 feat(lite): 发布多跳代理跳板 9999091814 2026-09-18 14:40:44 +08:00
4 changed files with 702 additions and 24 deletions
+3
View File
@@ -7,3 +7,6 @@ lite/.claude/
lite/backups/
lite/build/
lite/dist/
lite/RELEASE_WORKFLOW.md
*.bak
*.bak-*
+1 -1
View File
@@ -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"
+481 -22
View File
@@ -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:<local_port> -> 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):
"""刷新节点副标题文字。"""
+217 -1
View File
@@ -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)