Compare commits
7
Commits
e13f41a493
...
bfb341cbb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfb341cbb6 | ||
|
|
561ad9ea71 | ||
|
|
504e350eea | ||
|
|
4c8f47bb0d | ||
|
|
a3f11ba72f | ||
|
|
458ad94527 | ||
|
|
dfdca2f2a6 |
@@ -2,3 +2,7 @@
|
|||||||
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本
|
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本
|
||||||
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
|
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
|
||||||
- 当前任何版本内 PAC 规则模式的配置、节点列表的获取随文件一起打包,不具有实时更新可用性,可自行添加
|
- 当前任何版本内 PAC 规则模式的配置、节点列表的获取随文件一起打包,不具有实时更新可用性,可自行添加
|
||||||
|
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows .exe
|
||||||
|
|
||||||
|
## 如何区分 common 版本与 Lite 版本
|
||||||
|
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
|
||||||
|
# 强制收集 PyQt5 全部模块及二进制文件
|
||||||
|
qt_datas, qt_binaries, qt_hiddenimports = collect_all('PyQt5')
|
||||||
|
|
||||||
|
# 自动收集 lite 目录下全部 *.json 数据文件(nodes.json / pac_*.json 等),
|
||||||
|
# 直接打进 EXE 内置目录(_MEIPASS),运行时无需在 EXE 旁附带任何文件。
|
||||||
|
# 以后新增 json 数据文件无需再改 spec。
|
||||||
|
_JSON_DATAS = [(str(p), '.') for p in Path(SPECPATH).glob('*.json')]
|
||||||
|
print(f"[spec] 打包 json 数据文件: {[Path(src).name for src, _ in _JSON_DATAS]}")
|
||||||
|
|
||||||
|
# 自定义 manifest:普通权限运行 + 声明 PerMonitorV2 DPI 感知,
|
||||||
|
# 避免程序在不同缩放比例的显示器之间移动时被 Windows 位图拉伸。
|
||||||
|
_MANIFEST = r'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="RRPP_Lite" type="win32"/>
|
||||||
|
<description>Royirin Rosmontis Private Proxy Lite</description>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor, system</dpiAwareness>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
</assembly>
|
||||||
|
'''
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['proxy_lite.py'],
|
||||||
|
pathex=['..\\.venv\\Lib\\site-packages'],
|
||||||
|
binaries=qt_binaries,
|
||||||
|
datas=[('ico.ico', '.')] + _JSON_DATAS + qt_datas,
|
||||||
|
hiddenimports=qt_hiddenimports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='Royirin Rosmontis Private Proxy Lite',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
# UPX 会压缩资源段(含图标),冷缓存下 Explorer 可能提取不到图标导致任务栏空白
|
||||||
|
upx=False,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
icon=['ico.ico'],
|
||||||
|
manifest=_MANIFEST,
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "",
|
||||||
|
"update_url": "",
|
||||||
|
"local_port": 1080
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"domain": "",
|
||||||
|
"port": "",
|
||||||
|
"country": "",
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"remark": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
+3742
-2
File diff suppressed because it is too large
Load Diff
+266
-72
@@ -1,27 +1,16 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import sys, os, subprocess, time, ctypes, threading, functools, ssl as _ssl, base64, bisect
|
import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# 私有 HTTPS 端点通常使用自签名/内部证书,创建免校验 SSL 上下文
|
|
||||||
# 用于:更新检查等程序内部 HTTP 请求
|
|
||||||
_SSL_NO_VERIFY = _ssl._create_unverified_context()
|
|
||||||
|
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QWidget, QLabel, QPushButton,
|
QApplication, QWidget, QLabel, QPushButton,
|
||||||
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
|
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
|
||||||
QCheckBox,
|
QCheckBox, QMenu,
|
||||||
)
|
)
|
||||||
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
|
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
|
||||||
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
|
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
|
||||||
|
|
||||||
VERSION = "999908162319"
|
|
||||||
UPDATE_USER = "user"
|
|
||||||
UPDATE_PASSWORD = "ftA406u@all#2309"
|
|
||||||
|
|
||||||
# 与 nginx 正向代理使用同一套 Basic 认证凭证
|
|
||||||
_PROXY_AUTH_BASIC = base64.b64encode(f"{UPDATE_USER}:{UPDATE_PASSWORD}".encode()).decode()
|
|
||||||
|
|
||||||
# GUI 窗口在 96 DPI / 100% 缩放下的逻辑尺寸;启用 Qt 高分屏缩放后,
|
# GUI 窗口在 96 DPI / 100% 缩放下的逻辑尺寸;启用 Qt 高分屏缩放后,
|
||||||
# Qt 会自动按 devicePixelRatio 放大/缩小,因此代码里保持逻辑尺寸不变即可。
|
# Qt 会自动按 devicePixelRatio 放大/缩小,因此代码里保持逻辑尺寸不变即可。
|
||||||
_BASE_WINDOW_WIDTH = 340
|
_BASE_WINDOW_WIDTH = 340
|
||||||
@@ -51,22 +40,121 @@ else:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _endpoint_domain():
|
# ── 普通设置(config.json)────────────────────────────
|
||||||
return "iam.aeolianchi.me"
|
# 版本号、更新链接等设置,缺失时用默认值兜底
|
||||||
def _endpoint_port(default=443):
|
_CONFIG = None
|
||||||
return 443
|
|
||||||
|
def _load_config():
|
||||||
|
"""加载 config.json;失败或缺失返回空字典(各设置用默认值兜底)。"""
|
||||||
|
global _CONFIG
|
||||||
|
if _CONFIG is not None:
|
||||||
|
return _CONFIG
|
||||||
|
_CONFIG = {}
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
path = _data_path("config.json")
|
||||||
|
if path is None:
|
||||||
|
log("config.json 缺失,使用默认设置")
|
||||||
|
return _CONFIG
|
||||||
|
data = json.loads(Path(path).read_text("utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("config.json 顶层应为对象")
|
||||||
|
_CONFIG = data
|
||||||
|
log(f"已加载普通设置: {len(_CONFIG)} 项 (config.json)")
|
||||||
|
except Exception as e:
|
||||||
|
log_red(f"config.json 加载失败: {e}")
|
||||||
|
return _CONFIG
|
||||||
|
|
||||||
|
def _config_val(key, default=""):
|
||||||
|
return _load_config().get(key, default)
|
||||||
|
|
||||||
|
# ── 节点配置(nodes.json)────────────────────────────
|
||||||
|
# 节点列表,当前固定取第一个(后续可扩展选择)
|
||||||
|
_NODES = None
|
||||||
|
|
||||||
|
def _load_nodes():
|
||||||
|
"""加载 nodes.json 节点列表;失败或缺失时返回空列表。"""
|
||||||
|
global _NODES
|
||||||
|
if _NODES is not None:
|
||||||
|
return _NODES
|
||||||
|
_NODES = []
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
path = _data_path("nodes.json")
|
||||||
|
if path is None:
|
||||||
|
log_red("nodes.json 缺失,无法加载节点配置")
|
||||||
|
return _NODES
|
||||||
|
data = json.loads(Path(path).read_text("utf-8"))
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError("nodes.json 顶层应为节点数组")
|
||||||
|
_NODES = data
|
||||||
|
log(f"已加载节点配置: {len(_NODES)} 个 (nodes.json)")
|
||||||
|
except Exception as e:
|
||||||
|
log_red(f"nodes.json 加载失败: {e}")
|
||||||
|
return _NODES
|
||||||
|
|
||||||
|
_SELECTED_NODE = 0 # 当前选中节点下标(默认第一条)
|
||||||
|
|
||||||
|
def _current_node():
|
||||||
|
"""当前使用的节点:默认取列表第一个;无节点时返回 None。"""
|
||||||
|
nodes = _load_nodes()
|
||||||
|
if not nodes:
|
||||||
|
return None
|
||||||
|
i = _SELECTED_NODE
|
||||||
|
return nodes[i] if 0 <= i < len(nodes) else nodes[0]
|
||||||
|
|
||||||
|
def _selected_index():
|
||||||
|
"""当前选中节点下标。"""
|
||||||
|
return _SELECTED_NODE
|
||||||
|
|
||||||
|
def _switch_node(index):
|
||||||
|
"""切换当前节点下标;相同或越界返回 False。"""
|
||||||
|
global _SELECTED_NODE
|
||||||
|
nodes = _load_nodes()
|
||||||
|
if not nodes or not (0 <= index < len(nodes)):
|
||||||
|
log_red("节点切换失败: 列表为空或下标越界")
|
||||||
|
return False
|
||||||
|
if index == _SELECTED_NODE:
|
||||||
|
log("已在当前节点")
|
||||||
|
return False
|
||||||
|
_SELECTED_NODE = index
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _reload_nodes():
|
||||||
|
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
|
||||||
|
global _NODES
|
||||||
|
_NODES = None
|
||||||
|
return _load_nodes()
|
||||||
|
|
||||||
|
def _node_val(key, default=""):
|
||||||
|
node = _current_node()
|
||||||
|
return node.get(key, default) if node else default
|
||||||
|
|
||||||
|
def _endpoint_domain():
|
||||||
|
return _node_val("domain", "???")
|
||||||
|
|
||||||
|
def _endpoint_port():
|
||||||
|
return int(_node_val("port", 443))
|
||||||
|
|
||||||
|
def _endpoint_country():
|
||||||
|
return _node_val("country", "")
|
||||||
|
|
||||||
|
def _proxy_auth_basic():
|
||||||
|
"""与 nginx 正向代理使用同一套 Basic 认证凭证(取自 nodes.json)。"""
|
||||||
|
node = _current_node()
|
||||||
|
if not node:
|
||||||
|
return ""
|
||||||
|
return base64.b64encode(f"{node.get('username', '')}:{node.get('password', '')}".encode()).decode()
|
||||||
|
|
||||||
_TLS_LOCAL_PORT = 1080
|
|
||||||
_tls_forwarder = None
|
_tls_forwarder = None
|
||||||
_pac_server = None
|
_pac_server = None
|
||||||
|
|
||||||
PROXY_HOST = "127.0.0.1"
|
PROXY_HOST = "127.0.0.1"
|
||||||
PROXY_PORT = _TLS_LOCAL_PORT
|
|
||||||
PROXY_REG = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings"
|
PROXY_REG = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings"
|
||||||
|
# _TLS_LOCAL_PORT / PROXY_PORT 定义在 _data_path 之后(取自 config.json local_port)
|
||||||
|
|
||||||
# PAC 模式使用全量转发 PAC:所有流量送进本地转发器,
|
# PAC 模式使用全量转发 PAC:所有流量送进本地转发器按 IP 智能分流
|
||||||
# 由转发器按 IP 智能分流(中国 IP 直连、其余走加密隧道)。
|
# (中国 IP 直连、其余走加密隧道);域名表在 pac_domains.json。
|
||||||
# 域名表已提取到 pac_domains.json(强制走隧道的补充规则),不再内置。
|
|
||||||
_BUILT_IN_PAC = r'''function FindProxyForURL(url, host) {
|
_BUILT_IN_PAC = r'''function FindProxyForURL(url, host) {
|
||||||
var proxy = "PROXY 127.0.0.1:1080";
|
var proxy = "PROXY 127.0.0.1:1080";
|
||||||
return proxy;
|
return proxy;
|
||||||
@@ -183,7 +271,7 @@ def log_red(msg: str):
|
|||||||
def _run(cmd, warn=True):
|
def _run(cmd, warn=True):
|
||||||
"""无窗口执行子进程,失败时记录警告日志。"""
|
"""无窗口执行子进程,失败时记录警告日志。"""
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True,
|
r = subprocess.run(cmd, capture_output=True, text=True,
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||||
if warn and r.returncode != 0:
|
if warn and r.returncode != 0:
|
||||||
err = r.stderr.strip() if r.stderr else ""
|
err = r.stderr.strip() if r.stderr else ""
|
||||||
log(f"[警告] {' '.join(str(c) for c in cmd)} -> {r.returncode} {err}")
|
log(f"[警告] {' '.join(str(c) for c in cmd)} -> {r.returncode} {err}")
|
||||||
@@ -239,11 +327,11 @@ def proxy_off():
|
|||||||
total = up + down
|
total = up + down
|
||||||
log(f"本次转发流量共 {_fmt_bytes(total)}")
|
log(f"本次转发流量共 {_fmt_bytes(total)}")
|
||||||
|
|
||||||
# ── 本地认证转发器(直连 / WG 模式均使用) ─────────────────
|
# ── 本地认证转发器 ─────────────────────────────────────
|
||||||
|
|
||||||
# ── 流量统计 ──────────────────────────────────────────
|
# ── 流量统计 ──────────────────────────────────────────
|
||||||
# 所有代理流量都经过本地 127.0.0.1:1080 转发器(直连 / WG / PAC 均如此),
|
# 所有代理流量都经过本地转发器(直连 / PAC 均如此),转发器里计数即可覆盖全部模式;
|
||||||
# 在转发器里计数即可覆盖全部模式。每次"开启代理"时清零,即"本次连接"流量。
|
# 每次"开启代理"时清零,即"本次连接"流量。
|
||||||
_traffic_lock = threading.Lock()
|
_traffic_lock = threading.Lock()
|
||||||
_traffic = {"up": 0, "down": 0}
|
_traffic = {"up": 0, "down": 0}
|
||||||
_traffic_prev = {"up": 0, "down": 0, "ts": 0.0}
|
_traffic_prev = {"up": 0, "down": 0, "ts": 0.0}
|
||||||
@@ -290,17 +378,15 @@ def _traffic_snapshot():
|
|||||||
return _traffic["up"], _traffic["down"]
|
return _traffic["up"], _traffic["down"]
|
||||||
|
|
||||||
def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, smart=False):
|
def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, smart=False):
|
||||||
"""启动本地认证转发器:127.0.0.1:1080 -> remote_host:remote_port(可选 TLS 包裹)。
|
"""启动本地认证转发器:127.0.0.1:<local_port> -> remote_host:remote_port(可选 TLS 包裹)。
|
||||||
|
|
||||||
smart=True 时启用 IP 智能分流:pac_domains.json 命中或非中国 IP 走隧道,
|
smart=True 时启用 IP 智能分流(域名表/非中国 IP 走隧道,中国 IP 直连)。
|
||||||
其余(中国 IP)直连。
|
|
||||||
"""
|
"""
|
||||||
global _tls_forwarder
|
global _tls_forwarder
|
||||||
_tls_stop()
|
_tls_stop()
|
||||||
import socket, ssl
|
import socket, ssl
|
||||||
|
# 服务器证书为 Let's Encrypt 正式证书,默认上下文完整校验
|
||||||
ctx = ssl.create_default_context()
|
ctx = ssl.create_default_context()
|
||||||
ctx.check_hostname = False
|
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
svr = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
svr = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
svr.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
svr.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
@@ -345,7 +431,7 @@ def _inject_proxy_auth(data: bytes) -> bytes:
|
|||||||
headers_lower = data[:end].lower()
|
headers_lower = data[:end].lower()
|
||||||
if b"authorization:" in headers_lower:
|
if b"authorization:" in headers_lower:
|
||||||
return data
|
return data
|
||||||
auth_line = f"Authorization: Basic {_PROXY_AUTH_BASIC}\r\n".encode()
|
auth_line = f"Authorization: Basic {_proxy_auth_basic()}\r\n".encode()
|
||||||
return data[:end + 2] + auth_line + data[end + 2:]
|
return data[:end + 2] + auth_line + data[end + 2:]
|
||||||
|
|
||||||
def _make_request_injector():
|
def _make_request_injector():
|
||||||
@@ -409,12 +495,14 @@ _FORCE_PROXY_SUFFIXES = None
|
|||||||
_FORCE_PROXY_LOADED = False
|
_FORCE_PROXY_LOADED = False
|
||||||
|
|
||||||
def _data_path(name: str):
|
def _data_path(name: str):
|
||||||
"""数据文件路径:EXE 同目录优先(可覆盖更新),其次打包内置目录。"""
|
"""数据文件路径:只取打包内置目录(单文件 EXE,不依赖外部文件)。"""
|
||||||
for d in (PERSIST_DIR, BASE_DIR):
|
p = Path(BASE_DIR) / name
|
||||||
p = Path(d) / name
|
return p if p.is_file() else None
|
||||||
if p.is_file():
|
|
||||||
return p
|
# 以下模块级取值需在 _data_path 定义之后执行
|
||||||
return None
|
VERSION = _config_val("version", "999908162319")
|
||||||
|
_TLS_LOCAL_PORT = int(_config_val("local_port", 1080)) # 本地转发器监听端口
|
||||||
|
PROXY_PORT = _TLS_LOCAL_PORT
|
||||||
|
|
||||||
def _load_cn_ranges():
|
def _load_cn_ranges():
|
||||||
"""加载 pac_ip_cn.json 中的中国 IPv4 段,合并为整数区间供二分查找。"""
|
"""加载 pac_ip_cn.json 中的中国 IPv4 段,合并为整数区间供二分查找。"""
|
||||||
@@ -528,12 +616,8 @@ def _is_force_proxy(host: str) -> bool:
|
|||||||
|
|
||||||
def _decide_direct(host: str, bypass_lan: bool, smart: bool) -> bool:
|
def _decide_direct(host: str, bypass_lan: bool, smart: bool) -> bool:
|
||||||
"""判断连接是否直连。判定顺序(域名表优先于 IP):
|
"""判断连接是否直连。判定顺序(域名表优先于 IP):
|
||||||
1. 智能分流:pac_domains.json 命中 -> 强制隧道;
|
域名表命中 -> 隧道;内网 + bypass_lan -> 直连;中国 IP -> 直连,其余 -> 隧道。
|
||||||
2. 内网 IP 且勾选 bypass_lan -> 直连;
|
仅 PAC(智能分流)模式生效,固定代理模式全部走隧道。
|
||||||
3. 智能分流:中国 IP -> 直连,其余 -> 隧道。
|
|
||||||
|
|
||||||
域名表与 IP 判定仅在智能分流(PAC)模式下生效;
|
|
||||||
固定代理模式下全部走隧道,也不触发数据文件加载。
|
|
||||||
"""
|
"""
|
||||||
if smart and _is_force_proxy(host):
|
if smart and _is_force_proxy(host):
|
||||||
return False
|
return False
|
||||||
@@ -728,7 +812,7 @@ def _cleanup_stale_proxy():
|
|||||||
"""清理上次异常退出残留的系统代理设置。
|
"""清理上次异常退出残留的系统代理设置。
|
||||||
|
|
||||||
TLS 转发器 / PAC 服务只活在进程内,程序启动时若注册表仍指向
|
TLS 转发器 / PAC 服务只活在进程内,程序启动时若注册表仍指向
|
||||||
127.0.0.1:1080 或本地 PAC 地址,说明是残留——不清理会导致全网不通。
|
本地转发端口或本地 PAC 地址,说明是残留——不清理会导致全网不通。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not is_proxy_on():
|
if not is_proxy_on():
|
||||||
@@ -763,6 +847,35 @@ def load_icon():
|
|||||||
img.fill(QColor(C_TITLE))
|
img.fill(QColor(C_TITLE))
|
||||||
return QIcon(QPixmap.fromImage(img))
|
return QIcon(QPixmap.fromImage(img))
|
||||||
|
|
||||||
|
# ── 可点击节点标签 ─────────────────────────────────────
|
||||||
|
|
||||||
|
_NL_BASE = f"color: {C_TITLE}; font-size: 12px;"
|
||||||
|
_NL_HOVER = _NL_BASE + " text-decoration: underline;"
|
||||||
|
|
||||||
|
class NodeLabel(QLabel):
|
||||||
|
"""节点标签:hover 显示下划线+手型光标,点击发出 clicked 信号。"""
|
||||||
|
clicked = pyqtSignal()
|
||||||
|
|
||||||
|
def __init__(self, text=""):
|
||||||
|
super().__init__(text)
|
||||||
|
self.setStyleSheet(_NL_BASE)
|
||||||
|
self.setToolTip("点击切换节点")
|
||||||
|
|
||||||
|
def enterEvent(self, e):
|
||||||
|
self.setStyleSheet(_NL_HOVER)
|
||||||
|
self.setCursor(Qt.PointingHandCursor)
|
||||||
|
super().enterEvent(e)
|
||||||
|
|
||||||
|
def leaveEvent(self, e):
|
||||||
|
self.setStyleSheet(_NL_BASE)
|
||||||
|
self.unsetCursor()
|
||||||
|
super().leaveEvent(e)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, e):
|
||||||
|
if e.button() == Qt.LeftButton:
|
||||||
|
self.clicked.emit()
|
||||||
|
super().mouseReleaseEvent(e)
|
||||||
|
|
||||||
# ── 状态指示灯(小圆点) ───────────────────────────────
|
# ── 状态指示灯(小圆点) ───────────────────────────────
|
||||||
|
|
||||||
class StatusDot(QLabel):
|
class StatusDot(QLabel):
|
||||||
@@ -800,8 +913,6 @@ class StatusDot(QLabel):
|
|||||||
|
|
||||||
# ── 主窗口 ────────────────────────────────────────────
|
# ── 主窗口 ────────────────────────────────────────────
|
||||||
|
|
||||||
# ── 主窗口 ────────────────────────────────────────────
|
|
||||||
|
|
||||||
class MainWindow(QWidget):
|
class MainWindow(QWidget):
|
||||||
log_signal = pyqtSignal(str)
|
log_signal = pyqtSignal(str)
|
||||||
_op_done = pyqtSignal(str, bool)
|
_op_done = pyqtSignal(str, bool)
|
||||||
@@ -836,7 +947,7 @@ class MainWindow(QWidget):
|
|||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
"""初始化主界面布局与样式。"""
|
"""初始化主界面布局与样式。"""
|
||||||
self.setWindowTitle("Royirin Rosmontis Private Proxy")
|
self.setWindowTitle("R.R.P.P.")
|
||||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
|
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
|
||||||
self.setWindowIcon(load_icon())
|
self.setWindowIcon(load_icon())
|
||||||
self.setFixedSize(_BASE_WINDOW_WIDTH, _BASE_WINDOW_HEIGHT)
|
self.setFixedSize(_BASE_WINDOW_WIDTH, _BASE_WINDOW_HEIGHT)
|
||||||
@@ -860,11 +971,17 @@ class MainWindow(QWidget):
|
|||||||
title.setAlignment(Qt.AlignCenter)
|
title.setAlignment(Qt.AlignCenter)
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
|
|
||||||
# 节点副标题
|
# 节点副标题(域名可点击切换:hover 下划线+手型提示,点击弹节点列表)
|
||||||
subtitle = QLabel(f"节点: {_endpoint_domain()} (JP)")
|
node_row = QHBoxLayout()
|
||||||
subtitle.setStyleSheet(f"color: {C_TITLE}; font-size: 12px;")
|
node_row.addStretch()
|
||||||
subtitle.setAlignment(Qt.AlignCenter)
|
prefix = QLabel("节点: ")
|
||||||
layout.addWidget(subtitle)
|
prefix.setStyleSheet(f"color: {C_TITLE}; font-size: 12px;")
|
||||||
|
node_row.addWidget(prefix)
|
||||||
|
self.subtitle = NodeLabel(f"{_endpoint_domain()} ({_endpoint_country()})")
|
||||||
|
self.subtitle.clicked.connect(self._show_node_menu)
|
||||||
|
node_row.addWidget(self.subtitle)
|
||||||
|
node_row.addStretch()
|
||||||
|
layout.addLayout(node_row)
|
||||||
|
|
||||||
# 连接方式选择区
|
# 连接方式选择区
|
||||||
pac_layout = QHBoxLayout()
|
pac_layout = QHBoxLayout()
|
||||||
@@ -875,7 +992,7 @@ class MainWindow(QWidget):
|
|||||||
self.cb_bypass_lan.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
self.cb_bypass_lan.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
pac_layout.addWidget(self.cb_bypass_lan)
|
pac_layout.addWidget(self.cb_bypass_lan)
|
||||||
self.cb_pac = QCheckBox("PAC 规则模式")
|
self.cb_pac = QCheckBox("PAC 规则模式")
|
||||||
self.cb_pac.setChecked(False) # 默认关闭 PAC,由用户按需开启
|
self.cb_pac.setChecked(True) # 默认开启智能分流(域名表/中国IP判定已足够完善)
|
||||||
self.cb_pac.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
self.cb_pac.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
self.cb_pac.setToolTip("只代理海外服务,使用域名和解析 IP 匹配。")
|
self.cb_pac.setToolTip("只代理海外服务,使用域名和解析 IP 匹配。")
|
||||||
pac_layout.addWidget(self.cb_pac)
|
pac_layout.addWidget(self.cb_pac)
|
||||||
@@ -966,7 +1083,6 @@ class MainWindow(QWidget):
|
|||||||
""")
|
""")
|
||||||
layout.addWidget(self.log_text)
|
layout.addWidget(self.log_text)
|
||||||
|
|
||||||
# 底部提示小字:独立容器收紧两行间距(主布局 spacing=14 太宽)
|
|
||||||
hints = QWidget()
|
hints = QWidget()
|
||||||
hints_layout = QVBoxLayout(hints)
|
hints_layout = QVBoxLayout(hints)
|
||||||
hints_layout.setContentsMargins(0, 0, 0, 0)
|
hints_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -977,8 +1093,7 @@ class MainWindow(QWidget):
|
|||||||
warn_label.setAlignment(Qt.AlignCenter)
|
warn_label.setAlignment(Qt.AlignCenter)
|
||||||
hints_layout.addWidget(warn_label)
|
hints_layout.addWidget(warn_label)
|
||||||
|
|
||||||
# 主背景是白色,纯白字不可见,用接近白的浅灰达到同样的弱化效果
|
privacy_label = QLabel("使用此软件将会收集并上报您的 IP 地址及部分设备信息")
|
||||||
privacy_label = QLabel("使用此软件将会收集并上报您的IP地址及部分设备信息")
|
|
||||||
privacy_label.setStyleSheet("color: #c8ccd4; font-size: 9px;")
|
privacy_label.setStyleSheet("color: #c8ccd4; font-size: 9px;")
|
||||||
privacy_label.setAlignment(Qt.AlignCenter)
|
privacy_label.setAlignment(Qt.AlignCenter)
|
||||||
hints_layout.addWidget(privacy_label)
|
hints_layout.addWidget(privacy_label)
|
||||||
@@ -1034,15 +1149,23 @@ class MainWindow(QWidget):
|
|||||||
threading.Thread(target=self._start_thread, args=(use_pac, use_bypass_lan), daemon=True).start()
|
threading.Thread(target=self._start_thread, args=(use_pac, use_bypass_lan), daemon=True).start()
|
||||||
def _start_thread(self, use_pac, use_bypass_lan):
|
def _start_thread(self, use_pac, use_bypass_lan):
|
||||||
try:
|
try:
|
||||||
|
self._start_proxy(use_pac, use_bypass_lan)
|
||||||
|
self._op_done.emit("start", True)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[错误] 开启失败: {e}")
|
||||||
|
self._op_done.emit("start", False)
|
||||||
|
|
||||||
|
def _start_proxy(self, use_pac, use_bypass_lan):
|
||||||
|
"""启动本地转发器并设置系统代理(PAC/固定两种模式),可在后台线程调用。"""
|
||||||
_traffic_reset()
|
_traffic_reset()
|
||||||
log("TLS 直连模式")
|
log("TLS 直连模式")
|
||||||
ep = _endpoint_domain()
|
ep = _endpoint_domain()
|
||||||
log(f" 目标域名: {ep}")
|
log(f" 目标域名: {ep}")
|
||||||
# PAC 模式 = 智能分流:PAC 把全部流量送进转发器,由转发器按域名表/解析 IP 决定直连或隧道
|
# PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流
|
||||||
if use_pac:
|
if use_pac:
|
||||||
_load_cn_ranges()
|
_load_cn_ranges()
|
||||||
_load_force_proxy_suffixes()
|
_load_force_proxy_suffixes()
|
||||||
_tls_start(ep, 443, use_tls=True, bypass_lan=use_bypass_lan, smart=use_pac)
|
_tls_start(ep, _endpoint_port(), use_tls=True, bypass_lan=use_bypass_lan, smart=use_pac)
|
||||||
if use_pac:
|
if use_pac:
|
||||||
# 本地生成全量转发 PAC(不再从服务器拉取),全部流量交给转发器做智能分流
|
# 本地生成全量转发 PAC(不再从服务器拉取),全部流量交给转发器做智能分流
|
||||||
pac_content = _build_pac(f"{PROXY_HOST}:{PROXY_PORT}")
|
pac_content = _build_pac(f"{PROXY_HOST}:{PROXY_PORT}")
|
||||||
@@ -1071,10 +1194,6 @@ class MainWindow(QWidget):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f" {name} 不可达: {e}")
|
log(f" {name} 不可达: {e}")
|
||||||
log("开启成功")
|
log("开启成功")
|
||||||
self._op_done.emit("start", True)
|
|
||||||
except Exception as e:
|
|
||||||
log(f"[错误] 开启失败: {e}")
|
|
||||||
self._op_done.emit("start", False)
|
|
||||||
|
|
||||||
def on_stop(self):
|
def on_stop(self):
|
||||||
"""禁用按钮并在后台线程执行关闭流程。"""
|
"""禁用按钮并在后台线程执行关闭流程。"""
|
||||||
@@ -1082,7 +1201,7 @@ class MainWindow(QWidget):
|
|||||||
threading.Thread(target=self._stop_thread, daemon=True).start()
|
threading.Thread(target=self._stop_thread, daemon=True).start()
|
||||||
|
|
||||||
def _stop_thread(self):
|
def _stop_thread(self):
|
||||||
"""后台关闭代理:清理系统代理、停止 TLS/PAC 服务、断开 WG。"""
|
"""后台关闭代理:清理系统代理、停止 TLS/PAC 服务。"""
|
||||||
try:
|
try:
|
||||||
log("正在关闭代理...")
|
log("正在关闭代理...")
|
||||||
proxy_off()
|
proxy_off()
|
||||||
@@ -1094,6 +1213,67 @@ class MainWindow(QWidget):
|
|||||||
self._op_done.emit("stop", False)
|
self._op_done.emit("stop", False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 节点切换 ──
|
||||||
|
def _show_node_menu(self):
|
||||||
|
"""弹出节点切换菜单(每次打开重新读取 nodes.json,外部改动即时生效)。"""
|
||||||
|
if self._proxy_set_by_us:
|
||||||
|
log("代理运行中,请先关闭代理再切换节点")
|
||||||
|
return
|
||||||
|
nodes = _reload_nodes()
|
||||||
|
if not nodes:
|
||||||
|
QMessageBox.information(self, "提示", "nodes.json 中没有可用节点")
|
||||||
|
return
|
||||||
|
menu = QMenu(self)
|
||||||
|
menu.setStyleSheet(f"""
|
||||||
|
QMenu {{
|
||||||
|
background-color: {C_CARD};
|
||||||
|
border: 1px solid {C_BORDER};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
}}
|
||||||
|
QMenu::item {{
|
||||||
|
padding: 6px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: {C_TEXT};
|
||||||
|
font-size: 12px;
|
||||||
|
}}
|
||||||
|
QMenu::item:selected {{
|
||||||
|
background-color: #e3e8ee;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
cur = _selected_index()
|
||||||
|
for i, n in enumerate(nodes):
|
||||||
|
domain = n.get("domain") or "???"
|
||||||
|
country = n.get("country") or ""
|
||||||
|
remark = n.get("remark") or ""
|
||||||
|
text = f"{domain} ({country})"
|
||||||
|
if remark and remark != domain:
|
||||||
|
text += f" {remark}"
|
||||||
|
if i == cur:
|
||||||
|
text = "✓ " + text
|
||||||
|
menu.addAction(text).triggered.connect(
|
||||||
|
lambda _=False, idx=i: self._on_node_selected(idx))
|
||||||
|
menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft()))
|
||||||
|
|
||||||
|
def _on_node_selected(self, idx):
|
||||||
|
"""切换节点:更新标题。"""
|
||||||
|
if not _switch_node(idx):
|
||||||
|
return
|
||||||
|
node = _current_node()
|
||||||
|
self._refresh_subtitle()
|
||||||
|
log(f"已切换节点: {node.get('domain')} ({node.get('country')})")
|
||||||
|
|
||||||
|
def _refresh_subtitle(self):
|
||||||
|
"""刷新节点副标题文字。"""
|
||||||
|
self.subtitle.setText(f"{_endpoint_domain()} ({_endpoint_country()})")
|
||||||
|
|
||||||
|
def _update_node_label(self):
|
||||||
|
"""代理运行中时提示禁止切换节点。"""
|
||||||
|
if self._proxy_set_by_us:
|
||||||
|
self.subtitle.setToolTip("代理运行中,请先关闭代理再切换节点")
|
||||||
|
else:
|
||||||
|
self.subtitle.setToolTip("点击切换节点")
|
||||||
|
|
||||||
def _after_op(self, op, ok):
|
def _after_op(self, op, ok):
|
||||||
"""开启/关闭操作完成后刷新界面状态并恢复按钮/控件可用性。"""
|
"""开启/关闭操作完成后刷新界面状态并恢复按钮/控件可用性。"""
|
||||||
self.refresh_status()
|
self.refresh_status()
|
||||||
@@ -1103,9 +1283,20 @@ class MainWindow(QWidget):
|
|||||||
is_off = (op == "stop") or (op == "start" and not ok)
|
is_off = (op == "stop") or (op == "start" and not ok)
|
||||||
self.cb_pac.setEnabled(is_off)
|
self.cb_pac.setEnabled(is_off)
|
||||||
self.cb_bypass_lan.setEnabled(is_off)
|
self.cb_bypass_lan.setEnabled(is_off)
|
||||||
|
self._update_node_label()
|
||||||
# ── 日志 ──
|
# ── 日志 ──
|
||||||
|
_early_logs = [] # 窗口初始化期间(log_text 创建前)到达的日志缓存
|
||||||
|
|
||||||
def _append_log(self, msg: str):
|
def _append_log(self, msg: str):
|
||||||
"""追加日志并自动滚动,最多保留 200 行。"""
|
"""追加日志并自动滚动,最多保留 200 行;log_text 未创建时先缓存。"""
|
||||||
|
if not hasattr(self, "log_text"):
|
||||||
|
self._early_logs.append(msg)
|
||||||
|
return
|
||||||
|
for m in self._early_logs + [msg]:
|
||||||
|
self._append_log_line(m)
|
||||||
|
self._early_logs.clear()
|
||||||
|
|
||||||
|
def _append_log_line(self, msg: str):
|
||||||
self.log_text.append(msg)
|
self.log_text.append(msg)
|
||||||
scrollbar = self.log_text.verticalScrollBar()
|
scrollbar = self.log_text.verticalScrollBar()
|
||||||
scrollbar.setValue(scrollbar.maximum())
|
scrollbar.setValue(scrollbar.maximum())
|
||||||
@@ -1122,7 +1313,7 @@ class MainWindow(QWidget):
|
|||||||
if self._status_busy:
|
if self._status_busy:
|
||||||
return
|
return
|
||||||
self._status_busy = True
|
self._status_busy = True
|
||||||
# GUI 线程读取模式选择,传给后台线程;直连模式下不检测 WG,避免无谓的 wg 子进程
|
# GUI 线程读取模式选择,传给后台线程
|
||||||
threading.Thread(target=self._poll_status, daemon=True).start()
|
threading.Thread(target=self._poll_status, daemon=True).start()
|
||||||
|
|
||||||
def _poll_status(self):
|
def _poll_status(self):
|
||||||
@@ -1161,16 +1352,19 @@ class MainWindow(QWidget):
|
|||||||
try:
|
try:
|
||||||
import json, urllib.request, base64
|
import json, urllib.request, base64
|
||||||
domain = _endpoint_domain()
|
domain = _endpoint_domain()
|
||||||
|
# 更新链接优先取 config.json 的 update_url,缺失时回退到域名拼接
|
||||||
|
url = _config_val("update_url", "")
|
||||||
|
if not url:
|
||||||
if domain == "???":
|
if domain == "???":
|
||||||
log("更新检查: 无法获取服务器域名")
|
log("更新检查: 无法获取服务器域名")
|
||||||
self._update_done.emit()
|
self._update_done.emit()
|
||||||
return
|
return
|
||||||
log("正在检查更新...")
|
|
||||||
url = f"https://{domain}/latest_version.json"
|
url = f"https://{domain}/latest_version.json"
|
||||||
|
log("正在检查更新...")
|
||||||
req = urllib.request.Request(url)
|
req = urllib.request.Request(url)
|
||||||
auth = base64.b64encode(f"{UPDATE_USER}:{UPDATE_PASSWORD}".encode()).decode()
|
auth = _proxy_auth_basic()
|
||||||
req.add_header("Authorization", f"Basic {auth}")
|
req.add_header("Authorization", f"Basic {auth}")
|
||||||
data = urllib.request.urlopen(req, context=_SSL_NO_VERIFY, timeout=10).read().decode("utf-8-sig")
|
data = urllib.request.urlopen(req, timeout=10).read().decode("utf-8-sig")
|
||||||
info = json.loads(data)
|
info = json.loads(data)
|
||||||
log("客户端认证成功")
|
log("客户端认证成功")
|
||||||
self._latest_version = info.get("latest_version", "")
|
self._latest_version = info.get("latest_version", "")
|
||||||
@@ -1214,10 +1408,10 @@ class MainWindow(QWidget):
|
|||||||
log(f"保存到: {save_path}")
|
log(f"保存到: {save_path}")
|
||||||
|
|
||||||
req = urllib.request.Request(url)
|
req = urllib.request.Request(url)
|
||||||
auth = base64.b64encode(f"{UPDATE_USER}:{UPDATE_PASSWORD}".encode()).decode()
|
auth = _proxy_auth_basic()
|
||||||
req.add_header("Authorization", f"Basic {auth}")
|
req.add_header("Authorization", f"Basic {auth}")
|
||||||
|
|
||||||
with urllib.request.urlopen(req, context=_SSL_NO_VERIFY, timeout=120) as resp:
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
data = resp.read()
|
data = resp.read()
|
||||||
|
|
||||||
if len(data) < 1024:
|
if len(data) < 1024:
|
||||||
@@ -1300,7 +1494,7 @@ def main():
|
|||||||
QMessageBox.information(None, "提示", "程序已在运行中。")
|
QMessageBox.information(None, "提示", "程序已在运行中。")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
# 上次异常退出可能残留指向 127.0.0.1:1080 的系统代理(转发器已随进程消失),
|
# 上次异常退出可能残留指向本地转发端口的系统代理(转发器已随进程消失),
|
||||||
# 不清理会导致全网不通,启动时先清掉
|
# 不清理会导致全网不通,启动时先清掉
|
||||||
_cleanup_stale_proxy()
|
_cleanup_stale_proxy()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user