Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8063b6d4e8 |
@@ -7,3 +7,6 @@ lite/.claude/
|
|||||||
lite/backups/
|
lite/backups/
|
||||||
lite/build/
|
lite/build/
|
||||||
lite/dist/
|
lite/dist/
|
||||||
|
lite/RELEASE_WORKFLOW.md
|
||||||
|
*.bak
|
||||||
|
*.bak-*
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "999909171702",
|
"version": "999909181435",
|
||||||
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
|
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
|
||||||
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client",
|
"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"
|
"update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
|
||||||
|
|||||||
+481
-22
@@ -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.widgets import NodeLabel, StatusDot, ToggleSwitch
|
||||||
from rrpp_ui.dialogs import (
|
from rrpp_ui.dialogs import (
|
||||||
LoginDialog, LoginServices, SettingsDialog, SettingsServices, ThirdPartyDialog, ThirdPartyServices,
|
LoginDialog, LoginServices, SettingsDialog, SettingsServices, ThirdPartyDialog, ThirdPartyServices,
|
||||||
WhitelistDialog, WhitelistServices,
|
WhitelistDialog, WhitelistServices, JumpDialog, JumpServices,
|
||||||
)
|
)
|
||||||
from rrpp_core.routing_primitives import (
|
from rrpp_core.routing_primitives import (
|
||||||
build_suffix_trie as _build_suffix_trie,
|
build_suffix_trie as _build_suffix_trie,
|
||||||
@@ -185,6 +185,8 @@ def _tag_official_nodes(nodes):
|
|||||||
|
|
||||||
def _node_reference(node):
|
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 {}
|
method = (node.get("methods") or {}).get("http") or {}
|
||||||
port = method.get("port", node.get("port", ""))
|
port = method.get("port", node.get("port", ""))
|
||||||
return "|".join((
|
return "|".join((
|
||||||
@@ -210,7 +212,8 @@ def _load_nodes():
|
|||||||
except Exception as error:
|
except Exception as error:
|
||||||
log_red(f"官方节点配置读取失败: {error}")
|
log_red(f"官方节点配置读取失败: {error}")
|
||||||
third_party = _subscription_nodes(_load_third_party_subscriptions())
|
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:
|
if not _SELECTED_RESTORED:
|
||||||
_restore_selected_node(_NODES)
|
_restore_selected_node(_NODES)
|
||||||
if _NODES:
|
if _NODES:
|
||||||
@@ -348,6 +351,12 @@ def _restore_selected_node(nodes):
|
|||||||
if saved_ref and _node_reference(n) == saved_ref:
|
if saved_ref and _node_reference(n) == saved_ref:
|
||||||
_SELECTED_NODE = i
|
_SELECTED_NODE = i
|
||||||
return
|
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):
|
for i, n in enumerate(nodes):
|
||||||
if saved and str(n.get("domain") or "") == saved:
|
if saved and str(n.get("domain") or "") == saved:
|
||||||
_SELECTED_NODE = i
|
_SELECTED_NODE = i
|
||||||
@@ -361,6 +370,207 @@ def _save_current_node(node):
|
|||||||
data["current_node_ref"] = _node_reference(node or {})
|
data["current_node_ref"] = _node_reference(node or {})
|
||||||
_settings_save(data)
|
_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 节点可达性(客户端标注用)─────────────────────
|
||||||
# IPv6-only 节点的域名只有 AAAA 记录(无 A 记录),本机没有 IPv6 出口时连不上。
|
# IPv6-only 节点的域名只有 AAAA 记录(无 A 记录),本机没有 IPv6 出口时连不上。
|
||||||
# 判定三级:ipv6_only 字段 → 服务端 remark 里的 "(IPv6)" 标记 → 域名解析兜底。
|
# 判定三级:ipv6_only 字段 → 服务端 remark 里的 "(IPv6)" 标记 → 域名解析兜底。
|
||||||
@@ -442,6 +652,8 @@ def _node_reachable(node):
|
|||||||
(按该节点自己的 HTTP 端口探测)。结果按域名缓存(探测一次)。"""
|
(按该节点自己的 HTTP 端口探测)。结果按域名缓存(探测一次)。"""
|
||||||
if not isinstance(node, dict):
|
if not isinstance(node, dict):
|
||||||
return True
|
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()
|
domain = str(node.get("domain") or "").strip()
|
||||||
if not _node_ipv6_only(node):
|
if not _node_ipv6_only(node):
|
||||||
return True
|
return True
|
||||||
@@ -472,11 +684,31 @@ def _node_reachable(node):
|
|||||||
|
|
||||||
def _reload_nodes():
|
def _reload_nodes():
|
||||||
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
|
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
|
||||||
global _NODES
|
global _NODES, _SELECTED_NODE
|
||||||
_NODES = None
|
_NODES = None
|
||||||
_IPV6_OK.clear() # 节点列表变了,IPv6 可达性/专用判定缓存作废
|
_IPV6_OK.clear() # 节点列表变了,IPv6 可达性/专用判定缓存作废
|
||||||
_IPV6_ONLY.clear()
|
_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):
|
def _first_reachable_index(nodes):
|
||||||
"""第一个本机可达的节点下标;都不可达返回 0。供选中不可达节点时回退。"""
|
"""第一个本机可达的节点下标;都不可达返回 0。供选中不可达节点时回退。"""
|
||||||
@@ -541,7 +773,7 @@ def _node_val(key, default=""):
|
|||||||
return v if v not in (None, "") else default # 空字符串视为未填写
|
return v if v not in (None, "") else default # 空字符串视为未填写
|
||||||
|
|
||||||
def _endpoint_domain():
|
def _endpoint_domain():
|
||||||
return _node_val("domain", "???")
|
return str(_terminal_node(_current_node()).get("domain") or "???")
|
||||||
|
|
||||||
def _http_method():
|
def _http_method():
|
||||||
"""节点 HTTP 代理方法参数(新版 nodes.json 的 methods.http)。
|
"""节点 HTTP 代理方法参数(新版 nodes.json 的 methods.http)。
|
||||||
@@ -549,7 +781,7 @@ def _http_method():
|
|||||||
返回 (node, get):get(key, default) 优先取 methods.http 下的键,
|
返回 (node, get):get(key, default) 优先取 methods.http 下的键,
|
||||||
缺失时回退节点顶层——兼容旧扁平结构(port/username/password 直接挂在节点上)。
|
缺失时回退节点顶层——兼容旧扁平结构(port/username/password 直接挂在节点上)。
|
||||||
"""
|
"""
|
||||||
node = _current_node() or {}
|
node = _terminal_node(_current_node())
|
||||||
m = (node.get("methods") or {}).get("http") or {}
|
m = (node.get("methods") or {}).get("http") or {}
|
||||||
|
|
||||||
def get(key, default=""):
|
def get(key, default=""):
|
||||||
@@ -569,11 +801,15 @@ def _endpoint_port():
|
|||||||
return 443
|
return 443
|
||||||
|
|
||||||
def _endpoint_country():
|
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():
|
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()
|
domain = _endpoint_domain()
|
||||||
if domain == "???":
|
if domain == "???":
|
||||||
return "无可用节点(请登录)"
|
return "无可用节点(请登录)"
|
||||||
@@ -894,7 +1130,8 @@ def _traffic_rate():
|
|||||||
def _traffic_snapshot():
|
def _traffic_snapshot():
|
||||||
return _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 包裹)。
|
"""启动本地认证转发器:127.0.0.1:<local_port> -> remote_host:remote_port(可选 TLS 包裹)。
|
||||||
|
|
||||||
smart=True 时启用 IP 智能分流(域名表/非中国 IP 走隧道,中国 IP 直连)。
|
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"):
|
while _tls_forwarder and _tls_forwarder.get("running"):
|
||||||
try:
|
try:
|
||||||
client, addr = svr.accept()
|
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:
|
except socket.timeout:
|
||||||
continue
|
continue
|
||||||
except Exception:
|
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 = threading.Thread(target=_run, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
if not quiet:
|
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:
|
def _inject_proxy_auth(data: bytes) -> bytes:
|
||||||
"""在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。
|
"""在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。
|
||||||
@@ -956,6 +1199,9 @@ def _inject_proxy_auth(data: bytes) -> bytes:
|
|||||||
Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行,
|
Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行,
|
||||||
保证上游(取首个 Authorization)读到的是我们的认证。
|
保证上游(取首个 Authorization)读到的是我们的认证。
|
||||||
"""
|
"""
|
||||||
|
node, _ = _http_method()
|
||||||
|
if not any(_node_proxy_credentials(node)):
|
||||||
|
return data
|
||||||
return _inject_proxy_auth_value(data, _proxy_auth_basic())
|
return _inject_proxy_auth_value(data, _proxy_auth_basic())
|
||||||
|
|
||||||
def _make_request_injector():
|
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 not (bypass_lan and _is_private_ip(host))
|
||||||
and _resolve_china_direct_ip(host) is not None)
|
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,
|
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
|
import socket
|
||||||
create = create_connection or socket.create_connection
|
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:
|
if not allow_direct_fallback:
|
||||||
raise
|
raise
|
||||||
log(f"[转发器] 国内直连 {direct_target[0]} 失败,回退上游隧道: {e}")
|
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)
|
remote = create((remote_host, remote_port), 10)
|
||||||
if use_tls:
|
if use_tls:
|
||||||
remote = ctx.wrap_socket(remote, server_hostname=remote_host)
|
remote = ctx.wrap_socket(remote, server_hostname=remote_host)
|
||||||
return remote, True
|
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(智能分流)目标直接连接;其余走远端隧道。"""
|
"""处理一条客户端连接:内网(bypass_lan)/中国IP(智能分流)目标直接连接;其余走远端隧道。"""
|
||||||
import socket, select
|
import socket, select
|
||||||
client.settimeout(2.0)
|
client.settimeout(2.0)
|
||||||
@@ -1641,7 +2067,8 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
|||||||
remote = None
|
remote = None
|
||||||
try:
|
try:
|
||||||
remote, via_tunnel = _connect_route(
|
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
|
count_traffic = via_tunnel
|
||||||
if not via_tunnel:
|
if not via_tunnel:
|
||||||
if is_connect:
|
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
|
idle_timeout = None if is_connect else 120
|
||||||
try:
|
try:
|
||||||
while True:
|
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:
|
if not r:
|
||||||
break
|
break
|
||||||
if client in r:
|
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)
|
remote.sendall(data)
|
||||||
if count_traffic:
|
if count_traffic:
|
||||||
_traffic_add(up=len(data))
|
_traffic_add(up=len(data))
|
||||||
if remote in r:
|
if remote_wait in r:
|
||||||
data = remote.recv(65536)
|
data = remote.recv(65536)
|
||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
@@ -1725,7 +2163,8 @@ def _ensure_forwarder():
|
|||||||
if _tls_forwarder:
|
if _tls_forwarder:
|
||||||
return False
|
return False
|
||||||
_tls_start(_endpoint_domain(), _endpoint_port(), use_tls=True,
|
_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
|
return True
|
||||||
|
|
||||||
def _release_forwarder(started_here):
|
def _release_forwarder(started_here):
|
||||||
@@ -1849,12 +2288,23 @@ def _settings_services():
|
|||||||
set_update_via_proxy=_set_update_via_proxy,
|
set_update_via_proxy=_set_update_via_proxy,
|
||||||
set_skip_update_check=_set_skip_update_check,
|
set_skip_update_check=_set_skip_update_check,
|
||||||
open_whitelist=lambda parent: WhitelistDialog(_whitelist_services(), parent).exec_(),
|
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_(),
|
open_third_party=lambda parent: ThirdPartyDialog(_third_party_services(), parent).exec_(),
|
||||||
has_login=lambda: _auth_load() is not None,
|
has_login=lambda: _auth_load() is not None,
|
||||||
open_login=_open_login_dialog,
|
open_login=_open_login_dialog,
|
||||||
logout=_logout_user_data,
|
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 出口:转发的流量全部连不上
|
# 当前选中节点是 IPv6 专用而本机没有 IPv6 出口:转发的流量全部连不上
|
||||||
raise RuntimeError("当前节点仅提供 IPv6 地址,本机没有 IPv6 出口,请在节点菜单切换其他节点")
|
raise RuntimeError("当前节点仅提供 IPv6 地址,本机没有 IPv6 出口,请在节点菜单切换其他节点")
|
||||||
_traffic_reset()
|
_traffic_reset()
|
||||||
log("TLS 直连模式")
|
current_node = _current_node()
|
||||||
|
jump_chain = _jump_chain(current_node)
|
||||||
|
log("TLS 多跳模式" if jump_chain else "TLS 直连模式")
|
||||||
ep = _endpoint_domain()
|
ep = _endpoint_domain()
|
||||||
log(f" 目标域名: {ep}")
|
log(f" 目标域名: {ep}")
|
||||||
|
if jump_chain:
|
||||||
|
log(f" 跳板路径: {_jump_route_text(current_node)}")
|
||||||
# PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流
|
# PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流
|
||||||
if use_pac:
|
if use_pac:
|
||||||
_load_cn_ranges()
|
_load_cn_ranges()
|
||||||
_load_force_proxy_suffixes()
|
_load_force_proxy_suffixes()
|
||||||
_load_direct_domains()
|
_load_direct_domains()
|
||||||
_raise_if_start_cancelled(cancel_event)
|
_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:
|
try:
|
||||||
_raise_if_start_cancelled(cancel_event)
|
_raise_if_start_cancelled(cancel_event)
|
||||||
if use_pac:
|
if use_pac:
|
||||||
@@ -2350,11 +2805,12 @@ class MainWindow(QWidget):
|
|||||||
""")
|
""")
|
||||||
cur = _selected_index()
|
cur = _selected_index()
|
||||||
for i, n in enumerate(nodes):
|
for i, n in enumerate(nodes):
|
||||||
domain = n.get("domain") or "???"
|
is_jump = n.get("_rrpp_source_kind") == "jump"
|
||||||
country = n.get("country") or ""
|
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 ""
|
remark = n.get("remark") or ""
|
||||||
text = f"{domain} ({country})"
|
text = f"{domain} ({country})"
|
||||||
if remark and remark != domain:
|
if remark and remark != domain and not is_jump:
|
||||||
text += f" {remark}"
|
text += f" {remark}"
|
||||||
source_name = n.get("_rrpp_source_name") or "官方订阅"
|
source_name = n.get("_rrpp_source_name") or "官方订阅"
|
||||||
text += f" [{source_name}]"
|
text += f" [{source_name}]"
|
||||||
@@ -2383,7 +2839,10 @@ class MainWindow(QWidget):
|
|||||||
return
|
return
|
||||||
node = _current_node()
|
node = _current_node()
|
||||||
self._refresh_subtitle()
|
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):
|
def _refresh_subtitle(self):
|
||||||
"""刷新节点副标题文字。"""
|
"""刷新节点副标题文字。"""
|
||||||
|
|||||||
+217
-1
@@ -292,6 +292,205 @@ class WhitelistDialog(QDialog):
|
|||||||
self.accept()
|
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)
|
@dataclass(frozen=True)
|
||||||
class ThirdPartyServices:
|
class ThirdPartyServices:
|
||||||
"""第三方订阅管理框所需的显式业务服务。"""
|
"""第三方订阅管理框所需的显式业务服务。"""
|
||||||
@@ -475,6 +674,7 @@ class SettingsServices:
|
|||||||
set_update_via_proxy: object
|
set_update_via_proxy: object
|
||||||
set_skip_update_check: object
|
set_skip_update_check: object
|
||||||
open_whitelist: object
|
open_whitelist: object
|
||||||
|
open_jump: object
|
||||||
open_third_party: object
|
open_third_party: object
|
||||||
has_login: object
|
has_login: object
|
||||||
open_login: object
|
open_login: object
|
||||||
@@ -489,7 +689,7 @@ class SettingsDialog(QDialog):
|
|||||||
self._services = services
|
self._services = services
|
||||||
self.setWindowTitle("设置")
|
self.setWindowTitle("设置")
|
||||||
self.setWindowIcon(services.load_icon())
|
self.setWindowIcon(services.load_icon())
|
||||||
self.setFixedSize(300, 396)
|
self.setFixedSize(300, 430)
|
||||||
self.setStyleSheet(f"""
|
self.setStyleSheet(f"""
|
||||||
QDialog {{ background-color: {C_BG}; }}
|
QDialog {{ background-color: {C_BG}; }}
|
||||||
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
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)
|
whitelist_row.addWidget(self.btn_whitelist)
|
||||||
layout.addLayout(whitelist_row)
|
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_row = QHBoxLayout()
|
||||||
third_party_label = QLabel("第三方")
|
third_party_label = QLabel("第三方")
|
||||||
third_party_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
third_party_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
@@ -636,6 +849,9 @@ class SettingsDialog(QDialog):
|
|||||||
def _open_whitelist(self):
|
def _open_whitelist(self):
|
||||||
self._services.open_whitelist(self)
|
self._services.open_whitelist(self)
|
||||||
|
|
||||||
|
def _open_jump(self):
|
||||||
|
self._services.open_jump(self)
|
||||||
|
|
||||||
def _open_third_party(self):
|
def _open_third_party(self):
|
||||||
self._services.open_third_party(self)
|
self._services.open_third_party(self)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user