新增功能: - 代理白名单:设置页新增第一个设置项「代理白名单+配置」,点配置打开编辑弹窗(Windows 环境变量风格列表),支持添加/删除强制直连的域名、IP、CIDR(IPv4/IPv6 均支持),每条规则带复选框可单独启用/禁用,保存后立即生效,白名单优先于域名表强制隧道与固定代理模式

This commit is contained in:
RoyirinRosmontis
2026-08-27 13:18:07 +08:00
parent c2c3bd1426
commit 567d395d6f
+300 -4
View File
@@ -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())