Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a67d7dca |
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "999909181435",
|
||||
"version": "999909181618",
|
||||
"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"
|
||||
|
||||
+259
-43
@@ -15,7 +15,8 @@ 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, JumpDialog, JumpServices,
|
||||
WhitelistDialog, WhitelistServices, FixedRouteDialog, FixedRouteServices,
|
||||
JumpDialog, JumpServices,
|
||||
)
|
||||
from rrpp_core.routing_primitives import (
|
||||
build_suffix_trie as _build_suffix_trie,
|
||||
@@ -815,11 +816,15 @@ def _endpoint_label():
|
||||
return "无可用节点(请登录)"
|
||||
return f"{domain} ({_endpoint_country()})"
|
||||
|
||||
def _proxy_auth_basic():
|
||||
def _proxy_auth_basic(node=None):
|
||||
"""正向代理 Basic 认证凭证:methods.http.{username,password}(新格式)
|
||||
或节点顶层同名字段(旧格式);用于转发器向上游代理注入 Authorization。"""
|
||||
_, get = _http_method()
|
||||
return base64.b64encode(f"{get('username')}:{get('password')}".encode()).decode()
|
||||
if node is None:
|
||||
_, get = _http_method()
|
||||
username, password = get("username"), get("password")
|
||||
else:
|
||||
username, password = _node_proxy_credentials(node)
|
||||
return base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
|
||||
_tls_forwarder = None
|
||||
_pac_server = None
|
||||
@@ -1189,7 +1194,7 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma
|
||||
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, proxy_node=None) -> bytes:
|
||||
"""在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。
|
||||
|
||||
nginx 的 auth_basic 校验 Authorization 而非 Proxy-Authorization,
|
||||
@@ -1199,12 +1204,12 @@ def _inject_proxy_auth(data: bytes) -> bytes:
|
||||
Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行,
|
||||
保证上游(取首个 Authorization)读到的是我们的认证。
|
||||
"""
|
||||
node, _ = _http_method()
|
||||
node = proxy_node or _http_method()[0]
|
||||
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(node))
|
||||
|
||||
def _make_request_injector():
|
||||
def _make_request_injector(proxy_node=None):
|
||||
"""为明文 HTTP 代理连接生成逐条注入认证的流式处理器。
|
||||
|
||||
keep-alive 连接上的后续请求同样需要 Authorization,不能只在首包注入。
|
||||
@@ -1220,7 +1225,7 @@ def _make_request_injector():
|
||||
if not _starts_with_method(data):
|
||||
return data
|
||||
if b"\r\n\r\n" in data or len(data) >= 65536:
|
||||
return _inject_proxy_auth(data)
|
||||
return _inject_proxy_auth(data, proxy_node)
|
||||
pending = data
|
||||
return b""
|
||||
return feed
|
||||
@@ -1339,38 +1344,187 @@ def _set_skip_update_check(enabled):
|
||||
_settings_save(data)
|
||||
log(f"检查更新已{'跳过' if enabled else '恢复'}(PAC 与程序版本;节点更新不受影响)")
|
||||
|
||||
# ── 代理白名单(用户可配置,强制直连,settings.json 的 whitelist 键) ─────────
|
||||
# 每条规则 {"value": 域名/IP/CIDR, "enabled": 是否启用},命中即直连(不经过代理)。
|
||||
# 规则保存在内存缓存 _WHITELIST,连接建立时直接读取,不落盘回读;
|
||||
# _WHITELIST_VERSION 在保存时 +1,使按 (host, version) 键控的匹配缓存失效。
|
||||
|
||||
def _use_pac_mode():
|
||||
"""是否默认启用 PAC 智能分流;缺失时保持原有默认值 True。"""
|
||||
return bool(_settings_load().get("pac_mode", True))
|
||||
|
||||
|
||||
def _set_use_pac_mode(enabled):
|
||||
"""保存 PAC 模式偏好,下一次启动时恢复。"""
|
||||
data = _settings_load()
|
||||
data["pac_mode"] = bool(enabled)
|
||||
_settings_save(data)
|
||||
|
||||
|
||||
def _bypass_lan():
|
||||
"""是否默认绕过内网 IP;缺失时保持原有默认值 True。"""
|
||||
return bool(_settings_load().get("bypass_lan", True))
|
||||
|
||||
|
||||
def _set_bypass_lan(enabled):
|
||||
"""保存内网 IP 直连偏好,下一次启动时恢复。"""
|
||||
data = _settings_load()
|
||||
data["bypass_lan"] = bool(enabled)
|
||||
_settings_save(data)
|
||||
|
||||
# ── 域名路由(兼容旧代理白名单,settings.json 的 whitelist 键) ────────
|
||||
# 每条规则 {"value": 域名/IP/CIDR, "enabled": 是否启用,
|
||||
# "action": "direct" | "node", "node_ref": 节点稳定标识}。
|
||||
# action=direct 为强制不代理,action=node 为强制走指定节点;旧版只有 value/enabled
|
||||
# 的白名单自动按 direct 解释。规则保存在内存缓存,修改后立即影响新连接。
|
||||
|
||||
_WHITELIST = None # 内存缓存:规范化后的规则列表
|
||||
_WHITELIST_VERSION = 0 # 规则版本号(保存时 +1,用于匹配缓存失效)
|
||||
_ROUTE_RULES = None # 全量域名路由规则(含 direct/node)
|
||||
|
||||
def _whitelist_rules():
|
||||
"""白名单规则列表(内存缓存,首次读取 settings.json;保存时同步更新)。"""
|
||||
global _WHITELIST
|
||||
if _WHITELIST is None:
|
||||
|
||||
def _route_domain_normalize(entry: str) -> str:
|
||||
"""规范化域名规则,保留 ``*.`` 通配符,兼容旧白名单输入。"""
|
||||
s = str(entry or "").strip().lower()
|
||||
if "://" in s:
|
||||
s = s.split("://", 1)[1]
|
||||
s = s.split("/", 1)[0].rstrip(".")
|
||||
wildcard = s.startswith("*.")
|
||||
if wildcard:
|
||||
s = s[2:]
|
||||
return ("*." if wildcard else "") + s
|
||||
|
||||
|
||||
def _route_rule_match(host: str, entry: str) -> bool:
|
||||
"""匹配域名路由:普通域名兼容旧版的根域+子域语义,``*.`` 仅匹配子域。"""
|
||||
value = _route_domain_normalize(entry)
|
||||
h = str(host or "").rstrip(".").lower()
|
||||
if value.startswith("*."):
|
||||
base = value[2:]
|
||||
return bool(base and h.endswith("." + base) and h != base)
|
||||
return _whitelist_entry_match(h, value)
|
||||
|
||||
|
||||
def _route_node_reference(node):
|
||||
"""固定节点规则使用稳定引用;跳板引用使用 profile ID,普通节点忽略备注变化。"""
|
||||
if isinstance(node, dict) and node.get("_rrpp_source_kind") == "jump":
|
||||
return _node_reference(node)
|
||||
return _jump_node_reference(node)
|
||||
|
||||
|
||||
def _route_node_label(node):
|
||||
if node.get("_rrpp_source_kind") == "jump":
|
||||
return f"{node.get('_jump_name') or '自定义跳板'} ({_jump_route_text(node)})"
|
||||
return f"{node.get('domain') or '???'} ({node.get('country') or '?'})"
|
||||
|
||||
|
||||
def _route_rules():
|
||||
"""读取并规范化全量路由规则;旧版 whitelist 自动迁移为强制直连。"""
|
||||
global _ROUTE_RULES
|
||||
if _ROUTE_RULES is None:
|
||||
raw = _settings_load().get("whitelist")
|
||||
if not isinstance(raw, list):
|
||||
raw = []
|
||||
_WHITELIST = [{"value": str(r.get("value") or "").strip().lower(),
|
||||
"enabled": bool(r.get("enabled", True))}
|
||||
for r in raw if isinstance(r, dict) and str(r.get("value") or "").strip()]
|
||||
return _WHITELIST
|
||||
result = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
value = _route_domain_normalize(item.get("domain") or item.get("value") or "")
|
||||
if not value:
|
||||
continue
|
||||
action = str(item.get("action") or ("node" if item.get("node_ref") else "direct")).lower()
|
||||
if action not in ("direct", "node"):
|
||||
action = "direct"
|
||||
result.append({
|
||||
"domain": value,
|
||||
"enabled": bool(item.get("enabled", True)),
|
||||
"action": action,
|
||||
"node_ref": str(item.get("node_ref") or ""),
|
||||
})
|
||||
_ROUTE_RULES = result
|
||||
return _ROUTE_RULES
|
||||
|
||||
def _whitelist_save(rules):
|
||||
"""保存白名单规则(规范化 + 去空白条目),更新内存缓存并记录日志。"""
|
||||
global _WHITELIST, _WHITELIST_VERSION
|
||||
clean = [{"value": _whitelist_normalize(r.get("value") or ""),
|
||||
"enabled": bool(r.get("enabled", True))}
|
||||
for r in rules if _whitelist_normalize(r.get("value") or "")]
|
||||
|
||||
def _route_rule_for_host(host: str):
|
||||
"""返回目标域名命中的最高优先级规则;规则变更对新连接立即生效。"""
|
||||
matches = []
|
||||
for rule in _route_rules():
|
||||
if not rule.get("enabled") or not _route_rule_match(host, rule.get("domain", "")):
|
||||
continue
|
||||
domain = rule["domain"]
|
||||
base = domain[2:] if domain.startswith("*.") else domain
|
||||
# 更长域名优先;同一根域下显式通配符优先于兼容旧语义的普通域名。
|
||||
matches.append((len(base), int(domain.startswith("*.")), rule))
|
||||
return max(matches, key=lambda item: (item[0], item[1]))[2] if matches else None
|
||||
|
||||
|
||||
def _fixed_route_nodes():
|
||||
"""返回可被域名规则选择的当前普通节点与多跳虚拟节点。"""
|
||||
candidates = [node for node in _load_nodes() if _node_reachable(node)]
|
||||
counts = {}
|
||||
for node in candidates:
|
||||
ref = _route_node_reference(node)
|
||||
counts[ref] = counts.get(ref, 0) + 1
|
||||
return [node for node in candidates if counts[_route_node_reference(node)] == 1]
|
||||
|
||||
|
||||
def _fixed_route_node(rule):
|
||||
"""按规则解析固定节点;节点下线或引用失效时返回 None。"""
|
||||
ref = str((rule or {}).get("node_ref") or "")
|
||||
if not ref:
|
||||
return None
|
||||
candidates = [node for node in _load_nodes() if _route_node_reference(node) == ref]
|
||||
return candidates[0] if len(candidates) == 1 and _node_reachable(candidates[0]) else None
|
||||
|
||||
|
||||
def _fixed_route_rules():
|
||||
"""供设置界面展示规则及节点可用状态,不暴露节点认证信息。"""
|
||||
nodes = {_route_node_reference(node): node for node in _fixed_route_nodes()}
|
||||
result = []
|
||||
for rule in _route_rules():
|
||||
item = dict(rule)
|
||||
node = nodes.get(rule.get("node_ref")) if rule.get("action") == "node" else None
|
||||
item["node_label"] = _route_node_label(node) if node else ("节点不可用" if rule.get("action") == "node" else "强制不代理")
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def _fixed_route_save(rules):
|
||||
"""保存域名路由规则;保留旧 whitelist 键名以兼容已有用户设置。"""
|
||||
global _ROUTE_RULES, _WHITELIST, _WHITELIST_VERSION
|
||||
clean = []
|
||||
for item in rules or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
value = _route_domain_normalize(item.get("domain") or item.get("value") or "")
|
||||
if not value:
|
||||
continue
|
||||
action = str(item.get("action") or "direct").lower()
|
||||
if action not in ("direct", "node"):
|
||||
action = "direct"
|
||||
clean.append({
|
||||
"value": value,
|
||||
"enabled": bool(item.get("enabled", True)),
|
||||
"action": action,
|
||||
"node_ref": str(item.get("node_ref") or "") if action == "node" else "",
|
||||
})
|
||||
data = _settings_load()
|
||||
data["whitelist"] = clean
|
||||
_settings_save(data)
|
||||
_WHITELIST = clean
|
||||
_ROUTE_RULES = None
|
||||
_WHITELIST = None
|
||||
_WHITELIST_VERSION += 1
|
||||
log(f"代理白名单已保存: {len(clean)} 条(立即生效)")
|
||||
_is_whitelisted.cache_clear()
|
||||
log(f"域名路由已保存: {len(clean)} 条(立即生效)")
|
||||
|
||||
def _whitelist_rules():
|
||||
"""兼容旧调用方:只返回强制不代理规则。"""
|
||||
global _WHITELIST
|
||||
if _WHITELIST is None:
|
||||
_WHITELIST = [{"value": r["domain"], "enabled": r["enabled"]}
|
||||
for r in _route_rules() if r.get("action") == "direct"]
|
||||
return _WHITELIST
|
||||
|
||||
def _whitelist_save(rules):
|
||||
"""旧白名单保存入口:把传入规则按强制不代理处理。"""
|
||||
_fixed_route_save([{"domain": r.get("value"), "enabled": r.get("enabled", True), "action": "direct"}
|
||||
for r in rules or []])
|
||||
|
||||
def _whitelist_normalize(entry: str) -> str:
|
||||
"""条目规范化:去协议头/通配符/末尾点,统一小写。"""
|
||||
@@ -1407,6 +1561,23 @@ def _whitelist_validate_entry(entry: str):
|
||||
return "", "IP 格式不正确"
|
||||
return s, ""
|
||||
|
||||
|
||||
def _fixed_route_validate_domain(entry: str):
|
||||
"""校验域名路由规则;支持 ``example.com`` 与 ``*.example.com``。"""
|
||||
value = _route_domain_normalize(entry)
|
||||
base = value[2:] if value.startswith("*.") else value
|
||||
if not base:
|
||||
return "", "域名不能为空"
|
||||
if any(ch.isspace() for ch in base) or "/" in base or ":" in base:
|
||||
return "", "域名格式不正确"
|
||||
labels = base.split(".")
|
||||
if len(labels) < 2 or any(not label or label.startswith("-") or label.endswith("-")
|
||||
for label in labels):
|
||||
return "", "请输入完整域名,例如 example.com"
|
||||
if any(not all(ch.isalnum() or ch == "-" for ch in label) for label in labels):
|
||||
return "", "域名只能包含字母、数字、短横线和点"
|
||||
return value, ""
|
||||
|
||||
def _ver_tuple(v):
|
||||
"""版本号转数字元组:'v0.1.0' -> (0, 1, 0),无法解析的段按 0 处理。"""
|
||||
parts = []
|
||||
@@ -2028,6 +2199,11 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
||||
injector = None
|
||||
direct_target = None
|
||||
allow_direct_fallback = False
|
||||
forced_rule = None
|
||||
forced_node = None
|
||||
route_remote_host = remote_host
|
||||
route_remote_port = remote_port
|
||||
route_jump_chain = jump_chain
|
||||
max_headers = 65536
|
||||
try:
|
||||
while len(buf) < max_headers:
|
||||
@@ -2055,10 +2231,27 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
||||
else:
|
||||
target = _parse_http_absolute(first_line)
|
||||
if target:
|
||||
direct_target = _direct_connect_target(
|
||||
target[0], target[1], bypass_lan, smart)
|
||||
allow_direct_fallback = (direct_target is not None and
|
||||
_is_smart_china_direct(target[0], bypass_lan, smart))
|
||||
forced_rule = _route_rule_for_host(target[0])
|
||||
if forced_rule and forced_rule.get("action") == "direct":
|
||||
# 显式强制不代理优先于 PAC 的域名表和 IP 判定。
|
||||
direct_target = (target[0], target[1])
|
||||
elif forced_rule and forced_rule.get("action") == "node":
|
||||
forced_node = _fixed_route_node(forced_rule)
|
||||
if forced_node is None:
|
||||
raise RuntimeError(
|
||||
f"域名 {target[0]} 指定的固定节点不可用,请在设置中更新域名路由"
|
||||
)
|
||||
terminal = _terminal_node(forced_node)
|
||||
route_remote_host = str(terminal.get("domain") or "")
|
||||
route_remote_port = _node_http_port(terminal)
|
||||
route_jump_chain = _jump_chain(forced_node)
|
||||
# 固定节点规则必须走指定节点,不允许 PAC 把目标改为直连。
|
||||
direct_target = None
|
||||
else:
|
||||
direct_target = _direct_connect_target(
|
||||
target[0], target[1], bypass_lan, smart)
|
||||
allow_direct_fallback = (direct_target is not None and
|
||||
_is_smart_china_direct(target[0], bypass_lan, smart))
|
||||
except Exception as e:
|
||||
log(f"[转发器] 读取首包失败: {e}")
|
||||
client.close()
|
||||
@@ -2067,8 +2260,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,
|
||||
jump_chain=jump_chain)
|
||||
direct_target, allow_direct_fallback, route_remote_host, route_remote_port, ctx, use_tls,
|
||||
jump_chain=route_jump_chain)
|
||||
count_traffic = via_tunnel
|
||||
if not via_tunnel:
|
||||
if is_connect:
|
||||
@@ -2079,8 +2272,10 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
||||
else:
|
||||
if is_http and b"\r\n\r\n" in buf:
|
||||
if not is_connect:
|
||||
injector = _make_request_injector()
|
||||
buf = _inject_proxy_auth(buf)
|
||||
injector = _make_request_injector(_terminal_node(forced_node) if forced_node else None)
|
||||
buf = _inject_proxy_auth(
|
||||
buf, _terminal_node(forced_node) if forced_node else None
|
||||
)
|
||||
if buf:
|
||||
remote.sendall(buf)
|
||||
if via_tunnel:
|
||||
@@ -2239,9 +2434,28 @@ def _login_services():
|
||||
def _whitelist_services():
|
||||
return WhitelistServices(
|
||||
load_icon=load_icon,
|
||||
load_rules=_whitelist_rules,
|
||||
validate_rule=_whitelist_validate_entry,
|
||||
save_rules=_whitelist_save,
|
||||
load_rules=lambda: [
|
||||
{"value": item["domain"], "enabled": item["enabled"]}
|
||||
for item in _fixed_route_rules() if item.get("action") == "direct"
|
||||
],
|
||||
validate_rule=_fixed_route_validate_domain,
|
||||
save_rules=lambda rules: _fixed_route_save([
|
||||
{"domain": item.get("value"), "enabled": item.get("enabled", True), "action": "direct"}
|
||||
for item in rules
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
def _fixed_route_services():
|
||||
return FixedRouteServices(
|
||||
load_icon=load_icon,
|
||||
load_rules=_fixed_route_rules,
|
||||
available_nodes=lambda: [
|
||||
{"ref": _route_node_reference(node), "label": _route_node_label(node)}
|
||||
for node in _fixed_route_nodes()
|
||||
],
|
||||
validate_domain=_fixed_route_validate_domain,
|
||||
save_rules=_fixed_route_save,
|
||||
)
|
||||
|
||||
|
||||
@@ -2287,7 +2501,7 @@ def _settings_services():
|
||||
set_port=_set_local_port,
|
||||
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_whitelist=lambda parent: FixedRouteDialog(_fixed_route_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,
|
||||
@@ -2423,13 +2637,15 @@ class MainWindow(QWidget):
|
||||
pac_layout.setSpacing(8)
|
||||
pac_layout.addStretch()
|
||||
self.cb_bypass_lan = QCheckBox("不代理内网 IP")
|
||||
self.cb_bypass_lan.setChecked(True) # 默认勾选:内网 IP 直连,不经过本地转发器
|
||||
self.cb_bypass_lan.setChecked(_bypass_lan())
|
||||
self.cb_bypass_lan.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||
self.cb_bypass_lan.toggled.connect(_set_bypass_lan)
|
||||
pac_layout.addWidget(self.cb_bypass_lan)
|
||||
self.cb_pac = QCheckBox("PAC 规则模式")
|
||||
self.cb_pac.setChecked(True) # 默认开启智能分流(域名表/中国IP判定已足够完善)
|
||||
self.cb_pac.setChecked(_use_pac_mode())
|
||||
self.cb_pac.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||
self.cb_pac.setToolTip("只代理海外服务,使用域名和解析 IP 匹配。")
|
||||
self.cb_pac.toggled.connect(_set_use_pac_mode)
|
||||
pac_layout.addWidget(self.cb_pac)
|
||||
pac_layout.addStretch()
|
||||
layout.addLayout(pac_layout)
|
||||
|
||||
+178
-4
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
||||
QComboBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
||||
QPushButton, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
@@ -292,6 +292,180 @@ class WhitelistDialog(QDialog):
|
||||
self.accept()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FixedRouteServices:
|
||||
"""域名路由编辑框所需的规则与节点服务。"""
|
||||
|
||||
load_icon: object
|
||||
load_rules: object
|
||||
available_nodes: object
|
||||
validate_domain: object
|
||||
save_rules: object
|
||||
|
||||
|
||||
class FixedRouteDialog(QDialog):
|
||||
"""为域名配置强制直连或指定节点代理。"""
|
||||
|
||||
def __init__(self, services: FixedRouteServices, parent=None):
|
||||
super().__init__(parent)
|
||||
self._services = services
|
||||
self.setWindowTitle("域名路由")
|
||||
self.setWindowIcon(services.load_icon())
|
||||
self.setFixedSize(620, 530)
|
||||
self.setStyleSheet(f"""
|
||||
QDialog {{ background-color: {C_BG}; }}
|
||||
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
||||
QLineEdit, QComboBox {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||||
padding: 5px 8px; color: {C_TEXT}; 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#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(9)
|
||||
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("example.com 匹配根域及子域;*.example.com 仅匹配子域")
|
||||
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||||
hint.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(hint)
|
||||
|
||||
self.lb_err = QLabel("")
|
||||
self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||||
self.lb_err.setFixedHeight(16)
|
||||
self.lb_err.hide()
|
||||
layout.addWidget(self.lb_err)
|
||||
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.setToolTip("勾选启用规则;选中后可删除")
|
||||
layout.addWidget(self.list_widget, 1)
|
||||
|
||||
domain_row = QHBoxLayout()
|
||||
domain_row.setSpacing(7)
|
||||
self.ed_domain = QLineEdit()
|
||||
self.ed_domain.setPlaceholderText("域名,如 example.com 或 *.example.com")
|
||||
self.ed_domain.returnPressed.connect(self._add_rule)
|
||||
domain_row.addWidget(self.ed_domain, 1)
|
||||
self.cb_action = QComboBox()
|
||||
self.cb_action.addItem("强制不代理", "direct")
|
||||
self.cb_action.addItem("强制节点代理", "node")
|
||||
self.cb_action.currentIndexChanged.connect(self._on_action_changed)
|
||||
domain_row.addWidget(self.cb_action)
|
||||
self.cb_node = QComboBox()
|
||||
self.cb_node.setMinimumWidth(190)
|
||||
domain_row.addWidget(self.cb_node)
|
||||
self.btn_add = QPushButton("添加")
|
||||
self.btn_add.setObjectName("btnOk")
|
||||
self.btn_add.clicked.connect(self._add_rule)
|
||||
domain_row.addWidget(self.btn_add)
|
||||
layout.addLayout(domain_row)
|
||||
|
||||
middle = QHBoxLayout()
|
||||
self.btn_delete = QPushButton("删除选中")
|
||||
self.btn_delete.setObjectName("btnSmall")
|
||||
self.btn_delete.clicked.connect(self._delete_selected)
|
||||
middle.addWidget(self.btn_delete)
|
||||
middle.addStretch()
|
||||
layout.addLayout(middle)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
self.btn_cancel = QPushButton("取消")
|
||||
self.btn_cancel.setObjectName("btnSmall")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
self.btn_save = QPushButton("保存")
|
||||
self.btn_save.setObjectName("btnOk")
|
||||
self.btn_save.clicked.connect(self._save)
|
||||
buttons.addWidget(self.btn_save)
|
||||
layout.addLayout(buttons)
|
||||
|
||||
self._reload_nodes()
|
||||
for rule in services.load_rules():
|
||||
self._add_item(rule)
|
||||
self._on_action_changed()
|
||||
|
||||
def _reload_nodes(self):
|
||||
self.cb_node.clear()
|
||||
for node in self._services.available_nodes():
|
||||
self.cb_node.addItem(node["label"], node["ref"])
|
||||
if not self.cb_node.count():
|
||||
self.cb_node.addItem("暂无可用节点", None)
|
||||
|
||||
def _on_action_changed(self, *_):
|
||||
self.cb_node.setEnabled(self.cb_action.currentData() == "node")
|
||||
|
||||
def _add_item(self, rule):
|
||||
domain = str(rule.get("domain") or rule.get("value") or "").strip()
|
||||
if not domain:
|
||||
return
|
||||
action = str(rule.get("action") or "direct")
|
||||
node_label = str(rule.get("node_label") or "节点不可用")
|
||||
target = "强制不代理" if action == "direct" else f"节点代理:{node_label}"
|
||||
item = QListWidgetItem(f"{domain} → {target}")
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.Checked if rule.get("enabled", True) else Qt.Unchecked)
|
||||
item.setData(Qt.UserRole, {
|
||||
"domain": domain,
|
||||
"action": action,
|
||||
"node_ref": str(rule.get("node_ref") or ""),
|
||||
})
|
||||
self.list_widget.addItem(item)
|
||||
|
||||
def _add_rule(self):
|
||||
domain, error = self._services.validate_domain(self.ed_domain.text())
|
||||
if error:
|
||||
self.lb_err.setText(error)
|
||||
self.lb_err.show()
|
||||
return
|
||||
action = self.cb_action.currentData()
|
||||
node_ref = self.cb_node.currentData() if action == "node" else ""
|
||||
if action == "node" and not node_ref:
|
||||
self.lb_err.setText("请先配置至少一个可用节点")
|
||||
self.lb_err.show()
|
||||
return
|
||||
for index in range(self.list_widget.count()):
|
||||
existing = self.list_widget.item(index).data(Qt.UserRole) or {}
|
||||
if str(existing.get("domain") or "").lower() == domain.lower():
|
||||
self.lb_err.setText("该域名规则已存在")
|
||||
self.lb_err.show()
|
||||
return
|
||||
self.lb_err.hide()
|
||||
self._add_item({
|
||||
"domain": domain,
|
||||
"action": action,
|
||||
"node_ref": node_ref,
|
||||
"node_label": self.cb_node.currentText() if action == "node" else "",
|
||||
"enabled": True,
|
||||
})
|
||||
self.ed_domain.clear()
|
||||
|
||||
def _delete_selected(self):
|
||||
for item in self.list_widget.selectedItems():
|
||||
self.list_widget.takeItem(self.list_widget.row(item))
|
||||
|
||||
def _save(self):
|
||||
rules = []
|
||||
for index in range(self.list_widget.count()):
|
||||
item = self.list_widget.item(index)
|
||||
rule = dict(item.data(Qt.UserRole) or {})
|
||||
rule["enabled"] = item.checkState() == Qt.Checked
|
||||
rules.append(rule)
|
||||
self._services.save_rules(rules)
|
||||
self.accept()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JumpServices:
|
||||
"""代理跳板管理所需的节点摘要与配置回调。"""
|
||||
@@ -682,7 +856,7 @@ class SettingsServices:
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""代理端口、更新方式、白名单与登录状态设置框。"""
|
||||
"""代理端口、更新方式、域名路由与登录状态设置框。"""
|
||||
|
||||
def __init__(self, services: SettingsServices, parent=None, port_locked=False):
|
||||
super().__init__(parent)
|
||||
@@ -728,9 +902,9 @@ class SettingsDialog(QDialog):
|
||||
layout.addStretch()
|
||||
|
||||
whitelist_row = QHBoxLayout()
|
||||
whitelist_label = QLabel("代理白名单")
|
||||
whitelist_label = QLabel("域名路由")
|
||||
whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||
whitelist_label.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用")
|
||||
whitelist_label.setToolTip("为域名配置强制不代理,或强制经过指定节点;支持 *.example.com")
|
||||
whitelist_row.addWidget(whitelist_label)
|
||||
whitelist_row.addStretch()
|
||||
self.btn_whitelist = QPushButton("配置")
|
||||
|
||||
Reference in New Issue
Block a user