新增功能:

- 节点配置登录弹窗(LoginDialog,风格与主界面一致):nodes.json 未配置时启动后自动弹出,点击开启代理时也会弹出
- 登录凭据以 Basic 认证请求 config.json 的 nodes_url(参与 nginx 认证),成功后拉取 nodes.json 并 DPAPI 加密保存到 %LOCALAPPDATA%\RRPP,自动刷新标题
- 401 显示"认证失败"、非法返回数据拒绝写入、请求期间按钮禁用防重复提交
- 移除原先无认证的 nodes 自动拉取(该接口已改为必须认证,避免每次启动 401 噪音)
This commit is contained in:
RoyirinRosmontis
2026-08-23 13:10:42 +08:00
parent 415e9787e3
commit 61a59a7872
+158 -28
View File
@@ -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,外部改动即时生效)。"""