From 567d395d6f1a8feff1597603def6e50cff64d0f0 Mon Sep 17 00:00:00 2001 From: RoyirinRosmontis Date: Thu, 27 Aug 2026 13:18:07 +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=E4=BB=A3=E7=90=86=E7=99=BD=E5=90=8D=E5=8D=95=EF=BC=9A?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E6=96=B0=E5=A2=9E=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E8=AE=BE=E7=BD=AE=E9=A1=B9=E3=80=8C=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95+=E9=85=8D=E7=BD=AE=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E7=82=B9=E9=85=8D=E7=BD=AE=E6=89=93=E5=BC=80=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=BC=B9=E7=AA=97=EF=BC=88Windows=20=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=E9=A3=8E=E6=A0=BC=E5=88=97=E8=A1=A8?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=94=AF=E6=8C=81=E6=B7=BB=E5=8A=A0/?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=BC=BA=E5=88=B6=E7=9B=B4=E8=BF=9E=E7=9A=84?= =?UTF-8?q?=E5=9F=9F=E5=90=8D=E3=80=81IP=E3=80=81CIDR=EF=BC=88IPv4/IPv6=20?= =?UTF-8?q?=E5=9D=87=E6=94=AF=E6=8C=81=EF=BC=89=EF=BC=8C=E6=AF=8F=E6=9D=A1?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=B8=A6=E5=A4=8D=E9=80=89=E6=A1=86=E5=8F=AF?= =?UTF-8?q?=E5=8D=95=E7=8B=AC=E5=90=AF=E7=94=A8/=E7=A6=81=E7=94=A8?= =?UTF-8?q?=EF=BC=8C=E4=BF=9D=E5=AD=98=E5=90=8E=E7=AB=8B=E5=8D=B3=E7=94=9F?= =?UTF-8?q?=E6=95=88=EF=BC=8C=E7=99=BD=E5=90=8D=E5=8D=95=E4=BC=98=E5=85=88?= =?UTF-8?q?=E4=BA=8E=E5=9F=9F=E5=90=8D=E8=A1=A8=E5=BC=BA=E5=88=B6=E9=9A=A7?= =?UTF-8?q?=E9=81=93=E4=B8=8E=E5=9B=BA=E5=AE=9A=E4=BB=A3=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lite/proxy_lite.py | 304 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 300 insertions(+), 4 deletions(-) diff --git a/lite/proxy_lite.py b/lite/proxy_lite.py index ae406ec..c5f28b8 100644 --- a/lite/proxy_lite.py +++ b/lite/proxy_lite.py @@ -7,6 +7,7 @@ from PyQt5.QtWidgets import ( QApplication, QWidget, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit, QCheckBox, QMenu, QDialog, QLineEdit, + QListWidget, QListWidgetItem, ) from PyQt5.QtCore import Qt, QTimer, QRectF, pyqtSignal from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor, QPainter @@ -635,6 +636,74 @@ 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) 键控的匹配缓存失效。 + +_WHITELIST = None # 内存缓存:规范化后的规则列表 +_WHITELIST_VERSION = 0 # 规则版本号(保存时 +1,用于匹配缓存失效) + +def _whitelist_rules(): + """白名单规则列表(内存缓存,首次读取 settings.json;保存时同步更新)。""" + global _WHITELIST + if _WHITELIST 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 + +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 "")] + data = _settings_load() + data["whitelist"] = clean + _settings_save(data) + _WHITELIST = clean + _WHITELIST_VERSION += 1 + log(f"代理白名单已保存: {len(clean)} 条(立即生效)") + +def _whitelist_normalize(entry: str) -> str: + """条目规范化:去协议头/通配符/末尾点,统一小写。""" + s = str(entry).strip().lower() + if "://" in s: + s = s.split("://", 1)[1] + return s.rstrip("/").rstrip(".").lstrip("*.") + +def _whitelist_validate_entry(entry: str): + """校验并规范化白名单条目;返回 (规范值, 错误),错误非空表示非法。""" + s = _whitelist_normalize(entry) + if not s: + return "", "内容不能为空" + if any(ch.isspace() for ch in s): + return "", "不能包含空格" + import ipaddress + if "/" in s: + # CIDR(IPv4/IPv6 网段或单地址) + try: + ipaddress.ip_network(s, strict=False) + except ValueError: + return "", "IP/CIDR 格式不正确(如 10.0.0.0/8)" + elif s.count(".") == 3 and all(p.isdigit() for p in s.split(".")): + # 形如 x.x.x.x:必须是合法 IPv4,防止 300.1.2.3 这类误当域名 + try: + ipaddress.ip_address(s) + except ValueError: + return "", "IP 格式不正确" + elif ":" in s: + # IPv6 字面量(域名不含冒号) + try: + ipaddress.ip_address(s) + except ValueError: + return "", "IP 格式不正确" + return s, "" + def _ver_tuple(v): """版本号转数字元组:'v0.1.0' -> (0, 1, 0),无法解析的段按 0 处理。""" parts = [] @@ -1003,11 +1072,55 @@ def _is_force_proxy(host: str) -> bool: h = host.rstrip(".").lower() return any(h == s or h.endswith("." + s) for s in suffixes) +def _resolve_host_ips(host: str): + """解析域名的 IPv4/IPv6 地址集合(字面量 IP 返回自身);解析失败返回空集合。""" + import ipaddress + try: + return {ipaddress.ip_address(host)} + except ValueError: + pass + try: + import socket + # 双栈解析(AF_UNSPEC):IPv6 白名单条目也能命中域名解析出的 IPv6 地址 + return {ipaddress.ip_address(a[4][0]) + for a in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)} + except Exception: + return set() + +def _whitelist_entry_match(host: str, entry: str) -> bool: + """单条白名单规则匹配:IP/CIDR 条目按解析地址比对,域名条目按后缀匹配。""" + h = host.rstrip(".").lower() + entry = entry.rstrip(".").lower() + import ipaddress + net = None + # 只有含点/冒号才按 IP 解析:纯数字串(如 8080)会被 ip_network 误解析为 + # 整型地址,应视为域名条目 + if "." in entry or ":" in entry: + try: + net = ipaddress.ip_network(entry, strict=False) + except ValueError: + net = None + if net is not None: + return any(addr in net for addr in _resolve_host_ips(h)) + # 域名条目:精确匹配或子域后缀匹配(与 pac_domains 同一规则) + return h == entry or h.endswith("." + entry) + +@functools.lru_cache(maxsize=8192) +def _is_whitelisted(host: str, version: int) -> bool: + """host 是否命中已启用的白名单规则(按版本键控缓存,规则变更即失效)。""" + entries = [r["value"] for r in _whitelist_rules() if r.get("enabled")] + if not entries: + return False + return any(_whitelist_entry_match(host, e) for e in entries) + def _decide_direct(host: str, bypass_lan: bool, smart: bool) -> bool: - """判断连接是否直连。判定顺序(域名表优先于 IP): - 域名表命中 -> 隧道;内网 + bypass_lan -> 直连;中国 IP -> 直连,其余 -> 隧道。 - 仅 PAC(智能分流)模式生效,固定代理模式全部走隧道。 + """判断连接是否直连。判定顺序(白名单最优先): + 白名单命中 -> 直连;域名表命中 -> 隧道;内网 + bypass_lan -> 直连; + 中国 IP -> 直连,其余 -> 隧道。 + 白名单在 PAC 与固定代理模式下均生效(固定模式除白名单外全部走隧道)。 """ + if _is_whitelisted(host, _WHITELIST_VERSION): + return True if smart and _is_force_proxy(host): return False if bypass_lan and _is_private_ip(host): @@ -1428,6 +1541,164 @@ class LoginDialog(QDialog): self.lb_err.setText(info) self.lb_err.show() +# ── 代理白名单弹窗 ───────────────────────────────────── + +class WhitelistDialog(QDialog): + """代理白名单编辑弹窗:可编辑列表(Windows 环境变量风格), + 每条规则带复选框启用/禁用,支持添加/删除,保存后立即生效。""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("代理白名单") + self.setWindowIcon(load_icon()) + self.setFixedSize(380, 400) + 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; + }} + QListWidget {{ + background-color: {C_CARD}; + border: 1px solid {C_BORDER}; + border-radius: 6px; + padding: 4px; + color: {C_TEXT}; + font-size: 12px; + }} + QListWidget::item {{ padding: 3px 6px; 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: 4px 12px; + font-size: 12px; font-weight: bold; + }} + QPushButton#btnOk:hover {{ background-color: #5ed978; }} + QPushButton#btnCancel {{ + background-color: {C_CARD}; color: {C_SUBTEXT}; + border: 1px solid {C_BORDER}; border-radius: 6px; + padding: 4px 12px; font-size: 12px; + }} + QPushButton#btnSmall {{ + background-color: {C_CARD}; color: {C_SUBTEXT}; + border: 1px solid {C_BORDER}; border-radius: 6px; + padding: 3px 10px; font-size: 11px; + }} + QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }} + """) + + 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.lb_err = QLabel("") + self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;") + self.lb_err.setFixedHeight(14) + self.lb_err.hide() # 无错误时不占空间 + layout.addWidget(self.lb_err) + + # 规则列表:每条前面带复选框(勾选启用 / 取消禁用) + self.list_widget = QListWidget() + self.list_widget.setToolTip("勾选启用规则;可多选后删除") + for r in _whitelist_rules(): + self._add_item(r["value"], r.get("enabled", True)) + layout.addWidget(self.list_widget, 1) + + # 添加行:输入框 + 添加按钮 + add_row = QHBoxLayout() + add_row.setSpacing(8) + self.ed_add = QLineEdit() + self.ed_add.setPlaceholderText("输入域名 / IP / CIDR,如 example.com") + self.ed_add.returnPressed.connect(self._add_rule) + add_row.addWidget(self.ed_add, 1) + self.btn_add = QPushButton("添加") + self.btn_add.setObjectName("btnOk") + self.btn_add.setStyleSheet(f""" + QPushButton {{ + background-color: {C_GREEN}; color: #fff; border: none; + border-radius: 6px; padding: 6px 14px; + font-size: 12px; font-weight: bold; + }} + QPushButton:hover {{ background-color: #5ed978; }} + """) + self.btn_add.clicked.connect(self._add_rule) + add_row.addWidget(self.btn_add) + layout.addLayout(add_row) + + # 删除选中 + 底部按钮 + mid_row = QHBoxLayout() + self.btn_delete = QPushButton("删除选中") + self.btn_delete.setObjectName("btnSmall") + self.btn_delete.clicked.connect(self._delete_selected) + mid_row.addWidget(self.btn_delete) + mid_row.addStretch() + layout.addLayout(mid_row) + + btns = QHBoxLayout() + btns.setSpacing(10) + 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._save) + btns.addWidget(self.btn_ok) + layout.addLayout(btns) + + def _add_item(self, value, enabled): + item = QListWidgetItem(value) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked if enabled else Qt.Unchecked) + self.list_widget.addItem(item) + + def _add_rule(self): + value, err = _whitelist_validate_entry(self.ed_add.text()) + if err: + self.lb_err.setText(err) + self.lb_err.show() + return + # 去重(不区分大小写) + for i in range(self.list_widget.count()): + if self.list_widget.item(i).text().lower() == value.lower(): + self.lb_err.setText("该规则已存在") + self.lb_err.show() + return + self.lb_err.hide() + self._add_item(value, True) + self.ed_add.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 i in range(self.list_widget.count()): + item = self.list_widget.item(i) + value = str(item.text()).strip() + if value: + rules.append({"value": value, "enabled": item.checkState() == Qt.Checked}) + _whitelist_save(rules) + self.accept() + # ── 设置弹窗 ───────────────────────────────────────── class SettingsDialog(QDialog): @@ -1437,7 +1708,7 @@ class SettingsDialog(QDialog): super().__init__(parent) self.setWindowTitle("设置") self.setWindowIcon(load_icon()) - self.setFixedSize(300, 316) + self.setFixedSize(300, 354) self.setStyleSheet(f""" QDialog {{ background-color: {C_BG}; }} QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} @@ -1485,6 +1756,27 @@ class SettingsDialog(QDialog): layout.addStretch() # 设置项在空白部分纵向居中(上下均分) + # 代理白名单:第一个设置项,点「配置」打开编辑弹窗 + wl_row = QHBoxLayout() + wl_lab = QLabel("代理白名单") + wl_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") + wl_lab.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用") + wl_row.addWidget(wl_lab) + wl_row.addStretch() + self.btn_whitelist = QPushButton("配置") + self.btn_whitelist.setCursor(Qt.PointingHandCursor) + self.btn_whitelist.setStyleSheet(f""" + QPushButton {{ + background-color: {C_CARD}; color: {C_SUBTEXT}; + border: 1px solid {C_BORDER}; border-radius: 6px; + padding: 2px 10px; font-size: 11px; + }} + QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }} + """) + self.btn_whitelist.clicked.connect(self._open_whitelist) + wl_row.addWidget(self.btn_whitelist) + layout.addLayout(wl_row) + # 代理转发端口 port_row = QHBoxLayout() port_lab = QLabel("代理转发端口") @@ -1573,6 +1865,10 @@ class SettingsDialog(QDialog): except Exception: pass + def _open_whitelist(self): + """打开代理白名单编辑弹窗(保存后立即生效,无需重启代理)。""" + WhitelistDialog(self).exec_() + def _save_settings(self): """保存更改:统一校验并应用全部设置项(后续新增设置项在此扩展)。""" port = _parse_port(self.ed_port.text())