Compare commits

...
Author SHA1 Message Date
RoyirinRosmontis cb3552f595 修复:- 上次选中的节点在本机已不可用(如 IPv6 专用节点但本机无 IPv6 出口)时启动不再停留其上:新增 _verify_selected_node/_first_reachable_index,启动后台校验并自动回退到第一个可达节点、写入设置、刷新标题栏(原来只在点开启代理时才报错) 2026-09-10 21:18:25 +08:00
RoyirinRosmontis 2a20215d9e 修复:- 节点菜单 IPv6 专用节点不可用提示改为直接写在菜单项上([不可用:本机无 IPv6]),原来只挂在 tooltip 上不明显 2026-09-10 21:16:52 +08:00
RoyirinRosmontis c3286593a6 修复:
- IPv6 专用节点识别不到:服务端 subscription?method=rrpp_client 吐的是旧扁平格式,ipv6_only 字段被后端丢掉、只在 remark 里留 "(IPv6)"。判定改为三级:ipv6_only 字段 -> remark 含 IPv6 -> 域名解析兜底(解析结果只有 IPv6 无 IPv4)
- 可达性探测改用该节点自己的 HTTP 端口(原来固定按 443 探测),IPv6 地址直接取自解析结果,避免端口不对造成误判
- 本机无全局 IPv6 地址时判定不可达(菜单置灰,提示「不可用:本机无 IPv6」),有 IPv6 时只标注不禁用
- 节点可达性判定不再打印日志
2026-09-10 21:14:59 +08:00
RoyirinRosmontis 9e766d8132 新增功能:
- 节点菜单标注 IPv6 专用节点(nodes.json 的 ipv6_only 字段):本机有 IPv6 出口时标 [IPv6],没有时标 [IPv6 专用 · 本机不可用] 并置灰不可选(带原因 tooltip),避免选中后连不上
- 新增 _local_has_ipv6()(查本机全局 IPv6 地址)/_node_reachable()(ipv6_only 节点真实探测 IPv6 连通性,按域名缓存)/_node_ipv6_only();_reload_nodes() 时清空可达性缓存
- 开启代理时若当前节点 IPv6 专用且本机无 IPv6 出口,直接报错提示切换节点(不再静默失败断网)
- 节点菜单构建拆出 _build_node_menu() 便于离屏测试
2026-09-10 21:01:02 +08:00
RoyirinRosmontis 259a1ae41f 修复:
- 节点 HTTP 代理参数读取不到真实字段导致认证恒为 401:新增 _http_method(),_endpoint_port() 与 _proxy_auth_basic() 改为优先读取新版 nodes.json 的 methods.http.port/username/password,缺失时回退节点顶层键(兼容旧扁平结构)
- 实测原逻辑下所有节点均返回 401 Authorization Required(认证串为空,US 节点端口也从 1205 落到默认 443)
2026-09-10 20:56:12 +08:00
RRRosmontis bcb113e8c9 移除误提交的 __pycache__,补 .gitignore 2026-09-10 10:52:48 +08:00
RRRosmontis 6d204c77fb server_config: SS 升级 2022-blake3 + http 独立凭据 + fail2ban 基线
- SS 方法 aes-256-gcm → 2022-blake3-aes-256-gcm(SIP022 抗重放探测),
  PSK 为 base64(32字节) 格式;模板 config.json 同步更新
- nginx CONNECT Basic Auth 改为每节点独立密码;补充无 apache2-utils 时
  用 openssl passwd -6 的替代写法;注明 htpasswd 实时读取无需 reload
- 新增 2.5 节 fail2ban sshd 基线(含 systemd backend 与 LXC 注意事项)
- 验证清单加 fail2ban 检查项

落实状态:jp/us SS 已升级并验证;四节点 http 独立凭据已生效并验证;
fail2ban 已在 jp/us/ru/aq 全部运行(sshd jail)。
2026-09-10 10:52:35 +08:00
RRRosmontis 63837eb9d8 server_config: 密钥体系改为每节点独立(VLESS UUID/REALITY 密钥对/short_id、SS 密码)
2026-09-10 起 jp/us/ru/aq 四节点已落实独立密钥;SNI 仍全节点统一。
旧「全套共用一套」表述同步修正(0 节/6.2/6.3)。
2026-09-10 10:12:31 +08:00
RRRosmontis efa7e25f66 server_config: 新增 PMTUD 黑洞缓解(tcp_mtu_probing)与 REALITY 公钥推导验证要求
- 2.4 节:客户端路径 PMTUD 黑洞导致「TCP 通但 TLS 握手稳定超时」,
  节点开启 net.ipv4.tcp_mtu_probing=1(本次已在 us/ru/aq 落实,jp 原有)
- 6.3 节:public_key 必须由 private_key 做 X25519 推导逐字符验证,禁止手抄
  (手抄错 1 字符曾致全节点 REALITY 握手失败)
- 验证清单补充 mtu_probing 检查项
2026-09-10 09:49:19 +08:00
RRRosmontis 20d2532e8f server_config: 重写为完整部署规范(BBR/nginx CONNECT/sing-box/mtg)
- 新增完整 README.md 部署规范:架构、端口规划、BBR 跨境优化、
  nginx 源码编译+CONNECT 隧道、sing-box(VLESS+REALITY/SS)、mtg(MTProto)、
  本机 IP 自动识别、systemd、启动顺序与验证清单
- nginx/nginx.conf:占位符版,proxy_connect_allow all + 回环保护
- sing-box/config.json:占位符版,VLESS+REALITY + SS
- systemd/:nginx/sing-box/mtg 服务文件
- scripts/sync-nginx-local-ips.sh:去除真实域名,占位符版
- fake-site/index.html:伪装页(清除真实品牌域名)
- 移除过时:traffic-monitor/、pac/、conf.d 具体站点配置
- 所有敏感值(IP/域名/端口/密钥)均以 <占位符> 表示
2026-09-10 00:22:03 +08:00
RoyirinRosmontis 17cdb276ea 修复: - 节点认证方式升级为 SSO:user/password 按 URL-safe Base64 作为查询参数拼接在 config.json 的 nodes_url 后(旧 nginx auth_basic 已废弃),服务端返回的 401/403/429 等中文原因直接透出到登录框,登录框输入占位改为 SSO 邮箱 2026-09-03 22:40:50 +08:00
RoyirinRosmontis f8eba465a9 修复: - 日志不再打印用户数据目录路径:节点配置加载与首启创建提示中的 %LOCALAPPDATA% 路径已移除 2026-08-31 16:36:44 +08:00
RoyirinRosmontis 53e9d1e31a 修复: - 检查更新不再内置回退地址:只读取 config.json 的 update_url,未配置时检查更新直接失败并记录日志 2026-08-31 16:25:48 +08:00
RoyirinRosmontis 59393b9ae9 修复: - 检查更新地址改用 config.json 的 update_url 配置,内置回退地址域名从 git.bu4.ren 更换为 git.rrrosmontis.icu 2026-08-31 16:24:15 +08:00
RoyirinRosmontis 89b5c09e95 修复: - 设置页代理转发端口输入框横向过长:宽度 72px 缩到 54px(够放下 5 位端口号) 2026-08-27 13:19:51 +08:00
RoyirinRosmontis b15beb97b4 修复: - 设置页代理转发端口输入框过大:内边距从 5px 缩到 2px,并固定为 72×22 与右侧开关同高,和其他设置项同一水平 2026-08-27 13:19:33 +08:00
RoyirinRosmontis 567d395d6f 新增功能: - 代理白名单:设置页新增第一个设置项「代理白名单+配置」,点配置打开编辑弹窗(Windows 环境变量风格列表),支持添加/删除强制直连的域名、IP、CIDR(IPv4/IPv6 均支持),每条规则带复选框可单独启用/禁用,保存后立即生效,白名单优先于域名表强制隧道与固定代理模式 2026-08-27 13:18:07 +08:00
RoyirinRosmontis c2c3bd1426 修复:
- 打包路径兼容 Nuitka:识别 __compiled__,无 sys._MEIPASS 时回退 __file__ 所在目录(onefile 解压临时目录);新增 build_nuitka.bat(PyQt5 插件、内置 config.json、输出名不变)
2026-08-26 14:56:06 +08:00
21 changed files with 1260 additions and 2195 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+562 -39
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
@@ -28,11 +29,12 @@ C_TEXT = "#333333" # 正文
C_SUBTEXT = "#889099" # 次要文字
# ── 路径 ──────────────────────────────────────────────
# frozen=True 表示已被 PyInstaller 打包为 EXE:
# BASE_DIR -> EXE 解压出的临时目录(_MEIPASS)
# 已被打包为 EXE(PyInstaller 的 sys.frozen / Nuitka 的 __compiled__):
# BASE_DIR -> 内置数据目录:PyInstaller 用 _MEIPASS,Nuitka onefile 用
# __file__ 所在解压临时目录(无 _MEIPASS 时回退)
# PERSIST_DIR -> EXE 所在目录,服务安装等需要持久化的操作都放这里
if getattr(sys, 'frozen', False):
BASE_DIR = Path(sys._MEIPASS)
if getattr(sys, 'frozen', False) or "__compiled__" in globals():
BASE_DIR = Path(getattr(sys, "_MEIPASS", None) or Path(__file__).resolve().parent)
PERSIST_DIR = Path(sys.executable).resolve().parent
else:
BASE_DIR = Path(__file__).resolve().parent
@@ -90,7 +92,7 @@ def _load_nodes():
_NODES = data
if not _SELECTED_RESTORED:
_restore_selected_node(_NODES) # 恢复上次使用的节点
log(f"已加载节点配置: {len(_NODES)} 个 ({path})")
log(f"已加载节点配置: {len(_NODES)} 个")
if data and not str(data[0].get("domain") or "").strip():
log_red("节点未填写域名:请配置 config.json 的 nodes_url 在线更新,"
"或使用 others/nodes_tool 导入节点配置")
@@ -144,12 +146,151 @@ def _save_current_node(domain):
data["current_node"] = str(domain or "")
_settings_save(data)
# ── IPv6-only 节点可达性(客户端标注用)─────────────────────
# IPv6-only 节点的域名只有 AAAA 记录(无 A 记录),本机没有 IPv6 出口时连不上。
# 判定三级:ipv6_only 字段 → 服务端 remark 里的 "(IPv6)" 标记 → 域名解析兜底。
# 服务端 subscription?method=rrpp_client 只吐旧扁平格式,ipv6_only 字段被丢掉,
# 只留 remark 里的 "(IPv6)"(见后端 countries_to_rrpp_client),故第 2 级是现网主路径。
_IPV6_OK = {} # domain -> 本机能否连上该节点的 IPv6 地址
_IPV6_ONLY = {} # domain -> 该域名是否只有 IPv6 地址(解析兜底)
_IPV6_PROBE_TIMEOUT = 4 # 单次 TCP 探测超时(秒)
def _local_has_ipv6():
"""本机是否有全局 IPv6 地址(排除回环/链路本地/唯一本地地址)。
没有则不可能连上 IPv6-only 节点,直接短路,省去探测。"""
try:
import socket
import ipaddress
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET6):
addr = info[4][0].split("%")[0] # 去掉 zone id(fe80::1%eth0)
try:
ip = ipaddress.ip_address(addr)
except ValueError:
continue
if ip.is_loopback or ip.is_link_local or ip.is_private:
continue
return True
except Exception:
return False
return False
def _domain_ipv6_only(domain: str) -> bool:
"""域名是否「只有 IPv6 地址」:解析结果里有 IPv6 且没有 IPv4 才算。
解析失败按 False(当普通节点处理,不误伤)。结果缓存。"""
if domain in _IPV6_ONLY:
return _IPV6_ONLY[domain]
import ipaddress
only = False
try:
addrs = _resolve_host_ips(domain)
if addrs:
has6 = any(ip.version == 6 for ip in addrs)
has4 = any(ip.version == 4 for ip in addrs)
only = has6 and not has4
except Exception:
only = False
_IPV6_ONLY[domain] = only
if only:
log(f"节点 {domain} 解析结果只有 IPv6 地址,按 IPv6 专用节点处理")
return only
def _node_ipv6_only(node) -> bool:
"""节点是否 IPv6 专用。
三级判定:
1. ipv6_only 字段(服务端新格式;rrpp_client 输出里没有)
2. remark 里的 "(IPv6)" 标记(rrpp_client 现网唯一的权威线索)
3. 域名解析兜底:解析结果只有 IPv6 地址、没有 IPv4
"""
if not isinstance(node, dict):
return False
if node.get("ipv6_only"):
return True
if "ipv6" in str(node.get("remark") or "").lower():
return True
return _domain_ipv6_only(str(node.get("domain") or "").strip())
def _node_http_port(node) -> int:
"""节点的 HTTP 代理端口(不依赖当前选中节点,供可达性探测用)。"""
m = (node.get("methods") or {}).get("http") or {}
for v in (m.get("port"), node.get("port")):
if v not in (None, ""):
try:
return int(v)
except (TypeError, ValueError):
pass
return 443
def _node_reachable(node):
"""节点在本机是否可达:普通节点恒为 True;IPv6 专用节点需真的能建立 IPv6 连接
(按该节点自己的 HTTP 端口探测)。结果按域名缓存(探测一次)。"""
if not isinstance(node, dict):
return True
domain = str(node.get("domain") or "").strip()
if not _node_ipv6_only(node):
return True
if not domain:
return False
if domain in _IPV6_OK:
return _IPV6_OK[domain]
port = _node_http_port(node)
ok = False
addrs = sorted((ip for ip in _resolve_host_ips(domain) if ip.version == 6), key=str)
if addrs and _local_has_ipv6():
import socket
for ip in addrs:
try:
s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
s.settimeout(_IPV6_PROBE_TIMEOUT)
s.connect((str(ip), port))
s.close()
ok = True
break
except Exception:
try:
s.close()
except Exception:
pass
_IPV6_OK[domain] = ok
return ok
def _reload_nodes():
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
global _NODES
_NODES = None
_IPV6_OK.clear() # 节点列表变了,IPv6 可达性/专用判定缓存作废
_IPV6_ONLY.clear()
return _load_nodes()
def _first_reachable_index(nodes):
"""第一个本机可达的节点下标;都不可达返回 0。供选中不可达节点时回退。"""
for i, n in enumerate(nodes):
if _node_reachable(n):
return i
return 0
def _verify_selected_node():
"""启动时校验上次选中的节点在本机是否还可用。
典型场景:上次选了 IPv6 专用节点,这次本机没有 IPv6 出口(或该节点已不可达),
继续选中它只会在开启代理时才报错。这里自动回退到第一个可达节点并记录到设置。
返回 True 表示发生了切换(调用方据此刷新标题栏)。"""
global _SELECTED_NODE
nodes = _load_nodes()
if not nodes or not (0 <= _SELECTED_NODE < len(nodes)):
return False
if _node_reachable(nodes[_SELECTED_NODE]):
return False
idx = _first_reachable_index(nodes)
old = str(nodes[_SELECTED_NODE].get("domain") or "")
new = str(nodes[idx].get("domain") or "")
_SELECTED_NODE = idx
_save_current_node(new)
if new and new != old:
log_red(f"节点 {old} 在本机不可用,已自动切换到 {new}")
return True
def _node_val(key, default=""):
node = _current_node()
if not node:
@@ -160,18 +301,39 @@ def _node_val(key, default=""):
def _endpoint_domain():
return _node_val("domain", "???")
def _http_method():
"""节点 HTTP 代理方法参数(新版 nodes.json 的 methods.http)。
返回 (node, get):get(key, default) 优先取 methods.http 下的键,
缺失时回退节点顶层——兼容旧扁平结构(port/username/password 直接挂在节点上)。
"""
node = _current_node() or {}
m = (node.get("methods") or {}).get("http") or {}
def get(key, default=""):
v = m.get(key)
if v in (None, ""):
v = node.get(key)
return default if v in (None, "") else v
return node, get
def _endpoint_port():
return int(_node_val("port", 443))
"""HTTP 代理端口:methods.http.port(新格式)或节点顶层 port(旧格式)。"""
_, get = _http_method()
try:
return int(get("port", 443))
except (TypeError, ValueError):
return 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()
"""正向代理 Basic 认证凭证:methods.http.{username,password}(新格式)
或节点顶层同名字段(旧格式);用于转发器向上游代理注入 Authorization。"""
_, get = _http_method()
return base64.b64encode(f"{get('username')}:{get('password')}".encode()).decode()
_tls_forwarder = None
_pac_server = None
@@ -634,6 +796,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 = []
@@ -825,16 +1055,40 @@ def _nodes_sync_remote():
except Exception as e:
log(f"节点配置在线更新失败,使用本地配置: {e}")
class _AuthError(Exception):
"""订阅接口返回的错误:code 为 HTTP 状态码,str 为服务端返回的中文提示。"""
def __init__(self, msg, code=0):
super().__init__(msg)
self.code = code
def _b64url(s: str) -> str:
"""SSO 认证查询参数的 URL-safe Base64 编码(末尾 padding 省略)。"""
return base64.urlsafe_b64encode(str(s).encode("utf-8")).decode("ascii").rstrip("=")
def _fetch_nodes_auth(url, user, password, timeout=10):
"""带 Basic 认证拉取 nodes.json(参与 nginx 认证;直连,不经过系统代理)。"""
"""用 SSO 账号拉取 nodes.json:user/password 按 URL-safe Base64 作为查询参数
拼接在 nodes_url 后(旧版 nginx auth_basic 已废弃);直连,不经过系统代理。
失败抛 _AuthError(服务端拒绝,str 为服务端中文原因)或 urllib 异常(网络问题)。"""
import json
import urllib.request
import urllib.error
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"))
sep = "&" if "?" in url else "?"
req_url = f"{url}{sep}user={_b64url(user)}&password={_b64url(password)}"
try:
with opener.open(req_url, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
# 401/403/429/5xx:服务端 body 是面向用户的中文原因,直接透出
body = ""
try:
body = e.read().decode("utf-8", "replace").strip()
except Exception:
pass
raise _AuthError(body or f"请求被拒绝 (HTTP {e.code})", e.code) from None
def _user_init_local():
@@ -846,13 +1100,13 @@ def _user_init_local():
"username": "", "password": "", "remark": ""}]
(_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(
json.dumps(template, ensure_ascii=False, indent=2) + "\n"))
log_red(f"已创建 {_USER_DIR / 'nodes.json'}(加密存储),请配置 config.json 的 "
f"nodes_url 在线更新,或使用 others/nodes_tool 导入节点配置")
log_red("已创建节点配置文件(加密存储),请配置 config.json 的 "
"nodes_url 在线更新,或使用 others/nodes_tool 导入节点配置")
def _user_init_remote(base_url=None):
"""检查并更新 PAC 数据(后台线程):
直接拉取两个数据文件(内含 version/date),按版本比对后更新;失败用本地副本。
节点配置的拉取走登录框(nodes_url 需 nginx 认证,见 LoginDialog)。
节点配置的拉取走登录框(nodes_url 需 SSO 认证,见 LoginDialog)。
"""
if _skip_update_check():
return # 设置跳过:不拉取也不打日志
@@ -1002,11 +1256,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):
@@ -1283,7 +1581,8 @@ class NodeLabel(QLabel):
# ── 节点配置登录框 ─────────────────────────────────────
class LoginDialog(QDialog):
"""节点配置登录框:凭据参与 nodes_url 的 nginx 认证,成功后拉取并加密保存 nodes.json。"""
"""节点配置登录框:SSO 账号(邮箱+密码,base64url 查询参数)认证 nodes_url,
成功后拉取并加密保存 nodes.json。"""
_done = pyqtSignal(bool, str)
def __init__(self, parent=None):
@@ -1340,7 +1639,7 @@ class LoginDialog(QDialog):
form = QVBoxLayout()
form.setSpacing(6)
self.ed_user = QLineEdit()
self.ed_user.setPlaceholderText("登录名")
self.ed_user.setPlaceholderText("SSO 邮箱")
form.addWidget(self.ed_user)
self.ed_pass = QLineEdit()
@@ -1395,7 +1694,7 @@ class LoginDialog(QDialog):
threading.Thread(target=self._worker, args=(user, password), daemon=True).start()
def _worker(self, user, password):
"""后台登录:Basic 认证拉取 nodes.json,校验后加密写入用户数据目录。"""
"""后台登录:SSO 认证拉取 nodes.json,校验后加密写入用户数据目录。"""
try:
import json
url = str(_config_val("nodes_url", "") or "").strip()
@@ -1409,12 +1708,13 @@ class LoginDialog(QDialog):
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))
_auth_save(user, password) # 记住凭据,之后每次启动自动同步节点
_auth_save(user, password) # 记住 SSO 凭据,之后每次启动自动同步节点
self._done.emit(True, str(len(data)))
except _AuthError as e:
# 服务端拒绝:直接展示其中文原因(认证失败/未开通/限流等)
self._done.emit(False, str(e))
except Exception as e:
msg = ("认证失败,请检查用户名/密码" if getattr(e, "code", None) == 401
else f"登录失败: {e}")
self._done.emit(False, msg)
self._done.emit(False, f"登录失败: {e}")
def _on_done(self, ok, info):
self.btn_ok.setEnabled(True)
@@ -1427,6 +1727,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):
@@ -1436,7 +1894,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; }}
@@ -1444,7 +1902,7 @@ class SettingsDialog(QDialog):
background-color: {C_CARD};
border: 1px solid {C_BORDER};
border-radius: 6px;
padding: 5px 8px;
padding: 2px 8px;
color: {C_TEXT};
font-size: 12px;
}}
@@ -1484,6 +1942,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("代理转发端口")
@@ -1491,7 +1970,7 @@ class SettingsDialog(QDialog):
port_row.addWidget(port_lab)
port_row.addStretch()
self.ed_port = QLineEdit(str(_TLS_LOCAL_PORT))
self.ed_port.setFixedWidth(80)
self.ed_port.setFixedSize(54, 22) # 与右侧开关同高,和其他设置项同一水平
self.ed_port.setEnabled(not port_locked)
port_row.addWidget(self.ed_port)
layout.addLayout(port_row)
@@ -1572,6 +2051,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())
@@ -1675,6 +2158,7 @@ class MainWindow(QWidget):
_update_download_done = pyqtSignal(bool, str)
_status_ready = pyqtSignal(object)
_node_switched = pyqtSignal() # 启动校验时代替用户切换了节点(刷新标题栏)
def __init__(self):
super().__init__()
@@ -1688,6 +2172,7 @@ class MainWindow(QWidget):
self._update_done.connect(self._on_update_done)
self._update_download_done.connect(self._on_update_download_done)
self._status_ready.connect(self._apply_status)
self._node_switched.connect(self._on_node_switched)
_user_init_local() # 首启初始化用户数据目录(节点模板),须在 init_ui 读节点之前
@@ -1705,9 +2190,27 @@ class MainWindow(QWidget):
threading.Thread(target=_user_init_remote, daemon=True).start()
# 后台用已保存凭据同步最新节点配置(服务端改动下次启动生效)
threading.Thread(target=_nodes_sync_remote, daemon=True).start()
# 后台校验上次选中的节点在本机是否还可用(IPv6 专用节点本机无 IPv6 时回退)
threading.Thread(target=self._verify_node_thread, daemon=True).start()
# 节点未配置时延迟弹出登录框(等窗口显示后)
QTimer.singleShot(600, self._maybe_show_login)
def _verify_node_thread(self):
"""等节点配置在线同步完成后,校验当前选中节点是否可用,必要时自动回退。
探测要解析 DNS 并尝试 TCP 连接,放在后台线程,避免卡住启动。"""
import time
time.sleep(1.5)
try:
if _verify_selected_node():
self._node_switched.emit()
except Exception as e:
log(f"节点可用性校验失败: {e}")
def _on_node_switched(self):
"""节点被自动切换后刷新标题栏(在 UI 线程执行)。"""
self.subtitle.setText(f"{_endpoint_domain()} ({_endpoint_country()})")
def init_ui(self):
"""初始化主界面布局与样式。"""
self.setWindowTitle("R.R.P.P.")
@@ -1945,6 +2448,9 @@ class MainWindow(QWidget):
if _endpoint_domain() == "???":
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置")
if not _node_reachable(_current_node()):
# 当前选中节点是 IPv6 专用而本机没有 IPv6 出口:转发的流量全部连不上
raise RuntimeError("当前节点仅提供 IPv6 地址,本机没有 IPv6 出口,请在节点菜单切换其他节点")
_traffic_reset()
log("TLS 直连模式")
ep = _endpoint_domain()
@@ -2042,6 +2548,11 @@ class MainWindow(QWidget):
"nodes.json 中没有可用节点。\n"
"可在右下角「设置」中退出登录后重新登录获取节点配置。")
return
menu = self._build_node_menu(nodes)
menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft()))
def _build_node_menu(self, nodes):
"""构建节点切换菜单:IPv6 专用节点加标注,本机无 IPv6 出口时置灰不可选。"""
menu = QMenu(self)
menu.setStyleSheet(f"""
QMenu {{
@@ -2068,11 +2579,24 @@ class MainWindow(QWidget):
text = f"{domain} ({country})"
if remark and remark != domain:
text += f" {remark}"
ipv6_only = _node_ipv6_only(n)
reachable = _node_reachable(n)
if ipv6_only:
# 不可达时的文案由 tooltip 改为直接写在菜单项上,用户一眼可见
text += " [IPv6]" if reachable else " [不可用:本机无 IPv6]"
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()))
act = menu.addAction(text)
if not reachable:
# 本机无 IPv6 出口:置灰不可选,避免选中后连不上
act.setEnabled(False)
act.setToolTip("不可用:本机无 IPv6")
else:
if ipv6_only:
act.setToolTip("该节点仅提供 IPv6 地址(本机 IPv6 可用)")
act.triggered.connect(
lambda _=False, idx=i: self._on_node_selected(idx))
return menu
def _on_node_selected(self, idx):
"""切换节点:更新标题。"""
@@ -2175,11 +2699,10 @@ class MainWindow(QWidget):
if _skip_update_check() and not manual:
return # 设置跳过(仅自动检查):不检查也不打日志
import json, urllib.request
# 基于 Gitea 仓库 releases API 检查更新;config.json 无 update_url 时用默认地址
# 基于 Gitea 仓库 releases API 检查更新;地址只取 config.json 的 update_url
url = str(_config_val("update_url", "") or "").strip()
if not url:
url = ("https://git.bu4.ren/api/v1/repos/RoyirinRosmontis/"
"Private-Proxy/releases/latest")
raise RuntimeError("config.json 未配置 update_url")
log("正在检查更新...")
if _update_via_proxy():
# 走节点隧道(CONNECT,设置「使用代理更新」开启时);显式代理避免残留干扰
+75
View File
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""离屏验证节点菜单:IPv6-only 节点的标注与置灰(真实 PyQt5,不出窗口)。
需要 Windows venv 的真实 PyQt5:
..\\.venv\\Scripts\\python.exe test\\check_node_menu.py
读取 test/nodes.json 作为节点数据,只构建菜单不进事件循环。
"""
import os, sys
os.environ["QT_QPA_PLATFORM"] = "offscreen"
from pathlib import Path
LITE = Path(__file__).resolve().parent.parent
# 脚本所在目录(test/)会被解释器自动放到 sys.path 首位,那里有 PyQt5 桩,
# 会顶掉真实 PyQt5 —— 必须移除,改用本程序自己的 venv
sys.path = [str(LITE)] + [p for p in sys.path if Path(p).resolve() != Path(__file__).resolve().parent]
sys.stdout.reconfigure(encoding="utf-8")
import proxy_lite as pp
from PyQt5.QtWidgets import QApplication
# 构造窗口时不写用户数据目录(避免污染),构造后再指向真实 nodes.json
pp._user_init_local = lambda: None
app = QApplication(sys.argv)
w = pp.MainWindow()
w.timer.stop() # 停掉状态轮询,纯静态检查
pp._USER_DIR = LITE / "test" # 真实 nodes.json 所在目录
nodes = pp._reload_nodes()
print(f"本机有全局 IPv6: {pp._local_has_ipv6()}")
print(f"节点数: {len(nodes)}")
menu = w._build_node_menu(nodes)
print("\n菜单项(enabled / 文案 / tooltip):")
for act in menu.actions():
print(f" {str(act.isEnabled()):5} | {act.text()}")
if act.toolTip():
print(f" └ {act.toolTip()}")
# 点击被禁用的项不应切换节点
before = pp._selected_index()
for act in menu.actions():
if not act.isEnabled():
act.trigger()
print(f"\n点击禁用项后节点下标: {before} -> {pp._selected_index()} (应不变)")
# ── 上次选中的节点已不可用:启动时自动回退到第一个可达节点 ──────────
print("\n启动校验(_verify_selected_node):")
bad = [i for i, n in enumerate(nodes) if not pp._node_reachable(n)]
if not bad:
print(" 本机所有节点均可达,无法验证回退(本机有 IPv6?)")
else:
import json
_bak = (pp._USER_DIR / "settings.json").read_bytes() if (pp._USER_DIR / "settings.json").is_file() else None
bad_idx = bad[0]
pp._SELECTED_NODE = bad_idx # 模拟上次选了不可用节点
switched = pp._verify_selected_node()
expect = pp._first_reachable_index(nodes)
ok = switched and pp._selected_index() == expect
print(f" 上次选中 {nodes[bad_idx].get('domain')}(下标 {bad_idx}) 不可用 -> "
f"回退到下标 {pp._selected_index()} ({nodes[pp._selected_index()].get('domain')}) "
f"{'PASS' if ok else 'FAIL'}")
saved = json.loads((pp._USER_DIR / "settings.json").read_text("utf-8")).get("current_node") if (pp._USER_DIR / "settings.json").is_file() else ""
ok2 = saved == str(nodes[expect].get("domain") or "")
print(f" 回退结果已写入设置 current_node={saved!r} {'PASS' if ok2 else 'FAIL'}")
# 再校验一次:已经是可达节点,不应再切换
ok3 = pp._verify_selected_node() is False
print(f" 再次校验不重复切换 {'PASS' if ok3 else 'FAIL'}")
if _bak is None:
(pp._USER_DIR / "settings.json").unlink(missing_ok=True)
else:
(pp._USER_DIR / "settings.json").write_bytes(_bak)
print("OK")
+450 -19
View File
@@ -1,26 +1,457 @@
# server_config
# RRPP 服务端部署规范
> 一套统一的跨境代理节点部署流程,适用于 Debian/Ubuntu 系 Linux。
> 每台节点运行相同的服务栈:**BBR 拥塞控制 + nginx CONNECT 隧道 + sing-box(VLESS+REALITY / Shadowsocks) + mtg(MTProto)**。
>
> 本文档为**通用规范**,所有 IP / 域名 / 端口 / 密钥均以**占位符**表示,部署时替换为实际值。
> 凡标注 `<...>` 的均为占位符,不要原样照抄。
---
## 目录
- [0. 架构总览](#0-架构总览)
- [1. 端口与服务规划](#1-端口与服务规划)
- [2. 系统基础优化(BBR 跨境丢包优化)](#2-系统基础优化bbr-跨境丢包优化)
- [3. 伪装站点页面](#3-伪装站点页面)
- [4. HTTPS 证书(acme.sh DNS-01)](#4-https-证书acmesh-dns-01)
- [5. nginx 源码编译 + CONNECT 隧道](#5-nginx-源码编译--connect-隧道)
- [6. sing-box(VLESS+REALITY / Shadowsocks)](#6-sing-boxvlessreality--shadowsocks)
- [7. mtg(MTProto 代理)](#7-mtgmtproto-代理)
- [8. 本机公网 IP 自动识别(防自代理回环)](#8-本机公网-ip-自动识别防自代理回环)
- [9. systemd 服务文件](#9-systemd-服务文件)
- [10. 启动顺序与验证清单](#10-启动顺序与验证清单)
- [11. 目录结构与文件索引](#11-目录结构与文件索引)
---
## 0. 架构总览
```
客户端 ──► nginx(443/<nginx端口>) ──┬─► CONNECT 隧道(任意端口, 全端口放行)
│ └─► 伪装页面(直接访问/IP访问)
│
├─► sing-box(<vless端口>) ──► VLESS+REALITY(vision)
├─► sing-box(<ss端口>) ──► Shadowsocks(2022-blake3-aes-256-gcm)
└─► mtg(<mtproto端口>) ──► MTProto(Fake TLS, Telegram 内置代理)
```
**四层代理各司其职:**
| 层 | 组件 | 协议 | 适用场景 |
|---|---|---|---|
| HTTP 隧道 | nginx | CONNECT + Basic Auth | 浏览器/curl/系统代理,全端口 WebSocket 透传 |
| 通用代理 | sing-box | VLESS+REALITY | Shadowrocket/Clash/v2rayN 客户端 |
| 兼容旧客户端 | sing-box | Shadowsocks | Shadowrocket 2.x 等不支持 REALITY 的客户端 || Telegram 专用 | mtg | MTProto Fake TLS | Telegram 内置代理,抗封锁 |
**核心密钥体系(2026-09 起全部每节点独立):**
- VLESS+REALITY:每节点**独立** UUID、REALITY 密钥对、short_id;SNI 仍全节点统一(用 `<伪装SNI域名>`,选一个高流量、TLS 指纹稳定的知名站点域名)
- Shadowsocks:每节点**独立** PSK,方法 `2022-blake3-aes-256-gcm`(SIP022,抗重放探测)
- nginx CONNECT:每节点**独立** Basic Auth 密码(客户端按节点携带凭据,无兼容性问题)
- MTProto:每台**独立 secret**(Fake TLS 类型,fronting 到 `<伪装SNI域名>`)
> 独立化的目的:单节点密钥泄漏/被污染时不波及其余节点,且换钥粒度更小。
> 代价是订阅/nodes.json 必须逐节点维护,改任一项后必须做端到端验证(见 6.3、第 10 节)。
---
## 1. 端口与服务规划
端口全部落在**同一区段**便于防火墙管理(示例用 1200–1220,可按需调整):
| 端口 | 服务 | 说明 |
|---|---|---|
| `<nginx端口>` | nginx | HTTPS 站点 + CONNECT 隧道(示例 `1205`) |
| `<vless端口>` | sing-box | VLESS+REALITY(示例 `1206`) |
| `<ss端口>` | sing-box | Shadowsocks(示例 `1207`) |
| `<mtproto端口>` | mtg | MTProto(示例 `1210`) |
> 节点 A 用 `80xx` 区段、节点 B 用 `120xx` 区段皆可,**只要同节点内不冲突、且运营商/防火墙放行**。
> 防火墙放行:`iptables -A INPUT -p tcp -m multiport --dports <nginx端口>,<vless端口>,<ss端口>,<mtproto端口> -j ACCEPT`
**域名规划:** 每个节点一个独立域名 `<节点域名>`(如 `node1.example.com`),A 记录指向节点公网 IP。acme.sh 用 DNS-01 签证书,无需 80 端口可达。
---
## 2. 系统基础优化(BBR 跨境丢包优化)
BBR(Bottleneck Bandwidth and RTT)+ fq 队列,显著改善跨境 TCP 丢包/高延迟下的吞吐。
### 2.1 启用 BBR
写入 `/etc/sysctl.d/99-bbr.conf`:
```conf
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
```
应用并验证:
```bash
modprobe tcp_bbr 2>/dev/null || true # 部分内核需手动加载模块
sysctl --system
sysctl net.ipv4.tcp_congestion_control net.core.default_qdisc
# 期望输出: bbr / fq
```
> **内核要求**:BBR 需 Linux 4.9+。`default_qdisc=fq` 在部分新内核(6.12+ cloud 内核)可能无此参数,仅保留 `tcp_congestion_control=bbr` 即可,不影响 BBR 生效。
### 2.2 文件描述符
nginx worker 高并发需要提高 limit,在 `/etc/security/limits.conf` 追加:
```
* soft nofile 65535
* hard nofile 65535
```
### 2.3 时区与 NTP
代理证书校验、日志排障都依赖准确时间:
```bash
timedatectl set-timezone Asia/Shanghai
apt install -y chrony && systemctl enable --now chrony
```
### 2.4 PMTUD 黑洞缓解(tcp_mtu_probing)
**症状**:部分客户端网络上出现"TCP 能连上、但 TLS 握手稳定超时"
(如 `_ssl.c:1011 handshake operation timed out`),而服务器从其他网络探测一切正常。
原因是客户端↔服务器路径上某跳 MTU 变小且 ICMP Fragmentation Needed 被过滤
(PMTUD 黑洞),服务器发出的满尺寸大包永远到不了客户端。
开启 RFC 4821 PLPMTUD,让服务器在探测失败后自动降低 MSS 恢复通信。
写入 `/etc/sysctl.d/99-mtu-probing.conf`:
```conf
net.ipv4.tcp_mtu_probing = 1
```
```bash
sysctl --system
sysctl net.ipv4.tcp_mtu_probing # 期望输出: 1
```
> 只影响命中黑洞的连接,正常连接无感知,可安全全节点开启。
### 2.5 fail2ban(sshd 爆破防护)
节点 SSH 暴露在公网,爆破尝试是常态(实测单节点累计 3 万+ 次)。装 fail2ban 护 sshd:
```bash
apt install -y fail2ban
cat > /etc/fail2ban/jail.d/sshd.local << 'EOF'
[sshd]
enabled = true
backend = systemd
EOF
systemctl enable --now fail2ban
fail2ban-client status sshd # 确认 jail 在跑
```
> - `backend = systemd`:无 `/var/log/auth.log` 的最小化系统(无 rsyslog)必须加这行,
> 否则 fail2ban 启动报 "Have not found any log file for sshd jail"。
> - LXC 容器也能正常封禁(实测 ban/unban 链路可用)。
> - 只加 fail2ban、**不动 sshd 认证方式**(root 登录等现状保留)。
> - 如需同时护 nginx CONNECT 的 401 爆破,可再加 nginx jail(凭据够强时可不做)。
---
## 3. 伪装站点页面
直接访问节点/IP 时返回一个普通网页(而非代理错误),降低被探测的特征。
放置一个静态 `index.html` 到 `/var/www/html/index.html`:
- 任意抓取的合法 HTML 页面(博客/门户首页快照均可)
- **所有节点用同一份**(md5 一致),避免逐节点特征差异
```bash
mkdir -p /var/www/html
# index.html 为本仓库 fake-site/index.html 的拷贝
cp fake-site/index.html /var/www/html/index.html
```
---
## 4. HTTPS 证书(acme.sh DNS-01)
nginx 站点与 CONNECT 隧道共用一张证书。用 [acme.sh](https://github.com/acmesh-official/acme.sh) + DNS-01(以阿里云 DNS 为例,其他 DNS 商同理):
```bash
curl https://get.acme.sh | sh -s email=<你的邮箱>
export Ali_Key="<阿里云AccessKey>"
export Ali_Secret="<阿里云Secret>"
~/.acme.sh/acme.sh --issue --dns dns_ali -d <节点域名> --keylength ec-256
# 安装到 nginx 读取路径
mkdir -p /etc/nginx/ssl/<节点域名>
~/.acme.sh/acme.sh --install-cert -d <节点域名> --ecc \
--fullchain-file /etc/nginx/ssl/<节点域名>/fullchain.pem \
--key-file /etc/nginx/ssl/<节点域名>/privkey.pem
```
acme.sh 自带 cron 自动续期。证书路径固定为 `/etc/nginx/ssl/<节点域名>/{fullchain,privkey}.pem`。
---
## 5. nginx 源码编译 + CONNECT 隧道
标准 nginx 不自带 CONNECT 方法支持,需打 [ngx_http_proxy_connect_module](https://github.com/chobits/ngx_http_proxy_connect_module) 补丁后**源码编译**。
### 5.1 编译
```bash
apt install -y build-essential libssl-dev libpcre3-dev zlib1g-dev curl wget
NGINX_VER=1.26.3
cd /usr/local/src
curl -LO http://nginx.org/download/nginx-$NGINX_VER.tar.gz
tar xzf nginx-$NGINX_VER.tar.gz
git clone https://github.com/chobits/ngx_http_proxy_connect_module.git
cd nginx-$NGINX_VER
patch -p1 < ../ngx_http_proxy_connect_module/patch/proxy_connect_rewrite_102101.patch
./configure \
--prefix=/usr/local/nginx \
--conf-path=/etc/nginx/nginx.conf \
--pid-path=/var/run/nginx.pid \
--error-log-path=/var/log/nginx/error.log \
--http-log-path=/var/log/nginx/access.log \
--with-http_ssl_module \
--with-http_v2_module \
--with-http_realip_module \
--with-http_stub_status_module \
--with-http_gzip_static_module \
--add-module=../ngx_http_proxy_connect_module
make -j$(nproc) && make install
```
> 若发行版 nginx 已自带 proxy_connect 补丁(极少见),可用 `apt install nginx` 跳过编译,但源码编译是通用做法。
### 5.2 Basic Auth 凭据
CONNECT 代理需要 Basic Auth(用户名/密码),**每节点独立密码**(客户端配置按节点携带凭据,无兼容性问题)。生成 htpasswd:
```bash
apt install -y apache2-utils
htpasswd -bc /etc/nginx/.htpasswd <代理用户名> '<本节点独立密码>'
chmod 644 /etc/nginx/.htpasswd # worker 以 nobody 运行,必须可读
```
> 无 apache2-utils 的机器可用 openssl 生成(sha512-crypt,nginx 同样支持):
> `echo "<用户名>:$(openssl passwd -6 '<密码>')" > /etc/nginx/.htpasswd`
> htpasswd 是**每次请求实时读取**的,改密码无需 reload nginx。
### 5.3 nginx.conf
完整配置见 [nginx/nginx.conf](nginx/nginx.conf)。**关键设计**:
- **两个 server 块共用 `<nginx端口>`**:
- 主块 `server_name <节点域名>`:证书站点 + `location /` 返回伪装页;非本域名一律 rewrite 到 `/__proxy` 转发
- fallback 块 `default_server`:其他 SNI 也接受 CONNECT 代理;本机/私网/公网 IP 直连返回伪装页,其余走 `/__proxy`
- `proxy_connect_allow all;`:**全端口** CONNECT 放行(codex/WebSocket 走任意端口均通)。若需限制,改回 `proxy_connect_allow 443 563;`
- `auth_basic`:仅 CONNECT/正向代理需要认证,`location /` 伪装页 `auth_basic off`
- **自代理回环保护**:`/__proxy` 转发时带 `X-Proxy-Loop-Guard` 头,二次进入返回 508 防死循环
- `/__local`:本机 IP 直连时剥前缀返回伪装页
- `map $host $is_local`:私网/回环段判定;公网 IP 由 cron 脚本动态写入 `local_ip_rules.conf`
### 5.4 自代理回环保护说明
正向代理转发目标若指向**本机自己**(如域名公网 IP 变更、或代理目标就是本节点),会无限循环。两层防护:
1. `map` 判定私网/回环段 + cron 维护的本机公网 IP 规则 → 直连伪装页
2. `/__proxy` 的 Loop-Guard 头兜底 → 二次进入返回 508
---
## 6. sing-box(VLESS+REALITY / Shadowsocks)
### 6.1 安装
```bash
SINGBOX_VER=1.13.19
curl -LO https://github.com/SagerNet/sing-box/releases/download/v$SINGBOX_VER/sing-box-$SINGBOX_VER-linux-amd64.tar.gz
tar xzf sing-box-*.tar.gz
install -m 755 sing-box-$SINGBOX_VER-linux-amd64/sing-box /usr/local/bin/sing-box
```
### 6.2 配置
完整配置见 [sing-box/config.json](sing-box/config.json)。要点:
- **VLESS+REALITY**:每节点独立 UUID/密钥对/short_id,SNI 全节点统一,flow `xtls-rprx-vision`;REALITY handshake server 用 `<伪装SNI域名>:443`
- **Shadowsocks**:`2022-blake3-aes-256-gcm`(SIP022:会话子密钥 + 抗重放探测,显著优于老 AEAD `aes-256-gcm`)+ 每节点独立 PSK,独立端口(兼容不支持 REALITY 的老客户端)
- **PSK 格式要求**:密码必须是 base64 编码的 32 字节密钥(`openssl rand -base64 32`),不是任意字符串;改错格式 sing-box 启动即报错
- sing-box / Shadowrocket / mihomo 均支持 2022-blake3 系列
- 无 SOCKS5 inbound(已按需移除;如需可在 inbounds 追加 `type: socks`)
### 6.3 REALITY 密钥生成
**每台节点独立生成一套**(互不共用):
```bash
# 生成私钥/公钥(sing-box 1.13+ 内置 reality 密钥生成)
sing-box generate reality-keypair
# 输出 private_key / public_key,记录备用
# short_id 用 8 字节随机 hex:
openssl rand -hex 8
# UUID:
sing-box generate uuid
```
> 私钥只放服务端配置;公钥给客户端订阅。UUID/密钥对/short_id 每节点独立。
**公钥必须推导验证,禁止手抄。** 订阅/节点配置里的 `public_key` 与服务端
`private_key` 必须严格配对(X25519 推导),曾发生手抄错一个字符导致全节点
REALITY 握手失败(客户端报 `reality verification failed`)的事故。改配置后验证:
```bash
python3 - << 'EOF'
import base64
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives import serialization
priv = base64.urlsafe_b64decode('<REALITY_PRIVATE_KEY>' + '==')
pub = X25519PrivateKey.from_private_bytes(priv).public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
print(base64.urlsafe_b64encode(pub).decode().rstrip('=')) # 应与下发的 public_key 逐字符一致
EOF
```
---
## 7. mtg(MTProto 代理)
Telegram 内置代理,Fake TLS 模式抗封锁。用 [mtg](https://github.com/9seconds/mtg)(Go 单二进制):
### 7.1 安装
```bash
MTG_VER=2.2.8
curl -LO https://github.com/9seconds/mtg/releases/download/v$MTG_VER/mtg-$MTG_VER-linux-amd64.tar.gz
tar xzf mtg-*.tar.gz
install -m 755 mtg-$MTG_VER-linux-amd64/mtg /usr/local/bin/mtg
```
### 7.2 生成 secret(每台独立)
```bash
mtg generate-secret --hex <伪装SNI域名>
# 输出 ee<32hex><域名hex>,以 ee 开头 = Fake TLS 类型
```
### 7.3 运行
```bash
mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <上面生成的secret>
```
> `--prefer-ip=prefer-ipv4`:强制 IPv4 出站,避免容器/双栈环境下 IPv6 不可达导致回退失败。
### 7.4 Telegram 客户端参数
- 服务器:`<节点域名>`
- 端口:`<mtproto端口>`
- Secret:上面生成的 hex(Telegram 自动识别 Fake TLS)
---
## 8. 本机公网 IP 自动识别(防自代理回环)
正向代理转发时,若目标 Host 是本机公网 IP,会被识别为"本机直连"返回伪装页(而非经代理转发本机,造成回环)。公网 IP 可能变动,用 cron 脚本每 5 分钟自动维护。
脚本见 [scripts/sync-nginx-local-ips.sh](scripts/sync-nginx-local-ips.sh),cron 见 [cron/sync-nginx-local-ips](cron/sync-nginx-local-ips):
```cron
*/5 * * * * root /usr/local/sbin/sync-nginx-local-ips.sh >/dev/null 2>&1
```
脚本逻辑:取 `api.ipify.org` 出站 IP + 节点域名 A 记录,比对重写 `/etc/nginx/local_ip_rules.conf`,**仅变更时** `nginx -t && systemctl reload nginx`。
---
## 9. systemd 服务文件
| 服务 | 文件 |
|---|---|
| nginx | [systemd/nginx.service](systemd/nginx.service) |
| sing-box | [systemd/sing-box.service](systemd/sing-box.service) |
| mtg | [systemd/mtg.service](systemd/mtg.service) |
```bash
cp systemd/*.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now nginx sing-box mtg
```
---
## 10. 启动顺序与验证清单
### 启动顺序
```bash
sysctl --system # 1. BBR
systemctl start sing-box # 2. VLESS/SS
systemctl start mtg # 3. MTProto
systemctl start nginx # 4. nginx(最后,依赖证书/伪装页/规则文件)
```
### 验证清单
```bash
# BBR
sysctl net.ipv4.tcp_congestion_control # => bbr
sysctl net.ipv4.tcp_mtu_probing # => 1(PMTUD 黑洞缓解,见 2.4)
# fail2ban
fail2ban-client status sshd # => jail 存在(见 2.5)
# sing-box
sing-box check -c /etc/sing-box/config.json
systemctl is-active sing-box
# mtg(Fake TLS:非法 ClientHello 应返回 TLS alert 0x15)
timeout 8 python3 -c "
import socket,os,struct
s=socket.connect(('<节点域名>',<mtproto端口>)) if False else socket.create_connection(('<节点域名>',<mtproto端口>),timeout=6)
s.sendall(b'\x16\x03\x01'+struct.pack('>H',100)+os.urandom(100))
print('resp:',s.recv(8).hex())" # 期望首字节 15 (TLS alert)
# nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>'))
B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0)
curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \
--proxy-header "Authorization: Basic $B64" \
-o /dev/null -w '%{http_code}\n' http://example.com/ # => 200
# 伪装页(直连 IP/域名,无认证)
curl -sk https://<节点域名>:<nginx端口>/ -o /dev/null -w '%{http_code}\n' # => 200
```
---
## 11. 目录结构与文件索引
```
server_config/
├── README.md ← 本文档
├── nginx/
│ ├── nginx.conf
│ ├── conf.d/
│ │ ├── api.aeolianchi.me.conf
│ │ └── autodiscover.aeolianchi.me.conf
│ └── local_ip_rules.conf.example
│ ├── nginx.conf ← 完整 nginx 配置(占位符版)
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
│ └── conf.d/ ← 预留:额外站点配置
├── sing-box/
│ └── config.json
├── traffic-monitor/
│ ├── config.json
│ ├── traffic_monitor.py
│ └── traffic-monitor.service
│ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
├── systemd/
│ ├── nginx.service
│ ├── sing-box.service
│ └── mtg.service
├── scripts/
│ ├── sync-nginx-local-ips.sh
│ └── rrpp-stats
├── pac/
│ ├── proxy.pac
│ └── proxy_wg.pac
└── cron/
├── crontab.txt
└── sync-nginx-local-ips
│ └── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
├── cron/
│ └── sync-nginx-local-ips ← cron 定义(每 5 分钟)
└── fake-site/
└── index.html ← 伪装站点页面(所有节点共用同一份)
```
> 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。
-2
View File
@@ -1,2 +0,0 @@
* * * * * /usr/local/bin/rrpp-stats | sudo tee /root/backups/stats.json > /dev/null
27 0,6,12,18 * * * "/home/RRRosmontis/.acme.sh"/acme.sh --cron --home "/home/RRRosmontis/.acme.sh" > /dev/null
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
server {
listen 443 ssl;
http2 on;
server_name api.aeolianchi.me;
ssl_certificate /etc/nginx/ssl/api.aeolianchi.me/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/api.aeolianchi.me/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location /vpn/status/traffic {
proxy_pass http://127.0.0.1:18080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
@@ -1,44 +0,0 @@
# autodiscover.aeolianchi.me -> jp 反代 -> bj autodiscover.rrrosmontis.icu
server {
listen 80;
listen [::]:80;
server_name autodiscover.aeolianchi.me;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://$host$request_uri;
}
}
# autodiscover.aeolianchi.me 443 -> 反代 -> bj autodiscover.rrrosmontis.icu
server {
listen 443 ssl;
http2 on;
server_name autodiscover.aeolianchi.me;
ssl_certificate /etc/nginx/ssl/aeolianchi.me/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/aeolianchi.me/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000";
error_page 497 https://$host$request_uri;
location / {
proxy_pass https://autodiscover.rrrosmontis.icu;
proxy_set_header Host autodiscover.rrrosmontis.icu;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_ssl_name autodiscover.rrrosmontis.icu;
}
access_log /var/log/nginx/autodiscover.aeolianchi.me.log;
error_log /var/log/nginx/autodiscover.aeolianchi.me.error.log;
}
@@ -1,4 +1,5 @@
# nginx 本机公网 IP 直连规则(自动生成,勿手改;由 sync-nginx-local-ips.sh 维护)
# 公网 IP 变更后本文件会被重写并 reload nginx
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
# 每条规则对应一个本机公网 IP(出站 IP + 节点域名 A 记录)
if ($host = "<PUBLIC_IP_1>") { rewrite ^(.*)$ /__local$1 last; }
if ($host = "<PUBLIC_IP_2>") { rewrite ^(.*)$ /__local$1 last; }
+12 -43
View File
@@ -1,4 +1,3 @@
load_module /usr/lib/nginx/modules/ngx_http_proxy_connect_module.so;
worker_processes auto;
worker_rlimit_nofile 65535;
events {
@@ -25,45 +24,15 @@ http {
keepalive_timeout 65;
keepalive_requests 100;
# 80 端口:iam.aeolianchi.me 强制跳转到 HTTPS(保留 ACME 验证路径)
# <nginx端口>:主域名 HTTPS 站点 + 认证 CONNECT 代理
server {
listen 80;
server_name iam.aeolianchi.me;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://$host$request_uri;
}
}
# 80 端口:默认站点只做静态伪装,不提供代理、不弹认证
server {
listen 80 default_server;
server_name _;
root /var/www/html;
index index.html;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
try_files $uri $uri/ =404;
}
}
server {
listen 443 ssl;
listen <nginx端口> ssl;
http2 on;
server_name XXX;
server_name <节点域名>;
root /var/www/html;
ssl_certificate /etc/nginx/ssl/XXX/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/XXX/privkey.pem;
ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
@@ -74,14 +43,14 @@ http {
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_connect;
proxy_connect_allow 443 563;
proxy_connect_allow all;
proxy_connect_connect_timeout 60s;
proxy_connect_read_timeout 120s;
proxy_connect_send_timeout 120s;
location / {
auth_basic off;
if ($host != "iam.aeolianchi.me") {
if ($host != "<节点域名>") {
rewrite ^ /__proxy last;
}
index index.html;
@@ -104,14 +73,14 @@ http {
}
}
# 443 端口 fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
# <nginx端口> fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
server {
listen 443 ssl default_server;
listen <nginx端口> ssl default_server;
http2 on;
server_name _;
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/iam.aeolianchi.me/privkey.pem;
ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
@@ -122,7 +91,7 @@ http {
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_connect;
proxy_connect_allow 443 563;
proxy_connect_allow all;
proxy_connect_connect_timeout 60s;
proxy_connect_read_timeout 120s;
proxy_connect_send_timeout 120s;
-647
View File
@@ -1,647 +0,0 @@
function FindProxyForURL(url, host) {
// RRPP 规则代理列表 — 仅以下域名走加密隧道
var proxy = "PROXY 127.0.0.1:1080";
// Google 全系 + Gemini + CDN
if (shExpMatch(host, "google.com") ||
shExpMatch(host, "*.google.com") ||
shExpMatch(host, "google.com.hk") ||
shExpMatch(host, "*.google.com.hk") ||
shExpMatch(host, "google.co.jp") ||
shExpMatch(host, "*.google.co.jp") ||
shExpMatch(host, "googleapis.com") ||
shExpMatch(host, "*.googleapis.com") ||
shExpMatch(host, "googleusercontent.com") ||
shExpMatch(host, "*.googleusercontent.com") ||
shExpMatch(host, "googletagmanager.com") ||
shExpMatch(host, "*.googletagmanager.com") ||
shExpMatch(host, "google-analytics.com") ||
shExpMatch(host, "*.google-analytics.com") ||
shExpMatch(host, "gstatic.com") ||
shExpMatch(host, "*.gstatic.com") ||
shExpMatch(host, "ggpht.com") ||
shExpMatch(host, "*.ggpht.com") ||
shExpMatch(host, "googlehosted.com") ||
shExpMatch(host, "*.googlehosted.com") ||
shExpMatch(host, "1e100.net") ||
shExpMatch(host, "*.1e100.net") ||
shExpMatch(host, "youtube.com") ||
shExpMatch(host, "*.youtube.com") ||
shExpMatch(host, "ytimg.com") ||
shExpMatch(host, "*.ytimg.com") ||
shExpMatch(host, "googlevideo.com") ||
shExpMatch(host, "*.googlevideo.com") ||
shExpMatch(host, "gemini.google.com") ||
shExpMatch(host, "*.gemini.google.com") ||
shExpMatch(host, "generativeai.googleapis.com") ||
shExpMatch(host, "*.generativeai.googleapis.com") ||
shExpMatch(host, "aistudio.google.com") ||
shExpMatch(host, "*.aistudio.google.com") ||
shExpMatch(host, "deepmind.google.com") ||
shExpMatch(host, "*.deepmind.google.com") ||
shExpMatch(host, "deepmind.com") ||
shExpMatch(host, "*.deepmind.com")) {
return proxy;
}
// OpenAI / ChatGPT 全系 + CDN
if (shExpMatch(host, "openai.com") ||
shExpMatch(host, "*.openai.com") ||
shExpMatch(host, "chatgpt.com") ||
shExpMatch(host, "*.chatgpt.com") ||
shExpMatch(host, "oaistatic.com") ||
shExpMatch(host, "*.oaistatic.com") ||
shExpMatch(host, "oaiusercontent.com") ||
shExpMatch(host, "*.oaiusercontent.com") ||
shExpMatch(host, "openaiusercontent.com") ||
shExpMatch(host, "*.openaiusercontent.com")) {
return proxy;
}
// Grok / xAI 全系 + CDN
if (shExpMatch(host, "grok.com") ||
shExpMatch(host, "*.grok.com") ||
shExpMatch(host, "x.com") ||
shExpMatch(host, "*.x.com") ||
shExpMatch(host, "twitter.com") ||
shExpMatch(host, "*.twitter.com") ||
shExpMatch(host, "twimg.com") ||
shExpMatch(host, "*.twimg.com") ||
shExpMatch(host, "x.ai") ||
shExpMatch(host, "*.x.ai") ||
shExpMatch(host, "xai.com") ||
shExpMatch(host, "*.xai.com")) {
return proxy;
}
// Meta 全系 + CDN
if (shExpMatch(host, "meta.com") ||
shExpMatch(host, "*.meta.com") ||
shExpMatch(host, "meta.ai") ||
shExpMatch(host, "*.meta.ai") ||
shExpMatch(host, "facebook.com") ||
shExpMatch(host, "*.facebook.com") ||
shExpMatch(host, "fb.com") ||
shExpMatch(host, "*.fb.com") ||
shExpMatch(host, "fbcdn.net") ||
shExpMatch(host, "*.fbcdn.net") ||
shExpMatch(host, "fbsbx.com") ||
shExpMatch(host, "*.fbsbx.com") ||
shExpMatch(host, "instagram.com") ||
shExpMatch(host, "*.instagram.com") ||
shExpMatch(host, "cdninstagram.com") ||
shExpMatch(host, "*.cdninstagram.com") ||
shExpMatch(host, "threads.net") ||
shExpMatch(host, "*.threads.net") ||
shExpMatch(host, "whatsapp.com") ||
shExpMatch(host, "*.whatsapp.com") ||
shExpMatch(host, "whatsapp.net") ||
shExpMatch(host, "*.whatsapp.net") ||
shExpMatch(host, "messenger.com") ||
shExpMatch(host, "*.messenger.com")) {
return proxy;
}
// Claude / Anthropic 全系 + CDN
if (shExpMatch(host, "anthropic.com") ||
shExpMatch(host, "*.anthropic.com") ||
shExpMatch(host, "claude.ai") ||
shExpMatch(host, "*.claude.ai") ||
shExpMatch(host, "claudeusercontent.com") ||
shExpMatch(host, "*.claudeusercontent.com")) {
return proxy;
}
// Dify 全系
if (shExpMatch(host, "dify.ai") ||
shExpMatch(host, "*.dify.ai") ||
shExpMatch(host, "udify.app") ||
shExpMatch(host, "*.udify.app")) {
return proxy;
}
// Cloudflare 全系
if (shExpMatch(host, "cloudflare.com") ||
shExpMatch(host, "*.cloudflare.com") ||
shExpMatch(host, "cloudflare.net") ||
shExpMatch(host, "*.cloudflare.net") ||
shExpMatch(host, "cloudflareinsights.com") ||
shExpMatch(host, "*.cloudflareinsights.com") ||
shExpMatch(host, "cloudflarestatus.com") ||
shExpMatch(host, "*.cloudflarestatus.com") ||
shExpMatch(host, "cloudflarecdn.com") ||
shExpMatch(host, "*.cloudflarecdn.com") ||
shExpMatch(host, "cf-cdn.com") ||
shExpMatch(host, "*.cf-cdn.com") ||
shExpMatch(host, "workers.dev") ||
shExpMatch(host, "*.workers.dev") ||
shExpMatch(host, "pages.dev") ||
shExpMatch(host, "*.pages.dev")) {
return proxy;
}
// GitHub 全系
if (shExpMatch(host, "github.com") ||
shExpMatch(host, "*.github.com") ||
shExpMatch(host, "githubusercontent.com") ||
shExpMatch(host, "*.githubusercontent.com") ||
shExpMatch(host, "githubassets.com") ||
shExpMatch(host, "*.githubassets.com") ||
shExpMatch(host, "github.io") ||
shExpMatch(host, "*.github.io") ||
shExpMatch(host, "githubcopilot.com") ||
shExpMatch(host, "*.githubcopilot.com") ||
shExpMatch(host, "ghcr.io") ||
shExpMatch(host, "*.ghcr.io")) {
return proxy;
}
// Hugging Face 全系 + CDN
if (shExpMatch(host, "huggingface.co") ||
shExpMatch(host, "*.huggingface.co") ||
shExpMatch(host, "hf.co") ||
shExpMatch(host, "*.hf.co") ||
shExpMatch(host, "huggingfacecdn.com") ||
shExpMatch(host, "*.huggingfacecdn.com")) {
return proxy;
}
// Microsoft Copilot 全系 + Bing + Azure CDN
if (shExpMatch(host, "microsoft.com") ||
shExpMatch(host, "*.microsoft.com") ||
shExpMatch(host, "bing.com") ||
shExpMatch(host, "*.bing.com") ||
shExpMatch(host, "bingapis.com") ||
shExpMatch(host, "*.bingapis.com") ||
shExpMatch(host, "bing.net") ||
shExpMatch(host, "*.bing.net") ||
shExpMatch(host, "live.com") ||
shExpMatch(host, "*.live.com") ||
shExpMatch(host, "microsoftonline.com") ||
shExpMatch(host, "*.microsoftonline.com") ||
shExpMatch(host, "microsoft365.com") ||
shExpMatch(host, "*.microsoft365.com") ||
shExpMatch(host, "office.com") ||
shExpMatch(host, "*.office.com") ||
shExpMatch(host, "office.net") ||
shExpMatch(host, "*.office.net") ||
shExpMatch(host, "azure.com") ||
shExpMatch(host, "*.azure.com") ||
shExpMatch(host, "azureedge.net") ||
shExpMatch(host, "*.azureedge.net") ||
shExpMatch(host, "azurefd.net") ||
shExpMatch(host, "*.azurefd.net") ||
shExpMatch(host, "msn.com") ||
shExpMatch(host, "*.msn.com") ||
shExpMatch(host, "msecnd.net") ||
shExpMatch(host, "*.msecnd.net") ||
shExpMatch(host, "msftncsi.com") ||
shExpMatch(host, "*.msftncsi.com") ||
shExpMatch(host, "s-microsoft.com") ||
shExpMatch(host, "*.s-microsoft.com")) {
return proxy;
}
// Steam 全系 + CDN / 社区 / 下载
if (shExpMatch(host, "steampowered.com") ||
shExpMatch(host, "*.steampowered.com") ||
shExpMatch(host, "steamcommunity.com") ||
shExpMatch(host, "*.steamcommunity.com") ||
shExpMatch(host, "steam.com") ||
shExpMatch(host, "*.steam.com") ||
shExpMatch(host, "steamgames.com") ||
shExpMatch(host, "*.steamgames.com") ||
shExpMatch(host, "steamusercontent.com") ||
shExpMatch(host, "*.steamusercontent.com") ||
shExpMatch(host, "steamcontent.com") ||
shExpMatch(host, "*.steamcontent.com") ||
shExpMatch(host, "steamstatic.com") ||
shExpMatch(host, "*.steamstatic.com") ||
shExpMatch(host, "steam-chat.com") ||
shExpMatch(host, "*.steam-chat.com") ||
shExpMatch(host, "steam-api.com") ||
shExpMatch(host, "*.steam-api.com") ||
shExpMatch(host, "steamserver.net") ||
shExpMatch(host, "*.steamserver.net") ||
shExpMatch(host, "steamcdn-a.akamaihd.net") ||
shExpMatch(host, "*.steamcdn-a.akamaihd.net") ||
shExpMatch(host, "steamcommunity-a.akamaihd.net") ||
shExpMatch(host, "*.steamcommunity-a.akamaihd.net") ||
shExpMatch(host, "steamstore-a.akamaihd.net") ||
shExpMatch(host, "*.steamstore-a.akamaihd.net") ||
shExpMatch(host, "steamuserimages-a.akamaihd.net") ||
shExpMatch(host, "*.steamuserimages-a.akamaihd.net") ||
shExpMatch(host, "akamaihd.net") ||
shExpMatch(host, "*.akamaihd.net") ||
shExpMatch(host, "valvesoftware.com") ||
shExpMatch(host, "*.valvesoftware.com")) {
return proxy;
}
// Discord 全系
if (shExpMatch(host, "discord.com") ||
shExpMatch(host, "*.discord.com") ||
shExpMatch(host, "discordapp.com") ||
shExpMatch(host, "*.discordapp.com") ||
shExpMatch(host, "discord.gg") ||
shExpMatch(host, "*.discord.gg") ||
shExpMatch(host, "discord.media") ||
shExpMatch(host, "*.discord.media") ||
shExpMatch(host, "discordapp.net") ||
shExpMatch(host, "*.discordapp.net")) {
return proxy;
}
// Telegram 全系
if (shExpMatch(host, "telegram.org") ||
shExpMatch(host, "*.telegram.org") ||
shExpMatch(host, "t.me") ||
shExpMatch(host, "*.t.me") ||
shExpMatch(host, "telega.one") ||
shExpMatch(host, "*.telega.one") ||
shExpMatch(host, "telegram-cdn.org") ||
shExpMatch(host, "*.telegram-cdn.org")) {
return proxy;
}
// Reddit 全系
if (shExpMatch(host, "reddit.com") ||
shExpMatch(host, "*.reddit.com") ||
shExpMatch(host, "redd.it") ||
shExpMatch(host, "*.redd.it") ||
shExpMatch(host, "redditmedia.com") ||
shExpMatch(host, "*.redditmedia.com") ||
shExpMatch(host, "redditstatic.com") ||
shExpMatch(host, "*.redditstatic.com")) {
return proxy;
}
// LINE 全系
if (shExpMatch(host, "line.me") ||
shExpMatch(host, "*.line.me") ||
shExpMatch(host, "line-apps.com") ||
shExpMatch(host, "*.line-apps.com") ||
shExpMatch(host, "line-scdn.net") ||
shExpMatch(host, "*.line-scdn.net")) {
return proxy;
}
// Spotify 全系
if (shExpMatch(host, "spotify.com") ||
shExpMatch(host, "*.spotify.com") ||
shExpMatch(host, "spotifycdn.com") ||
shExpMatch(host, "*.spotifycdn.com") ||
shExpMatch(host, "scdn.co") ||
shExpMatch(host, "*.scdn.co")) {
return proxy;
}
// Netflix 全系
if (shExpMatch(host, "netflix.com") ||
shExpMatch(host, "*.netflix.com") ||
shExpMatch(host, "nflxvideo.net") ||
shExpMatch(host, "*.nflxvideo.net") ||
shExpMatch(host, "nflximg.net") ||
shExpMatch(host, "*.nflximg.net") ||
shExpMatch(host, "nflxext.com") ||
shExpMatch(host, "*.nflxext.com")) {
return proxy;
}
// Disney+ 全系
if (shExpMatch(host, "disneyplus.com") ||
shExpMatch(host, "*.disneyplus.com") ||
shExpMatch(host, "disney-plus.net") ||
shExpMatch(host, "*.disney-plus.net") ||
shExpMatch(host, "dssott.com") ||
shExpMatch(host, "*.dssott.com")) {
return proxy;
}
// TikTok 全系
if (shExpMatch(host, "tiktok.com") ||
shExpMatch(host, "*.tiktok.com") ||
shExpMatch(host, "tiktokcdn.com") ||
shExpMatch(host, "*.tiktokcdn.com") ||
shExpMatch(host, "tiktokv.com") ||
shExpMatch(host, "*.tiktokv.com")) {
return proxy;
}
// PlayStation 全系
if (shExpMatch(host, "playstation.com") ||
shExpMatch(host, "*.playstation.com") ||
shExpMatch(host, "sonyentertainmentnetwork.com") ||
shExpMatch(host, "*.sonyentertainmentnetwork.com")) {
return proxy;
}
// Nintendo 全系
if (shExpMatch(host, "nintendo.com") ||
shExpMatch(host, "*.nintendo.com") ||
shExpMatch(host, "nintendowifi.net") ||
shExpMatch(host, "*.nintendowifi.net")) {
return proxy;
}
// Epic Games 全系
if (shExpMatch(host, "epicgames.com") ||
shExpMatch(host, "*.epicgames.com") ||
shExpMatch(host, "unrealengine.com") ||
shExpMatch(host, "*.unrealengine.com")) {
return proxy;
}
// DuckDuckGo / Brave Search
if (shExpMatch(host, "duckduckgo.com") ||
shExpMatch(host, "*.duckduckgo.com") ||
shExpMatch(host, "search.brave.com") ||
shExpMatch(host, "*.search.brave.com")) {
return proxy;
}
// Proton 全系
if (shExpMatch(host, "proton.me") ||
shExpMatch(host, "*.proton.me") ||
shExpMatch(host, "protonmail.com") ||
shExpMatch(host, "*.protonmail.com") ||
shExpMatch(host, "protonvpn.com") ||
shExpMatch(host, "*.protonvpn.com")) {
return proxy;
}
// Stack Overflow / Stack Exchange 全系
if (shExpMatch(host, "stackoverflow.com") ||
shExpMatch(host, "*.stackoverflow.com") ||
shExpMatch(host, "stackexchange.com") ||
shExpMatch(host, "*.stackexchange.com") ||
shExpMatch(host, "superuser.com") ||
shExpMatch(host, "*.superuser.com") ||
shExpMatch(host, "serverfault.com") ||
shExpMatch(host, "*.serverfault.com")) {
return proxy;
}
// Quora / Medium
if (shExpMatch(host, "quora.com") ||
shExpMatch(host, "*.quora.com") ||
shExpMatch(host, "medium.com") ||
shExpMatch(host, "*.medium.com")) {
return proxy;
}
// Pixiv 全系
if (shExpMatch(host, "pixiv.net") ||
shExpMatch(host, "*.pixiv.net") ||
shExpMatch(host, "pximg.net") ||
shExpMatch(host, "*.pximg.net")) {
return proxy;
}
// ニコニコ 全系
if (shExpMatch(host, "nicovideo.jp") ||
shExpMatch(host, "*.nicovideo.jp") ||
shExpMatch(host, "nico.ms") ||
shExpMatch(host, "*.nico.ms")) {
return proxy;
}
// EA / Origin / Apex 全系
if (shExpMatch(host, "ea.com") ||
shExpMatch(host, "*.ea.com") ||
shExpMatch(host, "origin.com") ||
shExpMatch(host, "*.origin.com") ||
shExpMatch(host, "eaassets.com") ||
shExpMatch(host, "*.eaassets.com") ||
shExpMatch(host, "apexlegends.com") ||
shExpMatch(host, "*.apexlegends.com")) {
return proxy;
}
// Battle.net / Blizzard 全系
if (shExpMatch(host, "battle.net") ||
shExpMatch(host, "*.battle.net") ||
shExpMatch(host, "blizzard.com") ||
shExpMatch(host, "*.blizzard.com")) {
return proxy;
}
// Ubisoft 全系
if (shExpMatch(host, "ubisoft.com") ||
shExpMatch(host, "*.ubisoft.com") ||
shExpMatch(host, "ubi.com") ||
shExpMatch(host, "*.ubi.com")) {
return proxy;
}
// Riot Games / 英雄联盟 / Valorant 全系
if (shExpMatch(host, "riotgames.com") ||
shExpMatch(host, "*.riotgames.com") ||
shExpMatch(host, "leagueoflegends.com") ||
shExpMatch(host, "*.leagueoflegends.com") ||
shExpMatch(host, "valorant.com") ||
shExpMatch(host, "*.valorant.com")) {
return proxy;
}
// GOG / itch.io 全系
if (shExpMatch(host, "gog.com") ||
shExpMatch(host, "*.gog.com") ||
shExpMatch(host, "itch.io") ||
shExpMatch(host, "*.itch.io")) {
return proxy;
}
// Twitch 全系
if (shExpMatch(host, "twitch.tv") ||
shExpMatch(host, "*.twitch.tv") ||
shExpMatch(host, "ttvnw.net") ||
shExpMatch(host, "*.ttvnw.net") ||
shExpMatch(host, "twitchcdn.net") ||
shExpMatch(host, "*.twitchcdn.net")) {
return proxy;
}
// Crunchyroll 全系
if (shExpMatch(host, "crunchyroll.com") ||
shExpMatch(host, "*.crunchyroll.com")) {
return proxy;
}
// Amazon Prime Video 全系
if (shExpMatch(host, "primevideo.com") ||
shExpMatch(host, "*.primevideo.com")) {
return proxy;
}
// Apple TV+ 全系
if (shExpMatch(host, "tv.apple.com") ||
shExpMatch(host, "tvplus.apple.com")) {
return proxy;
}
// HBO Max / Hulu / Paramount+ 全系
if (shExpMatch(host, "max.com") ||
shExpMatch(host, "*.max.com") ||
shExpMatch(host, "hulu.com") ||
shExpMatch(host, "*.hulu.com") ||
shExpMatch(host, "paramountplus.com") ||
shExpMatch(host, "*.paramountplus.com")) {
return proxy;
}
// Slack 全系
if (shExpMatch(host, "slack.com") ||
shExpMatch(host, "*.slack.com") ||
shExpMatch(host, "slack-edge.com") ||
shExpMatch(host, "*.slack-edge.com") ||
shExpMatch(host, "slack-imgs.com") ||
shExpMatch(host, "*.slack-imgs.com")) {
return proxy;
}
// Zoom 全系
if (shExpMatch(host, "zoom.us") ||
shExpMatch(host, "*.zoom.us") ||
shExpMatch(host, "zoom.com") ||
shExpMatch(host, "*.zoom.com") ||
shExpMatch(host, "zoomgov.com") ||
shExpMatch(host, "*.zoomgov.com")) {
return proxy;
}
// Signal 全系
if (shExpMatch(host, "signal.org") ||
shExpMatch(host, "*.signal.org")) {
return proxy;
}
// Docker 全系
if (shExpMatch(host, "docker.com") ||
shExpMatch(host, "*.docker.com") ||
shExpMatch(host, "docker.io") ||
shExpMatch(host, "*.docker.io")) {
return proxy;
}
// GitLab 全系
if (shExpMatch(host, "gitlab.com") ||
shExpMatch(host, "*.gitlab.com") ||
shExpMatch(host, "gitlab.io") ||
shExpMatch(host, "*.gitlab.io")) {
return proxy;
}
// Vercel 全系
if (shExpMatch(host, "vercel.com") ||
shExpMatch(host, "*.vercel.com") ||
shExpMatch(host, "vercel.app") ||
shExpMatch(host, "*.vercel.app")) {
return proxy;
}
// Netlify 全系
if (shExpMatch(host, "netlify.com") ||
shExpMatch(host, "*.netlify.com") ||
shExpMatch(host, "netlify.app") ||
shExpMatch(host, "*.netlify.app")) {
return proxy;
}
// Perplexity / Poe / Midjourney 全系
if (shExpMatch(host, "perplexity.ai") ||
shExpMatch(host, "*.perplexity.ai") ||
shExpMatch(host, "poe.com") ||
shExpMatch(host, "*.poe.com") ||
shExpMatch(host, "midjourney.com") ||
shExpMatch(host, "*.midjourney.com")) {
return proxy;
}
// Figma / Canva 全系
if (shExpMatch(host, "figma.com") ||
shExpMatch(host, "*.figma.com") ||
shExpMatch(host, "canva.com") ||
shExpMatch(host, "*.canva.com")) {
return proxy;
}
// LinkedIn 全系
if (shExpMatch(host, "linkedin.com") ||
shExpMatch(host, "*.linkedin.com") ||
shExpMatch(host, "licdn.com") ||
shExpMatch(host, "*.licdn.com")) {
return proxy;
}
// Pinterest 全系
if (shExpMatch(host, "pinterest.com") ||
shExpMatch(host, "*.pinterest.com") ||
shExpMatch(host, "pinimg.com") ||
shExpMatch(host, "*.pinimg.com")) {
return proxy;
}
// Tumblr 全系
if (shExpMatch(host, "tumblr.com") ||
shExpMatch(host, "*.tumblr.com")) {
return proxy;
}
// Snapchat 全系
if (shExpMatch(host, "snapchat.com") ||
shExpMatch(host, "*.snapchat.com") ||
shExpMatch(host, "sc-cdn.net") ||
shExpMatch(host, "*.sc-cdn.net")) {
return proxy;
}
// Wikipedia / Wikimedia 全系
if (shExpMatch(host, "wikipedia.org") ||
shExpMatch(host, "*.wikipedia.org") ||
shExpMatch(host, "wikimedia.org") ||
shExpMatch(host, "*.wikimedia.org")) {
return proxy;
}
// PayPal 全系
if (shExpMatch(host, "paypal.com") ||
shExpMatch(host, "*.paypal.com") ||
shExpMatch(host, "paypalobjects.com") ||
shExpMatch(host, "*.paypalobjects.com")) {
return proxy;
}
// Patreon 全系
if (shExpMatch(host, "patreon.com") ||
shExpMatch(host, "*.patreon.com")) {
return proxy;
}
// 通用 CDN
if (shExpMatch(host, "cloudfront.net") ||
shExpMatch(host, "*.cloudfront.net") ||
shExpMatch(host, "fastly.net") ||
shExpMatch(host, "*.fastly.net") ||
shExpMatch(host, "fastlylb.net") ||
shExpMatch(host, "*.fastlylb.net") ||
shExpMatch(host, "jsdelivr.net") ||
shExpMatch(host, "*.jsdelivr.net") ||
shExpMatch(host, "unpkg.com") ||
shExpMatch(host, "*.unpkg.com") ||
shExpMatch(host, "statically.io") ||
shExpMatch(host, "*.statically.io") ||
shExpMatch(host, "cloudinary.com") ||
shExpMatch(host, "*.cloudinary.com") ||
shExpMatch(host, "imgur.com") ||
shExpMatch(host, "*.imgur.com")) {
return proxy;
}
// linux.do
if (shExpMatch(host, "linux.do") ||
shExpMatch(host, "*.linux.do")) {
return proxy;
}
return "DIRECT";
}
-648
View File
@@ -1,648 +0,0 @@
function FindProxyForURL(url, host) {
// RRPP 规则代理列表 — 仅以下域名走加密隧道
var proxy = "PROXY 127.0.0.1:1080";
// Google 全系 + Gemini + CDN
if (shExpMatch(host, "google.com") ||
shExpMatch(host, "*.google.com") ||
shExpMatch(host, "google.com.hk") ||
shExpMatch(host, "*.google.com.hk") ||
shExpMatch(host, "google.co.jp") ||
shExpMatch(host, "*.google.co.jp") ||
shExpMatch(host, "googleapis.com") ||
shExpMatch(host, "*.googleapis.com") ||
shExpMatch(host, "googleusercontent.com") ||
shExpMatch(host, "*.googleusercontent.com") ||
shExpMatch(host, "googletagmanager.com") ||
shExpMatch(host, "*.googletagmanager.com") ||
shExpMatch(host, "google-analytics.com") ||
shExpMatch(host, "*.google-analytics.com") ||
shExpMatch(host, "gstatic.com") ||
shExpMatch(host, "*.gstatic.com") ||
shExpMatch(host, "ggpht.com") ||
shExpMatch(host, "*.ggpht.com") ||
shExpMatch(host, "googlehosted.com") ||
shExpMatch(host, "*.googlehosted.com") ||
shExpMatch(host, "1e100.net") ||
shExpMatch(host, "*.1e100.net") ||
shExpMatch(host, "youtube.com") ||
shExpMatch(host, "*.youtube.com") ||
shExpMatch(host, "ytimg.com") ||
shExpMatch(host, "*.ytimg.com") ||
shExpMatch(host, "googlevideo.com") ||
shExpMatch(host, "*.googlevideo.com") ||
shExpMatch(host, "gemini.google.com") ||
shExpMatch(host, "*.gemini.google.com") ||
shExpMatch(host, "generativeai.googleapis.com") ||
shExpMatch(host, "*.generativeai.googleapis.com") ||
shExpMatch(host, "aistudio.google.com") ||
shExpMatch(host, "*.aistudio.google.com") ||
shExpMatch(host, "deepmind.google.com") ||
shExpMatch(host, "*.deepmind.google.com") ||
shExpMatch(host, "deepmind.com") ||
shExpMatch(host, "*.deepmind.com")) {
return proxy;
}
// OpenAI / ChatGPT 全系 + CDN
if (shExpMatch(host, "openai.com") ||
shExpMatch(host, "*.openai.com") ||
shExpMatch(host, "chatgpt.com") ||
shExpMatch(host, "*.chatgpt.com") ||
shExpMatch(host, "oaistatic.com") ||
shExpMatch(host, "*.oaistatic.com") ||
shExpMatch(host, "oaiusercontent.com") ||
shExpMatch(host, "*.oaiusercontent.com") ||
shExpMatch(host, "openaiusercontent.com") ||
shExpMatch(host, "*.openaiusercontent.com")) {
return proxy;
}
// Grok / xAI 全系 + CDN
if (shExpMatch(host, "grok.com") ||
shExpMatch(host, "*.grok.com") ||
shExpMatch(host, "x.com") ||
shExpMatch(host, "*.x.com") ||
shExpMatch(host, "twitter.com") ||
shExpMatch(host, "*.twitter.com") ||
shExpMatch(host, "twimg.com") ||
shExpMatch(host, "*.twimg.com") ||
shExpMatch(host, "x.ai") ||
shExpMatch(host, "*.x.ai") ||
shExpMatch(host, "xai.com") ||
shExpMatch(host, "*.xai.com")) {
return proxy;
}
// Meta 全系 + CDN
if (shExpMatch(host, "meta.com") ||
shExpMatch(host, "*.meta.com") ||
shExpMatch(host, "meta.ai") ||
shExpMatch(host, "*.meta.ai") ||
shExpMatch(host, "facebook.com") ||
shExpMatch(host, "*.facebook.com") ||
shExpMatch(host, "fb.com") ||
shExpMatch(host, "*.fb.com") ||
shExpMatch(host, "fbcdn.net") ||
shExpMatch(host, "*.fbcdn.net") ||
shExpMatch(host, "fbsbx.com") ||
shExpMatch(host, "*.fbsbx.com") ||
shExpMatch(host, "instagram.com") ||
shExpMatch(host, "*.instagram.com") ||
shExpMatch(host, "cdninstagram.com") ||
shExpMatch(host, "*.cdninstagram.com") ||
shExpMatch(host, "threads.net") ||
shExpMatch(host, "*.threads.net") ||
shExpMatch(host, "whatsapp.com") ||
shExpMatch(host, "*.whatsapp.com") ||
shExpMatch(host, "whatsapp.net") ||
shExpMatch(host, "*.whatsapp.net") ||
shExpMatch(host, "messenger.com") ||
shExpMatch(host, "*.messenger.com")) {
return proxy;
}
// Claude / Anthropic 全系 + CDN
if (shExpMatch(host, "anthropic.com") ||
shExpMatch(host, "*.anthropic.com") ||
shExpMatch(host, "claude.ai") ||
shExpMatch(host, "*.claude.ai") ||
shExpMatch(host, "claudeusercontent.com") ||
shExpMatch(host, "*.claudeusercontent.com")) {
return proxy;
}
// Dify 全系
if (shExpMatch(host, "dify.ai") ||
shExpMatch(host, "*.dify.ai") ||
shExpMatch(host, "udify.app") ||
shExpMatch(host, "*.udify.app")) {
return proxy;
}
// Cloudflare 全系
if (shExpMatch(host, "cloudflare.com") ||
shExpMatch(host, "*.cloudflare.com") ||
shExpMatch(host, "cloudflare.net") ||
shExpMatch(host, "*.cloudflare.net") ||
shExpMatch(host, "cloudflareinsights.com") ||
shExpMatch(host, "*.cloudflareinsights.com") ||
shExpMatch(host, "cloudflarestatus.com") ||
shExpMatch(host, "*.cloudflarestatus.com") ||
shExpMatch(host, "cloudflarecdn.com") ||
shExpMatch(host, "*.cloudflarecdn.com") ||
shExpMatch(host, "cf-cdn.com") ||
shExpMatch(host, "*.cf-cdn.com") ||
shExpMatch(host, "workers.dev") ||
shExpMatch(host, "*.workers.dev") ||
shExpMatch(host, "pages.dev") ||
shExpMatch(host, "*.pages.dev")) {
return proxy;
}
// GitHub 全系
if (shExpMatch(host, "github.com") ||
shExpMatch(host, "*.github.com") ||
shExpMatch(host, "githubusercontent.com") ||
shExpMatch(host, "*.githubusercontent.com") ||
shExpMatch(host, "githubassets.com") ||
shExpMatch(host, "*.githubassets.com") ||
shExpMatch(host, "github.io") ||
shExpMatch(host, "*.github.io") ||
shExpMatch(host, "githubcopilot.com") ||
shExpMatch(host, "*.githubcopilot.com") ||
shExpMatch(host, "ghcr.io") ||
shExpMatch(host, "*.ghcr.io")) {
return proxy;
}
// Hugging Face 全系 + CDN
if (shExpMatch(host, "huggingface.co") ||
shExpMatch(host, "*.huggingface.co") ||
shExpMatch(host, "hf.co") ||
shExpMatch(host, "*.hf.co") ||
shExpMatch(host, "huggingfacecdn.com") ||
shExpMatch(host, "*.huggingfacecdn.com")) {
return proxy;
}
// Microsoft Copilot 全系 + Bing + Azure CDN
if (shExpMatch(host, "microsoft.com") ||
shExpMatch(host, "*.microsoft.com") ||
shExpMatch(host, "bing.com") ||
shExpMatch(host, "*.bing.com") ||
shExpMatch(host, "bingapis.com") ||
shExpMatch(host, "*.bingapis.com") ||
shExpMatch(host, "bing.net") ||
shExpMatch(host, "*.bing.net") ||
shExpMatch(host, "live.com") ||
shExpMatch(host, "*.live.com") ||
shExpMatch(host, "microsoftonline.com") ||
shExpMatch(host, "*.microsoftonline.com") ||
shExpMatch(host, "microsoft365.com") ||
shExpMatch(host, "*.microsoft365.com") ||
shExpMatch(host, "office.com") ||
shExpMatch(host, "*.office.com") ||
shExpMatch(host, "office.net") ||
shExpMatch(host, "*.office.net") ||
shExpMatch(host, "azure.com") ||
shExpMatch(host, "*.azure.com") ||
shExpMatch(host, "azureedge.net") ||
shExpMatch(host, "*.azureedge.net") ||
shExpMatch(host, "azurefd.net") ||
shExpMatch(host, "*.azurefd.net") ||
shExpMatch(host, "msn.com") ||
shExpMatch(host, "*.msn.com") ||
shExpMatch(host, "msecnd.net") ||
shExpMatch(host, "*.msecnd.net") ||
shExpMatch(host, "msftncsi.com") ||
shExpMatch(host, "*.msftncsi.com") ||
shExpMatch(host, "s-microsoft.com") ||
shExpMatch(host, "*.s-microsoft.com")) {
return proxy;
}
// Steam 全系 + CDN / 社区 / 下载
if (shExpMatch(host, "steampowered.com") ||
shExpMatch(host, "*.steampowered.com") ||
shExpMatch(host, "steamcommunity.com") ||
shExpMatch(host, "*.steamcommunity.com") ||
shExpMatch(host, "steam.com") ||
shExpMatch(host, "*.steam.com") ||
shExpMatch(host, "steamgames.com") ||
shExpMatch(host, "*.steamgames.com") ||
shExpMatch(host, "steamusercontent.com") ||
shExpMatch(host, "*.steamusercontent.com") ||
shExpMatch(host, "steamcontent.com") ||
shExpMatch(host, "*.steamcontent.com") ||
shExpMatch(host, "steamstatic.com") ||
shExpMatch(host, "*.steamstatic.com") ||
shExpMatch(host, "steam-chat.com") ||
shExpMatch(host, "*.steam-chat.com") ||
shExpMatch(host, "steam-api.com") ||
shExpMatch(host, "*.steam-api.com") ||
shExpMatch(host, "steamserver.net") ||
shExpMatch(host, "*.steamserver.net") ||
shExpMatch(host, "steamcdn-a.akamaihd.net") ||
shExpMatch(host, "*.steamcdn-a.akamaihd.net") ||
shExpMatch(host, "steamcommunity-a.akamaihd.net") ||
shExpMatch(host, "*.steamcommunity-a.akamaihd.net") ||
shExpMatch(host, "steamstore-a.akamaihd.net") ||
shExpMatch(host, "*.steamstore-a.akamaihd.net") ||
shExpMatch(host, "steamuserimages-a.akamaihd.net") ||
shExpMatch(host, "*.steamuserimages-a.akamaihd.net") ||
shExpMatch(host, "akamaihd.net") ||
shExpMatch(host, "*.akamaihd.net") ||
shExpMatch(host, "valvesoftware.com") ||
shExpMatch(host, "*.valvesoftware.com")) {
return proxy;
}
// Discord 全系
if (shExpMatch(host, "discord.com") ||
shExpMatch(host, "*.discord.com") ||
shExpMatch(host, "discordapp.com") ||
shExpMatch(host, "*.discordapp.com") ||
shExpMatch(host, "discord.gg") ||
shExpMatch(host, "*.discord.gg") ||
shExpMatch(host, "discord.media") ||
shExpMatch(host, "*.discord.media") ||
shExpMatch(host, "discordapp.net") ||
shExpMatch(host, "*.discordapp.net")) {
return proxy;
}
// Telegram 全系
if (shExpMatch(host, "telegram.org") ||
shExpMatch(host, "*.telegram.org") ||
shExpMatch(host, "t.me") ||
shExpMatch(host, "*.t.me") ||
shExpMatch(host, "telega.one") ||
shExpMatch(host, "*.telega.one") ||
shExpMatch(host, "telegram-cdn.org") ||
shExpMatch(host, "*.telegram-cdn.org")) {
return proxy;
}
// Reddit 全系
if (shExpMatch(host, "reddit.com") ||
shExpMatch(host, "*.reddit.com") ||
shExpMatch(host, "redd.it") ||
shExpMatch(host, "*.redd.it") ||
shExpMatch(host, "redditmedia.com") ||
shExpMatch(host, "*.redditmedia.com") ||
shExpMatch(host, "redditstatic.com") ||
shExpMatch(host, "*.redditstatic.com")) {
return proxy;
}
// LINE 全系
if (shExpMatch(host, "line.me") ||
shExpMatch(host, "*.line.me") ||
shExpMatch(host, "line-apps.com") ||
shExpMatch(host, "*.line-apps.com") ||
shExpMatch(host, "line-scdn.net") ||
shExpMatch(host, "*.line-scdn.net")) {
return proxy;
}
// Spotify 全系
if (shExpMatch(host, "spotify.com") ||
shExpMatch(host, "*.spotify.com") ||
shExpMatch(host, "spotifycdn.com") ||
shExpMatch(host, "*.spotifycdn.com") ||
shExpMatch(host, "scdn.co") ||
shExpMatch(host, "*.scdn.co")) {
return proxy;
}
// Netflix 全系
if (shExpMatch(host, "netflix.com") ||
shExpMatch(host, "*.netflix.com") ||
shExpMatch(host, "nflxvideo.net") ||
shExpMatch(host, "*.nflxvideo.net") ||
shExpMatch(host, "nflximg.net") ||
shExpMatch(host, "*.nflximg.net") ||
shExpMatch(host, "nflxext.com") ||
shExpMatch(host, "*.nflxext.com")) {
return proxy;
}
// Disney+ 全系
if (shExpMatch(host, "disneyplus.com") ||
shExpMatch(host, "*.disneyplus.com") ||
shExpMatch(host, "disney-plus.net") ||
shExpMatch(host, "*.disney-plus.net") ||
shExpMatch(host, "dssott.com") ||
shExpMatch(host, "*.dssott.com")) {
return proxy;
}
// TikTok 全系
if (shExpMatch(host, "tiktok.com") ||
shExpMatch(host, "*.tiktok.com") ||
shExpMatch(host, "tiktokcdn.com") ||
shExpMatch(host, "*.tiktokcdn.com") ||
shExpMatch(host, "tiktokv.com") ||
shExpMatch(host, "*.tiktokv.com")) {
return proxy;
}
// PlayStation 全系
if (shExpMatch(host, "playstation.com") ||
shExpMatch(host, "*.playstation.com") ||
shExpMatch(host, "sonyentertainmentnetwork.com") ||
shExpMatch(host, "*.sonyentertainmentnetwork.com")) {
return proxy;
}
// Nintendo 全系
if (shExpMatch(host, "nintendo.com") ||
shExpMatch(host, "*.nintendo.com") ||
shExpMatch(host, "nintendowifi.net") ||
shExpMatch(host, "*.nintendowifi.net")) {
return proxy;
}
// Epic Games 全系
if (shExpMatch(host, "epicgames.com") ||
shExpMatch(host, "*.epicgames.com") ||
shExpMatch(host, "unrealengine.com") ||
shExpMatch(host, "*.unrealengine.com")) {
return proxy;
}
// DuckDuckGo / Brave Search
if (shExpMatch(host, "duckduckgo.com") ||
shExpMatch(host, "*.duckduckgo.com") ||
shExpMatch(host, "search.brave.com") ||
shExpMatch(host, "*.search.brave.com")) {
return proxy;
}
// Proton 全系
if (shExpMatch(host, "proton.me") ||
shExpMatch(host, "*.proton.me") ||
shExpMatch(host, "protonmail.com") ||
shExpMatch(host, "*.protonmail.com") ||
shExpMatch(host, "protonvpn.com") ||
shExpMatch(host, "*.protonvpn.com")) {
return proxy;
}
// Stack Overflow / Stack Exchange 全系
if (shExpMatch(host, "stackoverflow.com") ||
shExpMatch(host, "*.stackoverflow.com") ||
shExpMatch(host, "stackexchange.com") ||
shExpMatch(host, "*.stackexchange.com") ||
shExpMatch(host, "superuser.com") ||
shExpMatch(host, "*.superuser.com") ||
shExpMatch(host, "serverfault.com") ||
shExpMatch(host, "*.serverfault.com")) {
return proxy;
}
// Quora / Medium
if (shExpMatch(host, "quora.com") ||
shExpMatch(host, "*.quora.com") ||
shExpMatch(host, "medium.com") ||
shExpMatch(host, "*.medium.com")) {
return proxy;
}
// Pixiv 全系
if (shExpMatch(host, "pixiv.net") ||
shExpMatch(host, "*.pixiv.net") ||
shExpMatch(host, "pximg.net") ||
shExpMatch(host, "*.pximg.net")) {
return proxy;
}
// ニコニコ 全系
if (shExpMatch(host, "nicovideo.jp") ||
shExpMatch(host, "*.nicovideo.jp") ||
shExpMatch(host, "nico.ms") ||
shExpMatch(host, "*.nico.ms")) {
return proxy;
}
// EA / Origin / Apex 全系
if (shExpMatch(host, "ea.com") ||
shExpMatch(host, "*.ea.com") ||
shExpMatch(host, "origin.com") ||
shExpMatch(host, "*.origin.com") ||
shExpMatch(host, "eaassets.com") ||
shExpMatch(host, "*.eaassets.com") ||
shExpMatch(host, "apexlegends.com") ||
shExpMatch(host, "*.apexlegends.com")) {
return proxy;
}
// Battle.net / Blizzard 全系
if (shExpMatch(host, "battle.net") ||
shExpMatch(host, "*.battle.net") ||
shExpMatch(host, "blizzard.com") ||
shExpMatch(host, "*.blizzard.com")) {
return proxy;
}
// Ubisoft 全系
if (shExpMatch(host, "ubisoft.com") ||
shExpMatch(host, "*.ubisoft.com") ||
shExpMatch(host, "ubi.com") ||
shExpMatch(host, "*.ubi.com")) {
return proxy;
}
// Riot Games / 英雄联盟 / Valorant 全系
if (shExpMatch(host, "riotgames.com") ||
shExpMatch(host, "*.riotgames.com") ||
shExpMatch(host, "leagueoflegends.com") ||
shExpMatch(host, "*.leagueoflegends.com") ||
shExpMatch(host, "valorant.com") ||
shExpMatch(host, "*.valorant.com")) {
return proxy;
}
// GOG / itch.io 全系
if (shExpMatch(host, "gog.com") ||
shExpMatch(host, "*.gog.com") ||
shExpMatch(host, "itch.io") ||
shExpMatch(host, "*.itch.io")) {
return proxy;
}
// Twitch 全系
if (shExpMatch(host, "twitch.tv") ||
shExpMatch(host, "*.twitch.tv") ||
shExpMatch(host, "ttvnw.net") ||
shExpMatch(host, "*.ttvnw.net") ||
shExpMatch(host, "twitchcdn.net") ||
shExpMatch(host, "*.twitchcdn.net")) {
return proxy;
}
// Crunchyroll 全系
if (shExpMatch(host, "crunchyroll.com") ||
shExpMatch(host, "*.crunchyroll.com")) {
return proxy;
}
// Amazon Prime Video 全系
if (shExpMatch(host, "primevideo.com") ||
shExpMatch(host, "*.primevideo.com")) {
return proxy;
}
// Apple TV+ 全系
if (shExpMatch(host, "tv.apple.com") ||
shExpMatch(host, "tvplus.apple.com")) {
return proxy;
}
// HBO Max / Hulu / Paramount+ 全系
if (shExpMatch(host, "max.com") ||
shExpMatch(host, "*.max.com") ||
shExpMatch(host, "hulu.com") ||
shExpMatch(host, "*.hulu.com") ||
shExpMatch(host, "paramountplus.com") ||
shExpMatch(host, "*.paramountplus.com")) {
return proxy;
}
// Slack 全系
if (shExpMatch(host, "slack.com") ||
shExpMatch(host, "*.slack.com") ||
shExpMatch(host, "slack-edge.com") ||
shExpMatch(host, "*.slack-edge.com") ||
shExpMatch(host, "slack-imgs.com") ||
shExpMatch(host, "*.slack-imgs.com")) {
return proxy;
}
// Zoom 全系
if (shExpMatch(host, "zoom.us") ||
shExpMatch(host, "*.zoom.us") ||
shExpMatch(host, "zoom.com") ||
shExpMatch(host, "*.zoom.com") ||
shExpMatch(host, "zoomgov.com") ||
shExpMatch(host, "*.zoomgov.com")) {
return proxy;
}
// Signal 全系
if (shExpMatch(host, "signal.org") ||
shExpMatch(host, "*.signal.org")) {
return proxy;
}
// Docker 全系
if (shExpMatch(host, "docker.com") ||
shExpMatch(host, "*.docker.com") ||
shExpMatch(host, "docker.io") ||
shExpMatch(host, "*.docker.io")) {
return proxy;
}
// GitLab 全系
if (shExpMatch(host, "gitlab.com") ||
shExpMatch(host, "*.gitlab.com") ||
shExpMatch(host, "gitlab.io") ||
shExpMatch(host, "*.gitlab.io")) {
return proxy;
}
// Vercel 全系
if (shExpMatch(host, "vercel.com") ||
shExpMatch(host, "*.vercel.com") ||
shExpMatch(host, "vercel.app") ||
shExpMatch(host, "*.vercel.app")) {
return proxy;
}
// Netlify 全系
if (shExpMatch(host, "netlify.com") ||
shExpMatch(host, "*.netlify.com") ||
shExpMatch(host, "netlify.app") ||
shExpMatch(host, "*.netlify.app")) {
return proxy;
}
// Perplexity / Poe / Midjourney 全系
if (shExpMatch(host, "perplexity.ai") ||
shExpMatch(host, "*.perplexity.ai") ||
shExpMatch(host, "poe.com") ||
shExpMatch(host, "*.poe.com") ||
shExpMatch(host, "midjourney.com") ||
shExpMatch(host, "*.midjourney.com")) {
return proxy;
}
// Figma / Canva 全系
if (shExpMatch(host, "figma.com") ||
shExpMatch(host, "*.figma.com") ||
shExpMatch(host, "canva.com") ||
shExpMatch(host, "*.canva.com")) {
return proxy;
}
// LinkedIn 全系
if (shExpMatch(host, "linkedin.com") ||
shExpMatch(host, "*.linkedin.com") ||
shExpMatch(host, "licdn.com") ||
shExpMatch(host, "*.licdn.com")) {
return proxy;
}
// Pinterest 全系
if (shExpMatch(host, "pinterest.com") ||
shExpMatch(host, "*.pinterest.com") ||
shExpMatch(host, "pinimg.com") ||
shExpMatch(host, "*.pinimg.com")) {
return proxy;
}
// Tumblr 全系
if (shExpMatch(host, "tumblr.com") ||
shExpMatch(host, "*.tumblr.com")) {
return proxy;
}
// Snapchat 全系
if (shExpMatch(host, "snapchat.com") ||
shExpMatch(host, "*.snapchat.com") ||
shExpMatch(host, "sc-cdn.net") ||
shExpMatch(host, "*.sc-cdn.net")) {
return proxy;
}
// Wikipedia / Wikimedia 全系
if (shExpMatch(host, "wikipedia.org") ||
shExpMatch(host, "*.wikipedia.org") ||
shExpMatch(host, "wikimedia.org") ||
shExpMatch(host, "*.wikimedia.org")) {
return proxy;
}
// PayPal 全系
if (shExpMatch(host, "paypal.com") ||
shExpMatch(host, "*.paypal.com") ||
shExpMatch(host, "paypalobjects.com") ||
shExpMatch(host, "*.paypalobjects.com")) {
return proxy;
}
// Patreon 全系
if (shExpMatch(host, "patreon.com") ||
shExpMatch(host, "*.patreon.com")) {
return proxy;
}
// 通用 CDN
if (shExpMatch(host, "cloudfront.net") ||
shExpMatch(host, "*.cloudfront.net") ||
shExpMatch(host, "fastly.net") ||
shExpMatch(host, "*.fastly.net") ||
shExpMatch(host, "fastlylb.net") ||
shExpMatch(host, "*.fastlylb.net") ||
shExpMatch(host, "jsdelivr.net") ||
shExpMatch(host, "*.jsdelivr.net") ||
shExpMatch(host, "unpkg.com") ||
shExpMatch(host, "*.unpkg.com") ||
shExpMatch(host, "statically.io") ||
shExpMatch(host, "*.statically.io") ||
shExpMatch(host, "cloudinary.com") ||
shExpMatch(host, "*.cloudinary.com") ||
shExpMatch(host, "imgur.com") ||
shExpMatch(host, "*.imgur.com")) {
return proxy;
}
// linux.do
if (shExpMatch(host, "linux.do") ||
shExpMatch(host, "*.linux.do")) {
return proxy;
}
return "DIRECT";
}
-4
View File
@@ -1,4 +0,0 @@
#!/bin/bash
# /usr/local/bin/rrpp-stats — returns traffic stats as JSON
vnstat --json 2>/dev/null || echo '{"error":"vnstat not available"}'
@@ -1,13 +1,15 @@
#!/bin/bash
# sync-nginx-local-ips.sh
# 自动维护 nginx 本机公网 IP 直连规则(/etc/nginx/local_ip_rules.conf):
# 出站公网 IP(ipify)+ iam.aeolianchi.me 的 A 记录 = 本机当前公网 IP 集合。
# 出站公网 IP(ipify)+ <节点域名> 的 A 记录 = 本机当前公网 IP 集合。
# 公网 IP 变更时重写规则文件并 reload nginx(仅变更时 reload)。
# 由 /etc/cron.d/sync-nginx-local-ips 每 5 分钟调用一次。
#
# 部署时把 DOMAIN 改为本节点域名。
set -u
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RULES=/etc/nginx/local_ip_rules.conf
DOMAIN=iam.aeolianchi.me
DOMAIN=<节点域名>
ips=""
# 出站公网 IP
+12 -4
View File
@@ -1,6 +1,6 @@
{
"log": {
"level": "warn",
"level": "info",
"timestamp": true
},
"inbounds": [
@@ -8,7 +8,7 @@
"type": "vless",
"tag": "vless-reality-in",
"listen": "0.0.0.0",
"listen_port": 8443,
"listen_port": "<vless端口>",
"users": [
{
"uuid": "<VLESS_UUID>",
@@ -17,11 +17,11 @@
],
"tls": {
"enabled": true,
"server_name": "www.apple.com",
"server_name": "<伪装SNI域名>",
"reality": {
"enabled": true,
"handshake": {
"server": "www.apple.com",
"server": "<伪装SNI域名>",
"server_port": 443
},
"private_key": "<REALITY_PRIVATE_KEY>",
@@ -30,6 +30,14 @@
]
}
}
},
{
"type": "shadowsocks",
"tag": "ss-in",
"listen": "0.0.0.0",
"listen_port": "<ss端口>",
"method": "2022-blake3-aes-256-gcm",
"password": "<SS_PSK_BASE64_32字节>"
}
],
"outbounds": [
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=MTProto proxy (mtg) for Telegram
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <MTPROTO_SECRET>
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=nginx (RRPP proxy, compiled with ngx_http_proxy_connect_module)
After=network.target
[Service]
Type=forking
PIDFile=/var/run/nginx.pid
ExecStartPre=/usr/local/nginx/sbin/nginx -t
ExecStart=/usr/local/nginx/sbin/nginx
ExecReload=/usr/local/nginx/sbin/nginx -s reload
ExecStop=/usr/local/nginx/sbin/nginx -s quit
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=sing-box (VLESS+REALITY / Shadowsocks)
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/sing-box -D /var/lib/sing-box -C /etc/sing-box run
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
-41
View File
@@ -1,41 +0,0 @@
{
"vnstat_bin": "vnstat",
"interface": "eth0",
"data_dir": "/home/agent/data",
"alert_state_path": "/home/agent/alert_state.json",
"poll_interval_sec": 60,
"schedule_interval_sec": 30,
"http_host": "127.0.0.1",
"http_port": 18080,
"quota_bytes": 15000000000,
"alert_thresholds_pct": [
50,
80,
90,
95,
99,
100
],
"effective_domain": "iam.aeolianchi.me",
"log_level": "INFO",
"smtp": {
"host": "mail.rrrosmontis.icu",
"port": 465,
"user": "<SMTP_USER>",
"password": "<SMTP_PASSWORD>",
"from_name": "Royirin Rosmontis Private Proxy JP 流量监控",
"from_addr": "<SMTP_FROM>",
"to_addr": "<SMTP_TO>",
"starttls": false,
"skip_cert_verify": false,
"timeout_sec": 30,
"ssl": true
},
"current_ip": "<PUBLIC_IP>",
"billing_cycle_start_day": 1,
"azure_usage_enabled": true,
"azure_usage_fetch_interval_sec": 21600,
"azure_usage_cache_path": "/home/agent/data/azure_usage.json",
"azure_meter_name_match": "Data Transfer Out",
"azure_free_limit_gb": 15
}
@@ -1,15 +0,0 @@
[Unit]
Description=Royirin Rosmontis Private Proxy JP Traffic Monitor
After=network-online.target nginx.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/agent
ExecStart=/usr/bin/python3 /home/agent/traffic_monitor.py --config /home/agent/config.json
Restart=always
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
@@ -1,666 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Royirin Rosmontis Private Proxy JP 流量监控(Azure 门户口径版)
- 主计量口径:Azure 门户免费服务页 "Data Transfer Out (GB)"(Standard Data Transfer Out - Free,MGN 路由)
—— 通过 VM 托管身份调用 Consumption Usage Details API 拉取,按自然月聚合,缓存至 data/azure_usage.json
- 参考口径:vnstat -i eth0(实时原始出站,仅作参考展示)
- 数据源(参考):vnstat -i <interface> --json m/d(eth0 为 bond 主接口,覆盖 nginx 443 与 sing-box 8443 全部流量)
- 按 年/月 聚合成 JSON 存入 data/YYYY/YYYY-MM.json(弃用 SQLite)
- HTTP 服务: GET /vpn/status/traffic 返回当月用量 JSON(Azure 口径为主)
- 阈值告警: 达到配额 50/80/90/95/99/100% 时发邮件(每档每月只发一次,跳档补发;基于 Azure 口径)
- 月报: 每月 1 日 00:05 起发送上个月报告(失败重试;基于 Azure 口径)
"""
import argparse
import json
import logging
import os
import socket
import smtplib
import ssl
import subprocess
import sys
import threading
import time
import urllib.parse
import urllib.request
from datetime import date, datetime, timedelta, timezone
from email.message import EmailMessage
from email.utils import formataddr
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
log = logging.getLogger('traffic_monitor')
GB = 1_000_000_000 # Azure 数据转移按十进制 GB 计费
def utcnow():
return datetime.now(timezone.utc)
def load_json(path, default):
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
return default
except (json.JSONDecodeError, OSError) as e:
log.warning('read %s failed: %s; using default', path, e)
return default
def save_json(path, obj):
tmp = path + '.tmp'
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(obj, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def gb_str(n_bytes):
return f'{n_bytes / GB:.2f} GB'
def month_struct(year, month):
return {
'year': int(year),
'month': int(month),
'rx_bytes': 0,
'tx_bytes': 0,
'days': [],
'updated_at': utcnow().isoformat(),
}
class Monitor:
def __init__(self, config):
self.config = config
self.lock = threading.RLock()
self.months = {}
self.alert_state = None
os.makedirs(self.config['data_dir'], exist_ok=True)
self._load_all_months()
self._load_alert_state()
# Azure 计费用量(Usage Details API,VM 托管身份鉴权)
self.azure_usage = None
self._azure_last_fetch = 0.0
cache_path = self.config.get('azure_usage_cache_path')
if cache_path and os.path.exists(cache_path):
self.azure_usage = load_json(cache_path, None)
log.info('azure usage cache loaded: %s', self.azure_usage)
# ---------- 基础 ----------
@property
def _config_path(self):
return os.environ.get('TRAFFIC_CONFIG', '/home/agent/config.json')
def _load_all_months(self):
root = self.config['data_dir']
if not os.path.isdir(root):
return
for year_dir in os.listdir(root):
ypath = os.path.join(root, year_dir)
if not os.path.isdir(ypath):
continue
for fn in os.listdir(ypath):
if not fn.endswith('.json'):
continue
try:
with open(os.path.join(ypath, fn), 'r', encoding='utf-8') as f:
m = json.load(f)
mk = f"{m['year']:04d}-{m['month']:02d}"
self.months[mk] = m
except Exception as e:
log.warning('load %s/%s failed: %s', ypath, fn, e)
def _load_alert_state(self):
self.alert_state = load_json(
self.config['alert_state_path'],
{'alert_thresholds_sent': {}, 'monthly_reports_sent': []})
self.alert_state.setdefault('alert_thresholds_sent', {})
self.alert_state.setdefault('monthly_reports_sent', [])
def _save_alert_state(self):
save_json(self.config['alert_state_path'], self.alert_state)
def _month_path(self, year, month):
return os.path.join(self.config['data_dir'], str(year), f'{year:04d}-{month:02d}.json')
def _persist_month(self, mk):
m = self.months.get(mk)
if m is None:
return
path = self._month_path(m['year'], m['month'])
os.makedirs(os.path.dirname(path), exist_ok=True)
save_json(path, m)
# ---------- vnstat 读取 ----------
def _vnstat(self, what):
bin_path = self.config.get('vnstat_bin', 'vnstat')
iface = self.config.get('interface', 'eth0')
cmd = [bin_path, '-i', iface, '--json', what]
try:
out = subprocess.run(cmd, capture_output=True, timeout=20, check=True)
return json.loads(out.stdout.decode('utf-8'))
except FileNotFoundError:
log.error('vnstat binary not found: %s', bin_path)
return None
except subprocess.TimeoutExpired:
log.error('vnstat --json %s timed out', what)
return None
except (subprocess.CalledProcessError, json.JSONDecodeError, OSError) as e:
log.error('vnstat --json %s failed: %s', what, e)
return None
def poll_vnstat(self):
"""拉取 vnstat 月/日数据,更新并落盘。返回更新的月数。"""
monthly = self._vnstat('m')
daily = self._vnstat('d')
touched = set()
with self.lock:
if monthly:
try:
for itf in monthly.get('interfaces', []):
for item in itf.get('traffic', {}).get('month', []):
d = item.get('date', {})
mk = f"{int(d['year']):04d}-{int(d['month']):02d}"
m = self.months.get(mk)
if m is None:
m = month_struct(d['year'], d['month'])
self.months[mk] = m
m['rx_bytes'] = int(item.get('rx', 0))
m['tx_bytes'] = int(item.get('tx', 0))
m['updated_at'] = utcnow().isoformat()
touched.add(mk)
except Exception as e:
log.exception('parse vnstat monthly failed: %s', e)
if daily:
try:
by_month = {}
for itf in daily.get('interfaces', []):
for item in itf.get('traffic', {}).get('day', []):
d = item.get('date', {})
mk = f"{int(d['year']):04d}-{int(d['month']):02d}"
by_month.setdefault(mk, []).append({
'day': int(d['day']),
'rx_bytes': int(item.get('rx', 0)),
'tx_bytes': int(item.get('tx', 0)),
})
for mk, days in by_month.items():
m = self.months.get(mk)
if m is None:
y, mon = mk.split('-')
m = month_struct(int(y), int(mon))
self.months[mk] = m
days.sort(key=lambda x: x['day'])
m['days'] = days
m['updated_at'] = utcnow().isoformat()
touched.add(mk)
except Exception as e:
log.exception('parse vnstat daily failed: %s', e)
for mk in touched:
self._persist_month(mk)
return len(touched)
# ---------- 用量(按 Azure 计费周期) ----------
def current_month_key(self):
return utcnow().strftime('%Y-%m')
def cycle_start(self, now):
"""返回当前计费周期的起始 datetime(每月 billing_cycle_start_day 00:00 UTC)。"""
day = int(self.config.get('billing_cycle_start_day', 1))
day = max(1, min(28, day))
if now.day >= day:
return now.replace(day=day, hour=0, minute=0, second=0, microsecond=0)
prev = (now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
- timedelta(days=1))
return prev.replace(day=day, hour=0, minute=0, second=0, microsecond=0)
def cycle_end(self, start):
"""周期结束 = 下个月同一天的 00:00(不含)。"""
nxt = (start.replace(day=1) + timedelta(days=32)).replace(day=1)
return nxt.replace(day=start.day, hour=0, minute=0, second=0, microsecond=0)
def _day_totals(self):
"""返回 [{date, rx, tx}],date 为 UTC date。"""
out = []
for m in self.months.values():
for d in m.get('days', []):
out.append((date(m['year'], m['month'], d['day']),
int(d.get('rx_bytes', 0)), int(d.get('tx_bytes', 0))))
return out
def cycle_usage(self):
"""返回当前计费周期 {rx_bytes, tx_bytes, start, end}。"""
now = utcnow()
start = self.cycle_start(now)
end = self.cycle_end(start)
rx = tx = 0
with self.lock:
for dt, r, t in self._day_totals():
if start.date() <= dt <= now.date():
rx += r
tx += t
return {'rx_bytes': rx, 'tx_bytes': tx, 'start': start, 'end': end}
def tx_bytes_for(self, mk):
with self.lock:
m = self.months.get(mk, {})
return int(m.get('tx_bytes', 0))
def current_status(self):
"""当前状态:以 Azure 门户口径(Standard Data Transfer Out - Free)为主计量。"""
now = utcnow()
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
cu = self.cycle_usage()
az = self.azure_usage or {}
used_gb = az.get('portal_data_transfer_out_gb')
used_pct = round(used_gb / quota_gb * 100, 4) if (used_gb is not None and quota_gb) else None
with self.lock:
cal_mk = now.strftime('%Y-%m')
m = self.months.get(cal_mk)
cal_tx = int(m.get('tx_bytes', 0)) if m else 0
cal_rx = int(m.get('rx_bytes', 0)) if m else 0
vnstat_updated = (m or {}).get('updated_at')
return {
'year': now.year,
'month': now.month,
'meter': 'azure_portal',
'used_gb': used_gb,
'quota_gb': quota_gb,
'quota_used_pct': used_pct,
'billing_cycle_start_day': int(self.config.get('billing_cycle_start_day', 1)),
'cycle_start': cu['start'].strftime('%Y-%m-%d'),
'cycle_end': cu['end'].strftime('%Y-%m-%d'),
'azure_portal_data_transfer_out_gb': az.get('portal_data_transfer_out_gb'),
'azure_bandwidth_out_gb': az.get('bandwidth_out_gb'),
'azure_usage_cost_cny': az.get('cost_cny'),
'azure_usage_month': az.get('month'),
'azure_usage_updated_at': az.get('updated_at'),
'vnstat_calendar_month_tx_bytes': cal_tx,
'vnstat_calendar_month_rx_bytes': cal_rx,
'vnstat_updated_at': vnstat_updated,
'updated_at': now.isoformat(),
}
def _vnstat_calendar_tx_gb(self):
"""参考值:vnstat 自然月出站(GB,十进制)。"""
now = utcnow()
with self.lock:
m = self.months.get(now.strftime('%Y-%m'), {})
return int(m.get('tx_bytes', 0)) / GB
# ---------- Azure 计费用量(Usage Details API) ----------
def _azure_token(self):
"""通过 VM 托管身份从 IMDS 获取 management.azure.com 的访问令牌。"""
url = ('http://169.254.169.254/metadata/identity/oauth2/token'
'?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F')
req = urllib.request.Request(url, headers={'Metadata': 'true'})
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode('utf-8'))['access_token']
def _azure_subscription_id(self):
url = 'http://169.254.169.254/metadata/instance/compute?api-version=2021-02-01'
req = urllib.request.Request(url, headers={'Metadata': 'true'})
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode('utf-8')).get('subscriptionId')
def fetch_azure_usage(self, month_offset=0):
"""拉取指定月份 usageDetails,聚合与门户免费服务页同源的 Data Transfer Out 用量。
month_offset: 0=当月(写入缓存),-1=上月(用于月报,不覆盖缓存)。"""
sub = self._azure_subscription_id()
if not sub:
raise RuntimeError('cannot read subscriptionId from IMDS')
token = self._azure_token()
now = utcnow()
month_start = (now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ timedelta(days=month_offset * 32)).replace(day=1)
nxt = (month_start.replace(day=1) + timedelta(days=32)).replace(day=1)
flt = ("properties/usageStart ge '{0}T00:00:00Z' and "
"properties/usageStart lt '{1}T00:00:00Z'").format(
month_start.strftime('%Y-%m-%d'), nxt.strftime('%Y-%m-%d'))
base = ('https://management.azure.com/subscriptions/{sub}/providers/'
'Microsoft.Consumption/usageDetails?api-version=2023-11-01'
'&metric=actualcost&$filter={f}')
url = base.format(sub=sub, f=urllib.parse.quote(flt, safe=''))
portal_gb = 0.0
bw_out_gb = 0.0
cost_cny = 0.0
rows = 0
pages = 0
while url:
req = urllib.request.Request(url, headers={'Authorization': 'Bearer ' + token})
with urllib.request.urlopen(req, timeout=30) as resp:
d = json.loads(resp.read().decode('utf-8'))
for item in d.get('value', []):
p = item.get('properties', {}) or {}
qty = float(p.get('quantity') or 0)
rows += 1
name = str(p.get('meterName') or '')
subcat = str(p.get('meterSubCategory') or '')
if str(p.get('meterCategory') or '') == 'Bandwidth' and 'Data Transfer Out' in name:
bw_out_gb += qty
# 门户免费服务页 "Data Transfer Out (GB)" = MGN 路由的出站免费计费表
if 'MGN' in subcat or '- Free' in name:
portal_gb += qty
cost_cny += float(p.get('costInBillingCurrency') or 0)
pages += 1
url = d.get('nextLink')
if pages > 30:
log.warning('azure usage pagination limit reached')
break
result = {
'month': month_start.strftime('%Y-%m'),
'portal_data_transfer_out_gb': round(portal_gb, 4),
'bandwidth_out_gb': round(bw_out_gb, 4),
'cost_cny': round(cost_cny, 4),
'rows': rows,
'updated_at': utcnow().isoformat(),
}
self.azure_usage = result
if month_offset == 0:
path = self.config.get('azure_usage_cache_path')
if path:
save_json(path, result)
log.info('azure usage fetched (offset=%d): %s', month_offset, result)
return result
def check_azure_usage(self):
if not self.config.get('azure_usage_enabled', False):
return
interval = float(self.config.get('azure_usage_fetch_interval_sec', 21600))
now = time.time()
if now - self._azure_last_fetch < interval:
return
self._azure_last_fetch = now
try:
self.fetch_azure_usage()
except Exception as e:
log.warning('azure usage fetch failed: %s', e)
# ---------- IP / 邮件 ----------
def get_current_ip(self):
configured = self.config.get('current_ip')
if configured:
return str(configured)
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(('8.8.8.8', 80))
return s.getsockname()[0]
finally:
s.close()
except OSError:
return '0.0.0.0'
def send_email(self, subject, body):
cfg = self.config['smtp']
msg = EmailMessage()
msg['Subject'] = subject
msg['From'] = formataddr((cfg.get('from_name', ''), cfg.get('from_addr')))
msg['To'] = cfg.get('to_addr')
msg.set_content(body)
context = None
if cfg.get('skip_cert_verify'):
context = ssl._create_unverified_context()
host = cfg['host']
port = int(cfg.get('port', 465))
timeout = int(cfg.get('timeout_sec', 30))
if cfg.get('ssl', False):
smtp_cls = smtplib.SMTP_SSL
kwargs = {'context': context}
else:
smtp_cls = smtplib.SMTP
kwargs = {}
with smtp_cls(host, port, timeout=timeout, **kwargs) as smtp:
if not cfg.get('ssl', False):
smtp.ehlo()
if cfg.get('starttls', True):
smtp.starttls(context=context)
smtp.ehlo()
smtp.login(cfg['user'], cfg['password'])
smtp.send_message(msg)
log.info('email sent: %s', subject)
def build_alert_mail(self, mk, used_gb, quota_gb, pct, az):
ip = self.get_current_ip()
month = (az or {}).get('month', mk.replace('azure:', ''))
az_updated = (az or {}).get('updated_at', '')
vnstat_tx = self._vnstat_calendar_tx_gb()
body = (
'Royirin Rosmontis Private Proxy JP 流量监控\n'
'==========\n'
'计量口径:Azure 门户(Standard Data Transfer Out - Free)\n'
f'统计月份:{month}(自然月)\n'
f'当前用量:{used_gb:.2f} GB / {quota_gb:.0f} GB({pct:.2f}%)\n'
f'AZURE 数据更新:{az_updated}\n'
f'参考(vnstat 自然月出站):{vnstat_tx:.2f} GB\n'
f'当前 IP:{ip}\n'
f'生效域名:{self.config.get("effective_domain", "")}\n'
'==========\n'
f'已达阈值 {pct:.2f}% (Azure 门户口径),请注意使用情况'
)
subject = f'[流量告警] {month} 已用 {pct:.2f}% ({used_gb:.2f}/{quota_gb:.0f} GB)'
return subject, body
def build_monthly_mail(self, month, used_gb, quota_gb, pct, az):
ip = self.get_current_ip()
over = '是' if used_gb > quota_gb else '否'
az_updated = (az or {}).get('updated_at', '')
vnstat_tx = self._vnstat_calendar_tx_gb()
body = (
'Royirin Rosmontis Private Proxy JP 流量监控\n'
'月度报告\n'
'==========\n'
'计量口径:Azure 门户(Standard Data Transfer Out - Free)\n'
f'报告月份:{month}(自然月)\n'
f'当月用量:{used_gb:.2f} GB / {quota_gb:.0f} GB({pct:.2f}%)\n'
f'AZURE 数据更新:{az_updated}\n'
f'参考(vnstat 自然月出站):{vnstat_tx:.2f} GB\n'
f'当前 IP:{ip}\n'
f'生效域名:{self.config.get("effective_domain", "")}\n'
'==========\n'
f'上月使用 {over} 超限, 使用量为限额的 {pct:.2f}%'
)
subject = f'[流量月报] {month} 用量 {used_gb:.2f} GB ({pct:.2f}%)'
return subject, body
# ---------- 定时任务 ----------
def check_alerts(self):
"""阈值告警:以 Azure 门户口径为准(无 Azure 数据时跳过,避免误报)。"""
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
if quota_gb <= 0:
return
az = self.azure_usage or {}
used_gb = az.get('portal_data_transfer_out_gb')
if used_gb is None:
log.warning('alerts skipped: no azure usage data yet')
return
mk = 'azure:' + str(az.get('month') or utcnow().strftime('%Y-%m'))
pct = used_gb / quota_gb * 100
sent = self.alert_state['alert_thresholds_sent'].setdefault(mk, [])
if not sent:
# 新周期/口径切换:已越过的档位直接标记,避免切换瞬间补发一堆邮件
for t in sorted(self.config.get('alert_thresholds_pct', [])):
if pct >= t:
sent.append(t)
self._save_alert_state()
log.info('seeded thresholds for %s at %.2f%%: %s', mk, pct, sent)
for t in sorted(self.config.get('alert_thresholds_pct', [])):
if pct >= t and t not in sent:
subject, body = self.build_alert_mail(mk, used_gb, quota_gb, pct, az)
try:
self.send_email(subject, body)
sent.append(t)
self._save_alert_state()
log.info('threshold %s%% alert sent for %s (used %.2f%%)', t, mk, pct)
except Exception as e:
log.error('send threshold %s%% alert failed for %s: %s; will retry',
t, mk, e)
break
def check_monthly_report(self):
"""月度报告:以 Azure 门户口径为准(上月数据,当月 1 号后发送)。"""
now = utcnow()
cur_start = self.cycle_start(now)
prev_start = (cur_start.replace(day=1) - timedelta(days=1)).replace(day=1)
prev_mk = 'azure:' + prev_start.strftime('%Y-%m')
if prev_mk in self.alert_state['monthly_reports_sent']:
return
# 口径切换迁移:旧键(cycle:)已发过则直接标记,避免重复发信
old_mk = 'cycle:' + prev_start.strftime('%Y-%m')
if old_mk in self.alert_state['monthly_reports_sent']:
self.alert_state['monthly_reports_sent'].append(prev_mk)
self._save_alert_state()
log.info('monthly report key migrated: %s already sent as %s', prev_mk, old_mk)
return
try:
az_prev = self.fetch_azure_usage(month_offset=-1)
except Exception as e:
log.error('fetch prev month azure usage failed: %s; will retry', e)
return
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
used_gb = az_prev.get('portal_data_transfer_out_gb') or 0.0
pct = used_gb / quota_gb * 100 if quota_gb else 0.0
subject, body = self.build_monthly_mail(
az_prev.get('month', prev_start.strftime('%Y-%m')), used_gb, quota_gb, pct, az_prev)
try:
self.send_email(subject, body)
self.alert_state['monthly_reports_sent'].append(prev_mk)
self._save_alert_state()
log.info('monthly report sent for %s (used %.2f GB, %.2f%%)',
prev_mk, used_gb, pct)
except Exception as e:
log.error('send monthly report for %s failed: %s; will retry', prev_mk, e)
def build_http_handler(monitor):
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/vpn/status/traffic':
try:
payload = json.dumps(monitor.current_status(), ensure_ascii=False)
body = payload.encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
except Exception as e:
log.exception('status endpoint error')
body = b'{"error":"internal"}'
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
elif self.path in ('/', '/healthz'):
body = b'ok\n'
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
body = b'{"error":"not found"}\n'
self.send_response(404)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
log.debug('http: ' + fmt, *args)
return Handler
def run_service(config):
monitor = Monitor(config)
def poll_loop():
while True:
try:
n = monitor.poll_vnstat()
log.debug('vnstat poll updated %d month(s)', n)
except Exception:
log.exception('poll loop error')
time.sleep(int(config.get('poll_interval_sec', 60)))
def schedule_loop():
while True:
try:
monitor.check_azure_usage() # 先刷新 Azure 口径数据,再评估告警
monitor.check_alerts()
monitor.check_monthly_report()
except Exception:
log.exception('schedule loop error')
time.sleep(int(config.get('schedule_interval_sec', 30)))
threading.Thread(target=poll_loop, daemon=True, name='vnstat-poll').start()
threading.Thread(target=schedule_loop, daemon=True, name='scheduler').start()
host = config.get('http_host', '127.0.0.1')
port = int(config.get('http_port', 18080))
server = ThreadingHTTPServer((host, port), build_http_handler(monitor))
log.info('HTTP server listening on http://%s:%d/vpn/status/traffic', host, port)
server.serve_forever()
def main():
ap = argparse.ArgumentParser(description='vnstat traffic monitor')
ap.add_argument('--config', default=os.environ.get('TRAFFIC_CONFIG', '/home/agent/config.json'))
ap.add_argument('--once', action='store_true', help='拉取一次 vnstat 后退出')
ap.add_argument('--status', action='store_true', help='打印当前状态 JSON 后退出')
ap.add_argument('--test-email', action='store_true', help='发送一封测试邮件后退出')
args = ap.parse_args()
os.environ['TRAFFIC_CONFIG'] = args.config
with open(args.config, 'r', encoding='utf-8') as f:
config = json.load(f)
logging.basicConfig(
level=getattr(logging, config.get('log_level', 'INFO').upper(), logging.INFO),
format='%(asctime)s %(levelname)s %(name)s: %(message)s')
if args.once:
m = Monitor(config)
n = m.poll_vnstat()
print(f'updated {n} month(s)')
return 0
if args.status:
m = Monitor(config)
m.poll_vnstat()
print(json.dumps(m.current_status(), ensure_ascii=False, indent=2))
return 0
if args.test_email:
m = Monitor(config)
m.poll_vnstat()
quota_gb = float(config.get('azure_free_limit_gb', 15))
az = m.azure_usage or {}
used_gb = az.get('portal_data_transfer_out_gb')
if used_gb is None:
try:
m.fetch_azure_usage()
az = m.azure_usage or {}
used_gb = az.get('portal_data_transfer_out_gb')
except Exception as e:
print('azure usage fetch failed:', e)
mk = 'azure:' + str(az.get('month') or utcnow().strftime('%Y-%m'))
pct = used_gb / quota_gb * 100 if (used_gb is not None and quota_gb) else 0.0
subject, body = m.build_alert_mail(mk, used_gb or 0.0, quota_gb, pct, az)
print('sending test email:')
print('Subject:', subject)
print(body)
m.send_email(subject, body)
print('sent')
return 0
run_service(config)
if __name__ == '__main__':
sys.exit(main())