From 61a59a78727c9e75d8ab4113378b250bcf3bdbd3 Mon Sep 17 00:00:00 2001 From: RoyirinRosmontis Date: Sun, 23 Aug 2026 13:10:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?=20-=20=E8=8A=82=E7=82=B9=E9=85=8D=E7=BD=AE=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=EF=BC=88LoginDialog=EF=BC=8C=E9=A3=8E?= =?UTF-8?q?=E6=A0=BC=E4=B8=8E=E4=B8=BB=E7=95=8C=E9=9D=A2=E4=B8=80=E8=87=B4?= =?UTF-8?q?=EF=BC=89=EF=BC=9Anodes.json=20=E6=9C=AA=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=97=B6=E5=90=AF=E5=8A=A8=E5=90=8E=E8=87=AA=E5=8A=A8=E5=BC=B9?= =?UTF-8?q?=E5=87=BA=EF=BC=8C=E7=82=B9=E5=87=BB=E5=BC=80=E5=90=AF=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=97=B6=E4=B9=9F=E4=BC=9A=E5=BC=B9=E5=87=BA=20-=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=87=AD=E6=8D=AE=E4=BB=A5=20Basic=20?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E8=AF=B7=E6=B1=82=20config.json=20=E7=9A=84?= =?UTF-8?q?=20nodes=5Furl=EF=BC=88=E5=8F=82=E4=B8=8E=20nginx=20=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=EF=BC=89=EF=BC=8C=E6=88=90=E5=8A=9F=E5=90=8E=E6=8B=89?= =?UTF-8?q?=E5=8F=96=20nodes.json=20=E5=B9=B6=20DPAPI=20=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=88=B0=20%LOCALAPPDATA%\RRPP=EF=BC=8C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=E6=A0=87=E9=A2=98=20-=2040?= =?UTF-8?q?1=20=E6=98=BE=E7=A4=BA"=E8=AE=A4=E8=AF=81=E5=A4=B1=E8=B4=A5"?= =?UTF-8?q?=E3=80=81=E9=9D=9E=E6=B3=95=E8=BF=94=E5=9B=9E=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E5=86=99=E5=85=A5=E3=80=81=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E6=9C=9F=E9=97=B4=E6=8C=89=E9=92=AE=E7=A6=81=E7=94=A8=E9=98=B2?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=8F=90=E4=BA=A4=20-=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=8E=9F=E5=85=88=E6=97=A0=E8=AE=A4=E8=AF=81=E7=9A=84=20nodes?= =?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E6=8B=89=E5=8F=96=EF=BC=88=E8=AF=A5?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=B7=B2=E6=94=B9=E4=B8=BA=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=EF=BC=8C=E9=81=BF=E5=85=8D=E6=AF=8F=E6=AC=A1?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=20401=20=E5=99=AA=E9=9F=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lite/proxy_lite.py | 186 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 158 insertions(+), 28 deletions(-) diff --git a/lite/proxy_lite.py b/lite/proxy_lite.py index a4593e4..7541956 100644 --- a/lite/proxy_lite.py +++ b/lite/proxy_lite.py @@ -6,7 +6,7 @@ from pathlib import Path from PyQt5.QtWidgets import ( QApplication, QWidget, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit, - QCheckBox, QMenu, + QCheckBox, QMenu, QDialog, QLineEdit, ) from PyQt5.QtCore import Qt, QTimer, pyqtSignal from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor @@ -663,6 +663,17 @@ def _nodes_decrypt(blob: bytes) -> str: except Exception: return blob.decode("utf-8") +def _fetch_nodes_auth(url, user, password, timeout=10): + """带 Basic 认证拉取 nodes.json(参与 nginx 认证;直连,不经过系统代理)。""" + import json + import urllib.request + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + req = urllib.request.Request(url) + req.add_header("Authorization", "Basic " + base64.b64encode( + f"{user}:{password}".encode()).decode()) + with opener.open(req, timeout=timeout) as r: + return json.loads(r.read().decode("utf-8")) + def _user_init_local(): """初始化用户数据目录(同步):建目录,nodes.json 缺失时写入加密空模板。""" @@ -676,10 +687,10 @@ def _user_init_local(): log_red(f"已创建 {_USER_DIR / 'nodes.json'}(加密存储),请配置 config.json 的 " f"nodes_url 在线更新,或使用 others/nodes_tool 导入节点配置") -def _user_init_remote(base_url=None, nodes_url=None): - """检查并更新 PAC 数据与节点配置(后台线程): - PAC 直接拉取两个数据文件(内含 version/date),按版本比对后更新;失败用本地副本。 - nodes 按内容比对(独立地址)。 +def _user_init_remote(base_url=None): + """检查并更新 PAC 数据(后台线程): + 直接拉取两个数据文件(内含 version/date),按版本比对后更新;失败用本地副本。 + 节点配置的拉取走登录框(nodes_url 需 nginx 认证,见 LoginDialog)。 """ base = (base_url or str(_config_val("pac_base_url", "") or "")).strip().rstrip("/") if not base: @@ -716,29 +727,6 @@ def _user_init_remote(base_url=None, nodes_url=None): failed += 1 if updated == 0 and failed == 0 and (_USER_DIR / "pac_domains.json").is_file(): log("PAC 数据已是最新") - # 节点配置在线更新:地址独立于 PAC,按内容比对,仅变化时原子覆盖 - nurl = (nodes_url or str(_config_val("nodes_url", "") or "")).strip() - if not nurl: - return - try: - import json - data = _pac_fetch_json(nurl) - if not (isinstance(data, list) and data - and all(isinstance(n, dict) for n in data)): - raise ValueError("nodes.json 数据格式不正确") - new_bytes = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + b"\n" - local = _USER_DIR / "nodes.json" - if local.is_file() and local.read_bytes() == new_bytes: - return # 内容无变化 - _USER_DIR.mkdir(parents=True, exist_ok=True) - tmp = _USER_DIR / "nodes.json.tmp" - tmp.write_bytes(new_bytes) - os.replace(tmp, local) - global _NODES - _NODES = None # 使后续读取(节点菜单等)拿到新配置 - log(f"节点配置已更新: {len(data)} 个节点 (nodes.json)") - except Exception as e: - log(f"节点配置在线更新失败,使用本地配置: {e}") def _load_cn_ranges(): """加载 pac_ip_cn.json 中的中国 IPv4 段,合并为整数区间供二分查找。""" @@ -1125,6 +1113,127 @@ class NodeLabel(QLabel): self.clicked.emit() super().mouseReleaseEvent(e) +# ── 节点配置登录框 ───────────────────────────────────── + +class LoginDialog(QDialog): + """节点配置登录框:凭据参与 nodes_url 的 nginx 认证,成功后拉取并加密保存 nodes.json。""" + _done = pyqtSignal(bool, str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("节点配置登录") + self.setWindowIcon(load_icon()) + self.setFixedSize(300, 232) + self.setStyleSheet(f""" + QDialog {{ background-color: {C_BG}; }} + QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} + QLineEdit {{ + background-color: {C_CARD}; + border: 1px solid {C_BORDER}; + border-radius: 6px; + padding: 6px 8px; + color: {C_TEXT}; + font-size: 12px; + }} + QPushButton#btnOk {{ + background-color: {C_GREEN}; color: #fff; border: none; + border-radius: 6px; padding: 7px 18px; + font-size: 13px; font-weight: bold; + }} + QPushButton#btnOk:hover {{ background-color: #5ed978; }} + QPushButton#btnOk:disabled {{ background-color: #2a3a2e; color: #666; }} + QPushButton#btnCancel {{ + background-color: {C_CARD}; color: {C_SUBTEXT}; + border: 1px solid {C_BORDER}; border-radius: 6px; + padding: 7px 18px; font-size: 13px; + }} + """) + + layout = QVBoxLayout(self) + layout.setSpacing(10) + layout.setContentsMargins(20, 16, 20, 16) + + title = QLabel("节点配置登录") + title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + hint = QLabel("登录后自动获取节点配置") + hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") + hint.setAlignment(Qt.AlignCenter) + layout.addWidget(hint) + + self.ed_user = QLineEdit() + self.ed_user.setPlaceholderText("用户名") + layout.addWidget(self.ed_user) + + self.ed_pass = QLineEdit() + self.ed_pass.setPlaceholderText("密码") + self.ed_pass.setEchoMode(QLineEdit.Password) + self.ed_pass.returnPressed.connect(self._submit) + layout.addWidget(self.ed_pass) + + self.lb_err = QLabel("") + self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;") + self.lb_err.setAlignment(Qt.AlignCenter) + layout.addWidget(self.lb_err) + + btns = QHBoxLayout() + btns.addStretch() + self.btn_cancel = QPushButton("取消") + self.btn_cancel.setObjectName("btnCancel") + self.btn_cancel.clicked.connect(self.reject) + btns.addWidget(self.btn_cancel) + self.btn_ok = QPushButton("登录") + self.btn_ok.setObjectName("btnOk") + self.btn_ok.clicked.connect(self._submit) + btns.addWidget(self.btn_ok) + btns.addStretch() + layout.addLayout(btns) + + self._done.connect(self._on_done) + + def _submit(self): + user = self.ed_user.text().strip() + password = self.ed_pass.text() + if not user or not password: + self.lb_err.setText("请输入用户名和密码") + return + self.btn_ok.setEnabled(False) + self.btn_cancel.setEnabled(False) + self.lb_err.setText("正在登录...") + threading.Thread(target=self._worker, args=(user, password), daemon=True).start() + + def _worker(self, user, password): + """后台登录:Basic 认证拉取 nodes.json,校验后加密写入用户数据目录。""" + try: + import json + url = str(_config_val("nodes_url", "") or "").strip() + if not url: + raise RuntimeError("config.json 未配置 nodes_url") + data = _fetch_nodes_auth(url, user, password) + if not (isinstance(data, list) and data + and all(isinstance(n, dict) for n in data) + and str(data[0].get("domain") or "").strip()): + raise ValueError("返回的节点数据无效") + text = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + _USER_DIR.mkdir(parents=True, exist_ok=True) + (_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(text)) + self._done.emit(True, str(len(data))) + except Exception as e: + msg = ("认证失败,请检查用户名/密码" if getattr(e, "code", None) == 401 + else f"登录失败: {e}") + self._done.emit(False, msg) + + def _on_done(self, ok, info): + self.btn_ok.setEnabled(True) + self.btn_cancel.setEnabled(True) + if ok: + log(f"登录成功,已获取 {info} 个节点(nodes.json 已加密保存)") + self.accept() + else: + self.lb_err.setText(info) + # ── 状态指示灯(小圆点) ─────────────────────────────── class StatusDot(QLabel): @@ -1197,6 +1306,8 @@ class MainWindow(QWidget): threading.Thread(target=self._check_update, daemon=True).start() # 后台检查 PAC 数据更新(首次启动自动拉取到用户数据目录) threading.Thread(target=_user_init_remote, daemon=True).start() + # 节点未配置时延迟弹出登录框(等窗口显示后) + QTimer.singleShot(600, self._maybe_show_login) def init_ui(self): """初始化主界面布局与样式。""" @@ -1395,6 +1506,12 @@ class MainWindow(QWidget): self.on_stop() def on_start(self): + # 节点未配置:先弹出登录框,成功后继续;取消/失败则中止开启 + if _endpoint_domain() == "???": + self._show_login_dialog() + if _endpoint_domain() == "???": + log_red("未登录,无法开启代理") + return self.btn_toggle.setEnabled(False) self.cb_pac.setEnabled(False) self.cb_bypass_lan.setEnabled(False) @@ -1474,6 +1591,19 @@ class MainWindow(QWidget): self._op_done.emit("stop", False) + # ── 节点登录 ── + def _maybe_show_login(self): + """启动时节点未配置则弹出登录框。""" + if _endpoint_domain() == "???": + self._show_login_dialog() + + def _show_login_dialog(self): + """弹出登录框;成功后重载节点并刷新标题。""" + dlg = LoginDialog(self) + if dlg.exec_() == QDialog.Accepted: + _reload_nodes() + self._refresh_subtitle() + # ── 节点切换 ── def _show_node_menu(self): """弹出节点切换菜单(每次打开重新读取 nodes.json,外部改动即时生效)。"""