diff --git a/lite/config.json b/lite/config.json index 5991fb3..4a51fad 100644 --- a/lite/config.json +++ b/lite/config.json @@ -1,5 +1,5 @@ { - "version": "999909181618", + "version": "999909181706", "pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/", "nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client", "update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest" diff --git a/lite/main.py b/lite/main.py index fc8dc82..4b0e6f6 100644 --- a/lite/main.py +++ b/lite/main.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect, re, json, uuid +from datetime import datetime from pathlib import Path from PyQt5.QtWidgets import ( @@ -14,7 +15,8 @@ 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.widgets import NodeLabel, StatusDot, ToggleSwitch from rrpp_ui.dialogs import ( - LoginDialog, LoginServices, SettingsDialog, SettingsServices, ThirdPartyDialog, ThirdPartyServices, + LoginDialog, LoginServices, SettingsDialog, SettingsServices, LogDialog, LogServices, + ThirdPartyDialog, ThirdPartyServices, WhitelistDialog, WhitelistServices, FixedRouteDialog, FixedRouteServices, JumpDialog, JumpServices, ) @@ -933,17 +935,62 @@ def _stop_pac_server(): # ── 日志输出 ────────────────────────────────────────── # GUI 初始化后会把 _log_func 设为 MainWindow.log_signal.emit _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): """输出一行日志到 GUI 日志区。""" + _record_log(msg) if _log_func: _log_func(msg) + +def log_detail(msg: str): + """仅写入详细日志历史,不打扰主界面的简要日志区。""" + _record_log(msg) + + def log_red(msg: str): """输出一行红色警告日志到 GUI 日志区。""" + _record_log(msg) if _log_func: _log_func(f'{msg}') + +_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): @@ -2192,10 +2239,30 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, jump_chain=None): """处理一条客户端连接:内网(bypass_lan)/中国IP(智能分流)目标直接连接;其余走远端隧道。""" 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) buf = b"" is_http = None 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 direct_target = None allow_direct_fallback = False @@ -2226,16 +2293,24 @@ 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: is_connect = buf.startswith(b"CONNECT ") 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: target = _parse_connect_authority(first_line) else: target = _parse_http_absolute(first_line) 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( @@ -2247,22 +2322,44 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, 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( 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)) + 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: log(f"[转发器] 读取首包失败: {e}") + log_detail(f"[连接 #{connection_id}] 失败: 读取首包,目标={target_text},规则匹配={rule_match},错误={e}") client.close() return remote = None + up_bytes = 0 + down_bytes = 0 try: remote, via_tunnel = _connect_route( direct_target, allow_direct_fallback, route_remote_host, route_remote_port, ctx, use_tls, jump_chain=route_jump_chain) count_traffic = via_tunnel + log_detail(f"[连接 #{connection_id}] 连接成功: 目标={target_text},路径={route_summary},承载={'隧道' if via_tunnel else '直连'}") if not via_tunnel: if is_connect: client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") @@ -2278,10 +2375,12 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, ) if buf: remote.sendall(buf) + up_bytes += len(buf) if via_tunnel: _traffic_add(up=len(buf)) except Exception as e: log(f"[转发器] 连接目标失败: {e}") + log_detail(f"[连接 #{connection_id}] 连接失败: 目标={target_text},规则匹配={rule_match},路径={route_summary},错误={e}") try: client.close() except Exception: @@ -2298,6 +2397,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, # CONNECT 隧道承载 WebSocket / 推送等长连接,空闲属正常,不设超时; # 明文 HTTP 连接空闲 120 秒回收 idle_timeout = None if is_connect else 120 + close_reason = "对端关闭" try: while True: if isinstance(remote, _LayeredTlsTransport): @@ -2306,33 +2406,40 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, if data is None: break if not data: + close_reason = "上游关闭" return client.sendall(data) + down_bytes += len(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: + close_reason = f"空闲超时({idle_timeout} 秒)" break if client in r: data = client.recv(65536) if not data: + close_reason = "客户端关闭" break if injector is not None: data = injector(data) if data: remote.sendall(data) + up_bytes += len(data) if count_traffic: _traffic_add(up=len(data)) if remote_wait in r: data = remote.recv(65536) if not data: + close_reason = "上游关闭" break client.sendall(data) + down_bytes += len(data) if count_traffic: _traffic_add(down=len(data)) - except Exception: - pass + except Exception as e: + close_reason = f"转发异常: {e}" finally: try: client.close() @@ -2342,6 +2449,12 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, remote.close() except Exception: 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(): """停止 TLS 转发器监听线程。""" global _tls_forwarder @@ -2478,6 +2591,10 @@ def _open_login_dialog(parent=None): return False +def _log_services(): + return LogServices(load_icon=load_icon, get_logs=_log_history) + + def _logout_user_data(): """清除官方登录与节点缓存;第三方订阅保持不变。""" global _NODES @@ -2504,6 +2621,7 @@ def _settings_services(): 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_(), + open_logs=lambda parent: LogDialog(_log_services(), parent).exec_(), has_login=lambda: _auth_load() is not None, open_login=_open_login_dialog, logout=_logout_user_data, diff --git a/lite/rrpp_ui/dialogs.py b/lite/rrpp_ui/dialogs.py index 5537950..7e3dfd8 100644 --- a/lite/rrpp_ui/dialogs.py +++ b/lite/rrpp_ui/dialogs.py @@ -2,12 +2,13 @@ import json import threading +from datetime import datetime from dataclasses import dataclass -from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtCore import Qt, QTimer, pyqtSignal from PyQt5.QtWidgets import ( - QComboBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, - QPushButton, QVBoxLayout, QWidget, + QApplication, QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit, + 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 @@ -834,6 +835,94 @@ class ThirdPartyDialog(QDialog): 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) class SettingsServices: """设置框所需的配置与业务回调。""" @@ -850,6 +939,7 @@ class SettingsServices: open_whitelist: object open_jump: object open_third_party: object + open_logs: object has_login: object open_login: object logout: object @@ -944,6 +1034,17 @@ class SettingsDialog(QDialog): third_party_row.addWidget(self.btn_third_party) 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_label = QLabel("代理转发端口") port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") @@ -974,6 +1075,7 @@ class SettingsDialog(QDialog): self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志") skip_row.addWidget(self.cb_skip_update) layout.addLayout(skip_row) + layout.addLayout(log_row) layout.addStretch() buttons = QHBoxLayout() @@ -1026,6 +1128,9 @@ class SettingsDialog(QDialog): def _open_third_party(self): self._services.open_third_party(self) + def _open_logs(self): + self._services.open_logs(self) + def _save_settings(self): port = self._services.parse_port(self.ed_port.text()) if port is None: