Compare commits

..
3 changed files with 676 additions and 66 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "999909181435", "version": "999909181706",
"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"
+375 -41
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect, re, json, uuid import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect, re, json, uuid
from datetime import datetime
from pathlib import Path from pathlib import Path
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
@@ -14,8 +15,10 @@ from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
from rrpp_ui.theme import C_TITLE, C_GREEN, C_YELLOW, C_RED, C_BG, C_CARD, C_BORDER, C_TEXT, C_SUBTEXT from rrpp_ui.theme import C_TITLE, C_GREEN, C_YELLOW, C_RED, C_BG, C_CARD, C_BORDER, C_TEXT, C_SUBTEXT
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, LogDialog, LogServices,
WhitelistDialog, WhitelistServices, JumpDialog, JumpServices, ThirdPartyDialog, ThirdPartyServices,
WhitelistDialog, WhitelistServices, FixedRouteDialog, FixedRouteServices,
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,
@@ -815,11 +818,15 @@ def _endpoint_label():
return "无可用节点(请登录)" return "无可用节点(请登录)"
return f"{domain} ({_endpoint_country()})" return f"{domain} ({_endpoint_country()})"
def _proxy_auth_basic(): def _proxy_auth_basic(node=None):
"""正向代理 Basic 认证凭证:methods.http.{username,password}(新格式) """正向代理 Basic 认证凭证:methods.http.{username,password}(新格式)
或节点顶层同名字段(旧格式);用于转发器向上游代理注入 Authorization。""" 或节点顶层同名字段(旧格式);用于转发器向上游代理注入 Authorization。"""
if node is None:
_, get = _http_method() _, get = _http_method()
return base64.b64encode(f"{get('username')}:{get('password')}".encode()).decode() username, password = get("username"), get("password")
else:
username, password = _node_proxy_credentials(node)
return base64.b64encode(f"{username}:{password}".encode()).decode()
_tls_forwarder = None _tls_forwarder = None
_pac_server = None _pac_server = None
@@ -928,17 +935,62 @@ def _stop_pac_server():
# ── 日志输出 ────────────────────────────────────────── # ── 日志输出 ──────────────────────────────────────────
# GUI 初始化后会把 _log_func 设为 MainWindow.log_signal.emit # GUI 初始化后会把 _log_func 设为 MainWindow.log_signal.emit
_log_func = None _log_func = None
_LOG_HISTORY_LIMIT = 2000
_LOG_HISTORY = []
_LOG_HISTORY_LOCK = threading.RLock()
def _plain_log_message(msg: str) -> str:
"""去掉红色警告日志的 HTML 包装,供日志弹窗显示纯文本。"""
return re.sub(r"<[^>]+>", "", str(msg))
def _record_log(msg: str) -> str:
"""记录带毫秒时间戳的日志并返回弹窗使用的纯文本行。"""
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
line = f"[{timestamp}] {_plain_log_message(msg)}"
with _LOG_HISTORY_LOCK:
_LOG_HISTORY.append(line)
if len(_LOG_HISTORY) > _LOG_HISTORY_LIMIT:
del _LOG_HISTORY[:-_LOG_HISTORY_LIMIT]
return line
def _log_history() -> list[str]:
"""返回日志快照,避免日志弹窗读取时阻塞连接线程。"""
with _LOG_HISTORY_LOCK:
return list(_LOG_HISTORY)
def log(msg: str): def log(msg: str):
"""输出一行日志到 GUI 日志区。""" """输出一行日志到 GUI 日志区。"""
_record_log(msg)
if _log_func: if _log_func:
_log_func(msg) _log_func(msg)
def log_detail(msg: str):
"""仅写入详细日志历史,不打扰主界面的简要日志区。"""
_record_log(msg)
def log_red(msg: str): def log_red(msg: str):
"""输出一行红色警告日志到 GUI 日志区。""" """输出一行红色警告日志到 GUI 日志区。"""
_record_log(msg)
if _log_func: if _log_func:
_log_func(f'<span style="color:{C_RED};">{msg}</span>') _log_func(f'<span style="color:{C_RED};">{msg}</span>')
_CONNECTION_ID = 0
_CONNECTION_ID_LOCK = threading.Lock()
def _next_connection_id() -> int:
"""为每条转发连接分配单调递增的进程内编号。"""
global _CONNECTION_ID
with _CONNECTION_ID_LOCK:
_CONNECTION_ID += 1
return _CONNECTION_ID
# ── 通用工具函数 ────────────────────────────────────── # ── 通用工具函数 ──────────────────────────────────────
def _run(cmd, warn=True): def _run(cmd, warn=True):
@@ -1189,7 +1241,7 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma
suffix = f" [{route}]" if route else "" suffix = f" [{route}]" if route else ""
log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port}{suffix})") 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(如果尚未存在)。 """在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。
nginx 的 auth_basic 校验 Authorization 而非 Proxy-Authorization, nginx 的 auth_basic 校验 Authorization 而非 Proxy-Authorization,
@@ -1199,12 +1251,12 @@ def _inject_proxy_auth(data: bytes) -> bytes:
Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行, Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行,
保证上游(取首个 Authorization)读到的是我们的认证。 保证上游(取首个 Authorization)读到的是我们的认证。
""" """
node, _ = _http_method() node = proxy_node or _http_method()[0]
if not any(_node_proxy_credentials(node)): if not any(_node_proxy_credentials(node)):
return data 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 代理连接生成逐条注入认证的流式处理器。 """为明文 HTTP 代理连接生成逐条注入认证的流式处理器。
keep-alive 连接上的后续请求同样需要 Authorization,不能只在首包注入。 keep-alive 连接上的后续请求同样需要 Authorization,不能只在首包注入。
@@ -1220,7 +1272,7 @@ def _make_request_injector():
if not _starts_with_method(data): if not _starts_with_method(data):
return data return data
if b"\r\n\r\n" in data or len(data) >= 65536: 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 pending = data
return b"" return b""
return feed return feed
@@ -1339,38 +1391,187 @@ def _set_skip_update_check(enabled):
_settings_save(data) _settings_save(data)
log(f"检查更新已{'跳过' if enabled else '恢复'}(PAC 与程序版本;节点更新不受影响)") log(f"检查更新已{'跳过' if enabled else '恢复'}(PAC 与程序版本;节点更新不受影响)")
# ── 代理白名单(用户可配置,强制直连,settings.json 的 whitelist 键) ─────────
# 每条规则 {"value": 域名/IP/CIDR, "enabled": 是否启用},命中即直连(不经过代理)。 def _use_pac_mode():
# 规则保存在内存缓存 _WHITELIST,连接建立时直接读取,不落盘回读; """是否默认启用 PAC 智能分流;缺失时保持原有默认值 True。"""
# _WHITELIST_VERSION 在保存时 +1,使按 (host, version) 键控的匹配缓存失效。 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 = None # 内存缓存:规范化后的规则列表
_WHITELIST_VERSION = 0 # 规则版本号(保存时 +1,用于匹配缓存失效) _WHITELIST_VERSION = 0 # 规则版本号(保存时 +1,用于匹配缓存失效)
_ROUTE_RULES = None # 全量域名路由规则(含 direct/node)
def _whitelist_rules():
"""白名单规则列表(内存缓存,首次读取 settings.json;保存时同步更新)。""" def _route_domain_normalize(entry: str) -> str:
global _WHITELIST """规范化域名规则,保留 ``*.`` 通配符,兼容旧白名单输入。"""
if _WHITELIST is None: 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") raw = _settings_load().get("whitelist")
if not isinstance(raw, list): if not isinstance(raw, list):
raw = [] raw = []
_WHITELIST = [{"value": str(r.get("value") or "").strip().lower(), result = []
"enabled": bool(r.get("enabled", True))} for item in raw:
for r in raw if isinstance(r, dict) and str(r.get("value") or "").strip()] if not isinstance(item, dict):
return _WHITELIST 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):
"""保存白名单规则(规范化 + 去空白条目),更新内存缓存并记录日志。""" def _route_rule_for_host(host: str):
global _WHITELIST, _WHITELIST_VERSION """返回目标域名命中的最高优先级规则;规则变更对新连接立即生效。"""
clean = [{"value": _whitelist_normalize(r.get("value") or ""), matches = []
"enabled": bool(r.get("enabled", True))} for rule in _route_rules():
for r in rules if _whitelist_normalize(r.get("value") or "")] 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 = _settings_load()
data["whitelist"] = clean data["whitelist"] = clean
_settings_save(data) _settings_save(data)
_WHITELIST = clean _ROUTE_RULES = None
_WHITELIST = None
_WHITELIST_VERSION += 1 _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: def _whitelist_normalize(entry: str) -> str:
"""条目规范化:去协议头/通配符/末尾点,统一小写。""" """条目规范化:去协议头/通配符/末尾点,统一小写。"""
@@ -1407,6 +1608,23 @@ def _whitelist_validate_entry(entry: str):
return "", "IP 格式不正确" return "", "IP 格式不正确"
return s, "" 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): def _ver_tuple(v):
"""版本号转数字元组:'v0.1.0' -> (0, 1, 0),无法解析的段按 0 处理。""" """版本号转数字元组:'v0.1.0' -> (0, 1, 0),无法解析的段按 0 处理。"""
parts = [] parts = []
@@ -2021,13 +2239,38 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
jump_chain=None): jump_chain=None):
"""处理一条客户端连接:内网(bypass_lan)/中国IP(智能分流)目标直接连接;其余走远端隧道。""" """处理一条客户端连接:内网(bypass_lan)/中国IP(智能分流)目标直接连接;其余走远端隧道。"""
import socket, select import socket, select
connection_id = _next_connection_id()
started_at = time.monotonic()
try:
peer = client.getpeername()
peer_text = f"{peer[0]}:{peer[1]}" if isinstance(peer, tuple) and len(peer) >= 2 else str(peer)
except Exception:
peer_text = "未知"
def _chain_detail(chain):
return "→".join(
f"{node.get('domain') or '?'}:{_node_http_port(node)}"
for node in (chain or [])
)
client.settimeout(2.0) client.settimeout(2.0)
buf = b"" buf = b""
is_http = None is_http = None
is_connect = False is_connect = False
first_line = ""
target = None
request_type = "未知请求"
target_text = "未解析"
rule_match = "否"
route_summary = f"上游节点 {_chain_detail(jump_chain) or f'{remote_host}:{remote_port}'}"
injector = None injector = None
direct_target = None direct_target = None
allow_direct_fallback = False 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 max_headers = 65536
try: try:
while len(buf) < max_headers: while len(buf) < max_headers:
@@ -2050,26 +2293,73 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
if is_http and b"\r\n\r\n" in buf: if is_http and b"\r\n\r\n" in buf:
is_connect = buf.startswith(b"CONNECT ") is_connect = buf.startswith(b"CONNECT ")
first_line = buf.split(b"\r\n", 1)[0].decode("latin-1", "replace") first_line = buf.split(b"\r\n", 1)[0].decode("latin-1", "replace")
request_type = "CONNECT" if is_connect else (first_line.split(" ", 1)[0] or "HTTP")
if is_connect: if is_connect:
target = _parse_connect_authority(first_line) target = _parse_connect_authority(first_line)
else: else:
target = _parse_http_absolute(first_line) target = _parse_http_absolute(first_line)
if target: if target:
target_text = f"{target[0]}:{target[1]}"
log_detail(f"[连接 #{connection_id}] 收到请求: {request_type} {target_text},客户端={peer_text}")
forced_rule = _route_rule_for_host(target[0])
if forced_rule and forced_rule.get("action") == "direct":
rule_match = f"是({forced_rule.get('domain') or target[0]},强制直连)"
log_detail(f"[连接 #{connection_id}] 规则匹配: {rule_match}")
# 显式强制不代理优先于 PAC 的域名表和 IP 判定。
direct_target = (target[0], target[1])
route_summary = f"强制直连 {target_text}"
elif forced_rule and forced_rule.get("action") == "node":
rule_match = f"是({forced_rule.get('domain') or target[0]},强制节点)"
log_detail(f"[连接 #{connection_id}] 规则匹配: {rule_match}")
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
route_summary = (
f"强制多跳 {_chain_detail(route_jump_chain)}"
if route_jump_chain else
f"强制节点 {route_remote_host}:{route_remote_port}"
)
else:
log_detail(f"[连接 #{connection_id}] 规则匹配: 否(未命中自定义域名路由)")
direct_target = _direct_connect_target( direct_target = _direct_connect_target(
target[0], target[1], bypass_lan, smart) target[0], target[1], bypass_lan, smart)
allow_direct_fallback = (direct_target is not None and allow_direct_fallback = (direct_target is not None and
_is_smart_china_direct(target[0], bypass_lan, smart)) _is_smart_china_direct(target[0], bypass_lan, smart))
if direct_target:
policy = "PAC/智能分流直连" if allow_direct_fallback else "直连策略"
route_summary = f"{policy} {direct_target[0]}:{direct_target[1]}"
else:
route_summary = (
f"多跳上游 {_chain_detail(route_jump_chain)}"
if route_jump_chain else
f"上游节点 {route_remote_host}:{route_remote_port}"
)
log_detail(f"[连接 #{connection_id}] 路由决策: {route_summary},TLS={'是' if use_tls else '否'}")
else:
log_detail(f"[连接 #{connection_id}] 无法解析目标: {first_line or '空请求'},客户端={peer_text}")
except Exception as e: except Exception as e:
log(f"[转发器] 读取首包失败: {e}") log(f"[转发器] 读取首包失败: {e}")
log_detail(f"[连接 #{connection_id}] 失败: 读取首包,目标={target_text},规则匹配={rule_match},错误={e}")
client.close() client.close()
return return
remote = None remote = None
up_bytes = 0
down_bytes = 0
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, route_remote_host, route_remote_port, ctx, use_tls,
jump_chain=jump_chain) jump_chain=route_jump_chain)
count_traffic = via_tunnel count_traffic = via_tunnel
log_detail(f"[连接 #{connection_id}] 连接成功: 目标={target_text},路径={route_summary},承载={'隧道' if via_tunnel else '直连'}")
if not via_tunnel: if not via_tunnel:
if is_connect: if is_connect:
client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
@@ -2079,14 +2369,18 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
else: else:
if is_http and b"\r\n\r\n" in buf: if is_http and b"\r\n\r\n" in buf:
if not is_connect: if not is_connect:
injector = _make_request_injector() injector = _make_request_injector(_terminal_node(forced_node) if forced_node else None)
buf = _inject_proxy_auth(buf) buf = _inject_proxy_auth(
buf, _terminal_node(forced_node) if forced_node else None
)
if buf: if buf:
remote.sendall(buf) remote.sendall(buf)
up_bytes += len(buf)
if via_tunnel: if via_tunnel:
_traffic_add(up=len(buf)) _traffic_add(up=len(buf))
except Exception as e: except Exception as e:
log(f"[转发器] 连接目标失败: {e}") log(f"[转发器] 连接目标失败: {e}")
log_detail(f"[连接 #{connection_id}] 连接失败: 目标={target_text},规则匹配={rule_match},路径={route_summary},错误={e}")
try: try:
client.close() client.close()
except Exception: except Exception:
@@ -2103,6 +2397,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
# CONNECT 隧道承载 WebSocket / 推送等长连接,空闲属正常,不设超时; # CONNECT 隧道承载 WebSocket / 推送等长连接,空闲属正常,不设超时;
# 明文 HTTP 连接空闲 120 秒回收 # 明文 HTTP 连接空闲 120 秒回收
idle_timeout = None if is_connect else 120 idle_timeout = None if is_connect else 120
close_reason = "对端关闭"
try: try:
while True: while True:
if isinstance(remote, _LayeredTlsTransport): if isinstance(remote, _LayeredTlsTransport):
@@ -2111,33 +2406,40 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
if data is None: if data is None:
break break
if not data: if not data:
close_reason = "上游关闭"
return return
client.sendall(data) client.sendall(data)
down_bytes += len(data)
if count_traffic: if count_traffic:
_traffic_add(down=len(data)) _traffic_add(down=len(data))
remote_wait = remote.wait_socket() if hasattr(remote, "wait_socket") else remote remote_wait = remote.wait_socket() if hasattr(remote, "wait_socket") else remote
r, _, _ = select.select([client, remote_wait], [], [], idle_timeout) r, _, _ = select.select([client, remote_wait], [], [], idle_timeout)
if not r: if not r:
close_reason = f"空闲超时({idle_timeout} 秒)"
break break
if client in r: if client in r:
data = client.recv(65536) data = client.recv(65536)
if not data: if not data:
close_reason = "客户端关闭"
break break
if injector is not None: if injector is not None:
data = injector(data) data = injector(data)
if data: if data:
remote.sendall(data) remote.sendall(data)
up_bytes += len(data)
if count_traffic: if count_traffic:
_traffic_add(up=len(data)) _traffic_add(up=len(data))
if remote_wait in r: if remote_wait in r:
data = remote.recv(65536) data = remote.recv(65536)
if not data: if not data:
close_reason = "上游关闭"
break break
client.sendall(data) client.sendall(data)
down_bytes += len(data)
if count_traffic: if count_traffic:
_traffic_add(down=len(data)) _traffic_add(down=len(data))
except Exception: except Exception as e:
pass close_reason = f"转发异常: {e}"
finally: finally:
try: try:
client.close() client.close()
@@ -2147,6 +2449,12 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
remote.close() remote.close()
except Exception: except Exception:
pass pass
elapsed = time.monotonic() - started_at
log_detail(
f"[连接 #{connection_id}] 已关闭: 目标={target_text},规则匹配={rule_match},"
f"原因={close_reason},上行={_fmt_bytes(up_bytes)},下行={_fmt_bytes(down_bytes)},"
f"持续={elapsed:.3f} 秒"
)
def _tls_stop(): def _tls_stop():
"""停止 TLS 转发器监听线程。""" """停止 TLS 转发器监听线程。"""
global _tls_forwarder global _tls_forwarder
@@ -2239,9 +2547,28 @@ def _login_services():
def _whitelist_services(): def _whitelist_services():
return WhitelistServices( return WhitelistServices(
load_icon=load_icon, load_icon=load_icon,
load_rules=_whitelist_rules, load_rules=lambda: [
validate_rule=_whitelist_validate_entry, {"value": item["domain"], "enabled": item["enabled"]}
save_rules=_whitelist_save, 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,
) )
@@ -2264,6 +2591,10 @@ def _open_login_dialog(parent=None):
return False return False
def _log_services():
return LogServices(load_icon=load_icon, get_logs=_log_history)
def _logout_user_data(): def _logout_user_data():
"""清除官方登录与节点缓存;第三方订阅保持不变。""" """清除官方登录与节点缓存;第三方订阅保持不变。"""
global _NODES global _NODES
@@ -2287,9 +2618,10 @@ def _settings_services():
set_port=_set_local_port, set_port=_set_local_port,
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: FixedRouteDialog(_fixed_route_services(), parent).exec_(),
open_jump=lambda parent: JumpDialog(_jump_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_(),
open_logs=lambda parent: LogDialog(_log_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,
@@ -2423,13 +2755,15 @@ class MainWindow(QWidget):
pac_layout.setSpacing(8) pac_layout.setSpacing(8)
pac_layout.addStretch() pac_layout.addStretch()
self.cb_bypass_lan = QCheckBox("不代理内网 IP") 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.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
self.cb_bypass_lan.toggled.connect(_set_bypass_lan)
pac_layout.addWidget(self.cb_bypass_lan) pac_layout.addWidget(self.cb_bypass_lan)
self.cb_pac = QCheckBox("PAC 规则模式") 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.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
self.cb_pac.setToolTip("只代理海外服务,使用域名和解析 IP 匹配。") self.cb_pac.setToolTip("只代理海外服务,使用域名和解析 IP 匹配。")
self.cb_pac.toggled.connect(_set_use_pac_mode)
pac_layout.addWidget(self.cb_pac) pac_layout.addWidget(self.cb_pac)
pac_layout.addStretch() pac_layout.addStretch()
layout.addLayout(pac_layout) layout.addLayout(pac_layout)
+295 -19
View File
@@ -2,12 +2,13 @@
import json import json
import threading import threading
from datetime import datetime
from dataclasses import dataclass from dataclasses import dataclass
from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QApplication, QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QVBoxLayout, QWidget, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget, QTextEdit,
) )
from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE
@@ -292,6 +293,180 @@ class WhitelistDialog(QDialog):
self.accept() 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) @dataclass(frozen=True)
class JumpServices: class JumpServices:
"""代理跳板管理所需的节点摘要与配置回调。""" """代理跳板管理所需的节点摘要与配置回调。"""
@@ -660,6 +835,94 @@ class ThirdPartyDialog(QDialog):
self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;") self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;")
@dataclass(frozen=True)
class LogServices:
"""运行日志框所需的显式业务依赖。"""
load_icon: object
get_logs: object
class LogDialog(QDialog):
"""显示带时间戳的运行日志;窗口打开时会持续读取最新日志。"""
def __init__(self, services: LogServices, parent=None):
super().__init__(parent)
self._services = services
self.setWindowTitle("运行日志")
self.setWindowIcon(services.load_icon())
self.resize(760, 500)
self.setMinimumSize(560, 320)
self.setStyleSheet(f"""
QDialog {{ background-color: {C_BG}; }}
QLabel {{ color: {C_SUBTEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 11px; }}
QTextEdit {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
padding: 6px 8px; color: {C_TEXT}; font-family: Consolas, "Microsoft YaHei", monospace; font-size: 11px; }}
QPushButton {{ background-color: #d9dde3; color: #4f5965; border: none; border-radius: 6px;
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
QPushButton:hover {{ background-color: #cdd3da; color: #3f4853; }}
""")
layout = QVBoxLayout(self)
layout.setSpacing(8)
layout.setContentsMargins(14, 12, 14, 12)
hint = QLabel("显示当前运行期间的最近日志,认证信息不会写入日志。")
layout.addWidget(hint)
self.text = QTextEdit()
self.text.setReadOnly(True)
self.text.setLineWrapMode(QTextEdit.NoWrap)
layout.addWidget(self.text, 1)
buttons = QHBoxLayout()
buttons.setSpacing(8)
buttons.addStretch()
copy = QPushButton("复制")
copy.clicked.connect(self._copy_logs)
buttons.addWidget(copy)
export = QPushButton("导出")
export.clicked.connect(self._export_logs)
buttons.addWidget(export)
close = QPushButton("关闭")
close.clicked.connect(self.accept)
buttons.addWidget(close)
layout.addLayout(buttons)
self._last_text = None
self._refresh()
self._timer = QTimer(self)
self._timer.timeout.connect(self._refresh)
self._timer.start(500)
def _log_text(self):
text = self.text.toPlainText()
return "" if text == "暂无日志" else text
def _copy_logs(self):
QApplication.clipboard().setText(self._log_text())
def _export_logs(self):
default_name = f"rrpp-log-{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt"
path, _ = QFileDialog.getSaveFileName(
self, "导出运行日志", default_name, "文本文件 (*.txt);;所有文件 (*.*)"
)
if not path:
return
with open(path, "w", encoding="utf-8", newline="\n") as file:
file.write(self._log_text())
def _refresh(self):
text = "\n".join(self._services.get_logs()) or "暂无日志"
if text == self._last_text:
return
scrollbar = self.text.verticalScrollBar()
follow_tail = scrollbar.value() >= scrollbar.maximum() - 4
self.text.setPlainText(text)
self._last_text = text
if follow_tail:
scrollbar.setValue(scrollbar.maximum())
@dataclass(frozen=True) @dataclass(frozen=True)
class SettingsServices: class SettingsServices:
"""设置框所需的配置与业务回调。""" """设置框所需的配置与业务回调。"""
@@ -676,20 +939,21 @@ class SettingsServices:
open_whitelist: object open_whitelist: object
open_jump: object open_jump: object
open_third_party: object open_third_party: object
open_logs: object
has_login: object has_login: object
open_login: object open_login: object
logout: object logout: object
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
"""代理端口、更新方式、白名单与登录状态设置框。""" """代理端口、更新方式、域名路由与登录状态设置框。"""
def __init__(self, services: SettingsServices, parent=None, port_locked=False): def __init__(self, services: SettingsServices, parent=None, port_locked=False):
super().__init__(parent) super().__init__(parent)
self._services = services self._services = services
self.setWindowTitle("设置") self.setWindowTitle("设置")
self.setWindowIcon(services.load_icon()) self.setWindowIcon(services.load_icon())
self.setFixedSize(300, 430) self.setFixedSize(300, 350)
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; }}
@@ -697,8 +961,8 @@ class SettingsDialog(QDialog):
padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }} padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }}
""") """)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setSpacing(12) layout.setSpacing(10)
layout.setContentsMargins(20, 16, 20, 16) layout.setContentsMargins(20, 10, 20, 10)
title = QLabel("设置") title = QLabel("设置")
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;") title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
@@ -728,9 +992,9 @@ class SettingsDialog(QDialog):
layout.addStretch() layout.addStretch()
whitelist_row = QHBoxLayout() whitelist_row = QHBoxLayout()
whitelist_label = QLabel("代理白名单") whitelist_label = QLabel("域名路由")
whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") 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.addWidget(whitelist_label)
whitelist_row.addStretch() whitelist_row.addStretch()
self.btn_whitelist = QPushButton("配置") self.btn_whitelist = QPushButton("配置")
@@ -750,7 +1014,7 @@ class SettingsDialog(QDialog):
jump_label.setToolTip("把当前可用节点串成 2-3 跳链路;最后一个节点作为对外出口") jump_label.setToolTip("把当前可用节点串成 2-3 跳链路;最后一个节点作为对外出口")
jump_row.addWidget(jump_label) jump_row.addWidget(jump_label)
jump_row.addStretch() jump_row.addStretch()
self.btn_jump = QPushButton("添加") self.btn_jump = QPushButton("配置")
self.btn_jump.setCursor(Qt.PointingHandCursor) self.btn_jump.setCursor(Qt.PointingHandCursor)
self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet()) self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet())
self.btn_jump.clicked.connect(self._open_jump) self.btn_jump.clicked.connect(self._open_jump)
@@ -758,18 +1022,29 @@ class SettingsDialog(QDialog):
layout.addLayout(jump_row) 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;")
third_party_label.setToolTip("添加、更新或删除第三方 RRPP Client JSON 订阅") third_party_label.setToolTip("添加、更新或删除第三方 RRPP Client JSON 订阅")
third_party_row.addWidget(third_party_label) third_party_row.addWidget(third_party_label)
third_party_row.addStretch() third_party_row.addStretch()
self.btn_third_party = QPushButton("添加") self.btn_third_party = QPushButton("配置")
self.btn_third_party.setCursor(Qt.PointingHandCursor) self.btn_third_party.setCursor(Qt.PointingHandCursor)
self.btn_third_party.setStyleSheet(self.btn_whitelist.styleSheet()) self.btn_third_party.setStyleSheet(self.btn_whitelist.styleSheet())
self.btn_third_party.clicked.connect(self._open_third_party) self.btn_third_party.clicked.connect(self._open_third_party)
third_party_row.addWidget(self.btn_third_party) third_party_row.addWidget(self.btn_third_party)
layout.addLayout(third_party_row) layout.addLayout(third_party_row)
log_row = QHBoxLayout()
log_label = QLabel("日志")
log_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
log_label.setToolTip("查看连接域名、规则匹配、路由、错误与流量等运行信息")
log_row.addWidget(log_label)
log_row.addStretch()
self.btn_logs = QPushButton("查看")
self.btn_logs.setCursor(Qt.PointingHandCursor)
self.btn_logs.setStyleSheet(self.btn_whitelist.styleSheet())
self.btn_logs.clicked.connect(self._open_logs)
log_row.addWidget(self.btn_logs)
port_row = QHBoxLayout() port_row = QHBoxLayout()
port_label = QLabel("代理转发端口") port_label = QLabel("代理转发端口")
port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
@@ -800,6 +1075,7 @@ class SettingsDialog(QDialog):
self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志") self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
skip_row.addWidget(self.cb_skip_update) skip_row.addWidget(self.cb_skip_update)
layout.addLayout(skip_row) layout.addLayout(skip_row)
layout.addLayout(log_row)
layout.addStretch() layout.addStretch()
buttons = QHBoxLayout() buttons = QHBoxLayout()
@@ -807,19 +1083,16 @@ class SettingsDialog(QDialog):
buttons.addStretch() buttons.addStretch()
self.btn_save = QPushButton("保存更改") self.btn_save = QPushButton("保存更改")
self.btn_save.setStyleSheet(f""" self.btn_save.setStyleSheet(f"""
QPushButton {{ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px; QPushButton {{ background-color: #d9dde3; color: #4f5965; border: none; border-radius: 6px;
padding: 5px 14px; font-size: 12px; font-weight: bold; }} padding: 5px 14px; font-size: 12px; font-weight: bold; }}
QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }} QPushButton:hover {{ background-color: #cdd3da; color: #3f4853; }}
QPushButton:disabled {{ background-color: #eceff2; color: #a8afb8; }}
""") """)
self.btn_save.setEnabled(not port_locked) self.btn_save.setEnabled(not port_locked)
self.btn_save.clicked.connect(self._save_settings) self.btn_save.clicked.connect(self._save_settings)
buttons.addWidget(self.btn_save) buttons.addWidget(self.btn_save)
self.btn_logout = QPushButton("退出登录" if services.has_login() else "登录") self.btn_logout = QPushButton("退出登录" if services.has_login() else "登录")
self.btn_logout.setStyleSheet(f""" self.btn_logout.setStyleSheet(self.btn_save.styleSheet())
QPushButton {{ background-color: {C_RED}; color: #fff; border: none; border-radius: 6px;
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
QPushButton:hover {{ background-color: #ff8787; }}
""")
self.btn_logout.setToolTip("登录获取官方节点;退出登录不会删除第三方订阅") self.btn_logout.setToolTip("登录获取官方节点;退出登录不会删除第三方订阅")
self.btn_logout.clicked.connect(self._logout) self.btn_logout.clicked.connect(self._logout)
buttons.addWidget(self.btn_logout) buttons.addWidget(self.btn_logout)
@@ -855,6 +1128,9 @@ class SettingsDialog(QDialog):
def _open_third_party(self): def _open_third_party(self):
self._services.open_third_party(self) self._services.open_third_party(self)
def _open_logs(self):
self._services.open_logs(self)
def _save_settings(self): def _save_settings(self):
port = self._services.parse_port(self.ed_port.text()) port = self._services.parse_port(self.ed_port.text())
if port is None: if port is None: