Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70e8174f0d | ||
|
|
70591fb94d | ||
|
|
221eff484e | ||
|
|
fa2fac7fc3 | ||
|
|
a07ff04cdf | ||
|
|
58b74bfe9f | ||
|
|
9c9ce67678 | ||
|
|
6e494d3f0d | ||
|
|
15fa5a5630 | ||
|
|
e232752f0b | ||
|
|
4169d20e3e | ||
|
|
2a3ef0b009 | ||
|
|
fb09af184b | ||
|
|
1a7746176a | ||
|
|
7b535649fe | ||
|
|
31b165d48f | ||
|
|
5153e6b061 | ||
|
|
953df2578e | ||
|
|
b5af7846f4 | ||
|
|
1da34a5d54 | ||
|
|
8be8078a04 | ||
|
|
1af53b884a |
-12
@@ -1,12 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
||||||
# Local development state and reproducible build outputs.
|
|
||||||
.claude/
|
|
||||||
lite/.claude/
|
|
||||||
lite/backups/
|
|
||||||
lite/build/
|
|
||||||
lite/dist/
|
|
||||||
lite/RELEASE_WORKFLOW.md
|
|
||||||
*.bak
|
|
||||||
*.bak-*
|
|
||||||
@@ -1,17 +1,4 @@
|
|||||||
# Royirin Rosmontis Private Proxy
|
|
||||||
Supported by Global Rosmanka Cloud Nexus Inc. —— https://rosmanka.rrrosmontis.icu
|
|
||||||
## 警告
|
## 警告
|
||||||
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本
|
- 包含 WireGuard 的 common 版本转发器永久不再维护,推荐使用 Lite 版本
|
||||||
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
|
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
|
||||||
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows 应用 (.exe)
|
- 当前任何版本内 PAC 规则模式的配置、节点列表的获取随文件一起打包,不具有实时更新可用性,可自行添加
|
||||||
|
|
||||||
### 如何区分 common 版本与 Lite 版本
|
|
||||||
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳
|
|
||||||
|
|
||||||
## 服务器侧配置手册 (部分内容过时)
|
|
||||||
- https://rrrosmontis.icu/articles/index.html?id=00005
|
|
||||||
- 更推荐参考本仓库 server_config 文件夹中的 md 文件手册
|
|
||||||
|
|
||||||
## 若要直接使用仓库客户端对应的服务
|
|
||||||
请前往 https://sso.rrrosmontis.icu 注册 Rosmanka ID,然后与管理员商定使用细则
|
|
||||||
royirin@rrrosmontis.icu
|
|
||||||
|
|||||||
+5
-12
@@ -1,16 +1,10 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from PyInstaller.utils.hooks import collect_all
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
|
||||||
# 强制收集 PyQt5 全部模块及二进制文件
|
# 强制收集 PyQt5 全部模块及二进制文件
|
||||||
qt_datas, qt_binaries, qt_hiddenimports = collect_all('PyQt5')
|
qt_datas, qt_binaries, qt_hiddenimports = collect_all('PyQt5')
|
||||||
|
|
||||||
# 只内置 config.json(普通设置)。nodes.json / pac_*.json 属用户数据:
|
|
||||||
# 运行时读 %LOCALAPPDATA%\RRPP(首启自动创建模板 / 从 CDN 拉取),不进 EXE。
|
|
||||||
_JSON_DATAS = [(str(Path(SPECPATH) / 'config.json'), '.')]
|
|
||||||
print(f"[spec] 打包 json 数据文件: {[Path(src).name for src, _ in _JSON_DATAS]}")
|
|
||||||
|
|
||||||
# 自定义 manifest:普通权限运行 + 声明 PerMonitorV2 DPI 感知,
|
# 自定义 manifest:普通权限运行 + 声明 PerMonitorV2 DPI 感知,
|
||||||
# 避免程序在不同缩放比例的显示器之间移动时被 Windows 位图拉伸。
|
# 避免程序在不同缩放比例的显示器之间移动时被 Windows 位图拉伸。
|
||||||
_MANIFEST = r'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
_MANIFEST = r'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
@@ -45,16 +39,15 @@ _MANIFEST = r'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['main.py'],
|
['proxy_lite.py'],
|
||||||
pathex=['..\\.venv\\Lib\\site-packages'],
|
pathex=['..\\.venv\\Lib\\site-packages'],
|
||||||
binaries=qt_binaries,
|
binaries=qt_binaries,
|
||||||
datas=[('ico.ico', '.')] + _JSON_DATAS + qt_datas,
|
datas=[('..\\ico.ico', '.'), ('pac_domains.json', '.'), ('pac_ip_cn.json', '.')] + qt_datas,
|
||||||
hiddenimports=qt_hiddenimports,
|
hiddenimports=qt_hiddenimports,
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
# 排除 Nuitka 构建链相关模块(venv 里装了 Nuitka,但 PyInstaller 产物不需要)
|
excludes=[],
|
||||||
excludes=['nuitka', 'zstandard', 'ordered_set'],
|
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
optimize=0,
|
optimize=0,
|
||||||
)
|
)
|
||||||
@@ -66,7 +59,7 @@ exe = EXE(
|
|||||||
a.binaries,
|
a.binaries,
|
||||||
a.datas,
|
a.datas,
|
||||||
[],
|
[],
|
||||||
name='Royirin Rosmontis Private Proxy Lite Python',
|
name='Royirin Rosmontis Private Proxy Lite',
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
@@ -80,6 +73,6 @@ exe = EXE(
|
|||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
icon=['ico.ico'],
|
icon=['..\\ico.ico'],
|
||||||
manifest=_MANIFEST,
|
manifest=_MANIFEST,
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-15
@@ -1,19 +1,6 @@
|
|||||||
@echo off
|
@echo off
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
call ..\.venv\Scripts\python.exe -m PyInstaller --version >nul 2>&1
|
call ..\.venv\Scripts\python.exe -m PyInstaller RRPP_Lite.spec
|
||||||
if errorlevel 1 (
|
|
||||||
echo PyInstaller not installed. Run first:
|
|
||||||
echo ..\.venv\Scripts\python.exe -m pip install pyinstaller
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
call ..\.venv\Scripts\python.exe -m PyInstaller RRPP_Lite.spec --noconfirm
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo.
|
|
||||||
echo Build failed.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
echo.
|
echo.
|
||||||
echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite Python.exe
|
echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite.exe
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
@echo off
|
|
||||||
cd /d "%~dp0"
|
|
||||||
call ..\.venv\Scripts\python.exe -m nuitka --version >nul 2>&1
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Nuitka not installed. Run first:
|
|
||||||
echo ..\.venv\Scripts\python.exe -m pip install nuitka zstandard
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
rem Remove prior artifacts and the SCons incremental state before every build.
|
|
||||||
rem The constants blob is embedded outside normal dependency tracking, so reuse
|
|
||||||
rem can leave new UI strings and settings keys out of the executable.
|
|
||||||
rd /s /q "dist\main.build" 2>nul
|
|
||||||
rd /s /q "dist\main.dist" 2>nul
|
|
||||||
rd /s /q "dist\main.onefile-build" 2>nul
|
|
||||||
rem Use Nuitka COFF objects for embedded constants to avoid compiler cache reuse.
|
|
||||||
rem Zig C23 embed caches do not reliably track embedded blob changes.
|
|
||||||
set NUITKA_RESOURCE_MODE=coff_obj
|
|
||||||
rem Convert config.json version to Windows file-version segments (each <= 65535).
|
|
||||||
for /f %%v in ('..\.venv\Scripts\python.exe -c "import json;v=json.load(open('config.json',encoding='utf-8'))['version'];print(f'9999.{int(v[4:8])}.{int(v[8:12])}.0')"') do set "FILEVER=%%v"
|
|
||||||
call ..\.venv\Scripts\python.exe -m nuitka ^
|
|
||||||
--standalone --onefile ^
|
|
||||||
--enable-plugin=pyqt5 ^
|
|
||||||
--include-data-files=config.json=config.json ^
|
|
||||||
--windows-icon-from-ico=ico.ico ^
|
|
||||||
--windows-console-mode=disable ^
|
|
||||||
--company-name="Global Rosmanka Cloud Nexus Inc" ^
|
|
||||||
--product-name="R.R.P.P." ^
|
|
||||||
--file-version=%FILEVER% ^
|
|
||||||
--output-dir=dist ^
|
|
||||||
--output-filename="Royirin Rosmontis Private Proxy Lite.exe" ^
|
|
||||||
--assume-yes-for-downloads ^
|
|
||||||
main.py
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo.
|
|
||||||
echo Build failed.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
echo.
|
|
||||||
echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite.exe
|
|
||||||
pause
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "999909181618",
|
|
||||||
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
|
|
||||||
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client",
|
|
||||||
"update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 121 KiB |
-3482
File diff suppressed because one or more lines are too long
@@ -1,10 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"domain": "",
|
|
||||||
"port": "",
|
|
||||||
"country": "",
|
|
||||||
"username": "",
|
|
||||||
"password": "",
|
|
||||||
"remark": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "",
|
|
||||||
"date": "",
|
|
||||||
"description": "PAC 智能分流的明确直连域名模板。强制代理规则和用户白名单优先级更高。",
|
|
||||||
"domains": []
|
|
||||||
}
|
|
||||||
+214
-4
@@ -1,6 +1,216 @@
|
|||||||
{
|
{
|
||||||
"_comment": "强制走代理隧道的域名后缀模板(数据字段为空)。生成与更新:others/update_pac_domains/update_pac_domains.py;客户端按文件内 version/date 从 CDN 在线更新。",
|
"_comment": "强制走代理隧道的域名后缀(IP 智能分流的补充规则):命中这些域名的连接即使解析到国内 IP 也强制走隧道。匹配规则:host == 条目 或 host 以 \".条目\" 结尾。该文件可选:EXE 同目录的 pac_domains.json 优先于打包内置版本,可自行增删。",
|
||||||
"version": "",
|
"domains": [
|
||||||
"date": "",
|
"1e100.net",
|
||||||
"domains": []
|
"aistudio.google.com",
|
||||||
|
"akamaihd.net",
|
||||||
|
"anthropic.com",
|
||||||
|
"apexlegends.com",
|
||||||
|
"azure.com",
|
||||||
|
"azureedge.net",
|
||||||
|
"azurefd.net",
|
||||||
|
"battle.net",
|
||||||
|
"bing.com",
|
||||||
|
"bing.net",
|
||||||
|
"bingapis.com",
|
||||||
|
"blizzard.com",
|
||||||
|
"canva.com",
|
||||||
|
"cdninstagram.com",
|
||||||
|
"cf-cdn.com",
|
||||||
|
"chatgpt.com",
|
||||||
|
"clau.de",
|
||||||
|
"claude.ai",
|
||||||
|
"claudeusercontent.com",
|
||||||
|
"cloudflare.com",
|
||||||
|
"cloudflare.net",
|
||||||
|
"cloudflarecdn.com",
|
||||||
|
"cloudflareinsights.com",
|
||||||
|
"cloudflarestatus.com",
|
||||||
|
"cloudfront.net",
|
||||||
|
"cloudinary.com",
|
||||||
|
"crunchyroll.com",
|
||||||
|
"deepmind.com",
|
||||||
|
"deepmind.google.com",
|
||||||
|
"discord.com",
|
||||||
|
"discord.gg",
|
||||||
|
"discord.media",
|
||||||
|
"discordapp.com",
|
||||||
|
"discordapp.net",
|
||||||
|
"disney-plus.net",
|
||||||
|
"disneyplus.com",
|
||||||
|
"docker.com",
|
||||||
|
"docker.io",
|
||||||
|
"dssott.com",
|
||||||
|
"duckduckgo.com",
|
||||||
|
"ea.com",
|
||||||
|
"eaassets.com",
|
||||||
|
"epicgames.com",
|
||||||
|
"facebook.com",
|
||||||
|
"fastly.net",
|
||||||
|
"fastlylb.net",
|
||||||
|
"fb.com",
|
||||||
|
"fbcdn.net",
|
||||||
|
"fbsbx.com",
|
||||||
|
"figma.com",
|
||||||
|
"gemini.google.com",
|
||||||
|
"generativeai.googleapis.com",
|
||||||
|
"ggpht.com",
|
||||||
|
"ghcr.io",
|
||||||
|
"github.com",
|
||||||
|
"github.io",
|
||||||
|
"githubassets.com",
|
||||||
|
"githubcopilot.com",
|
||||||
|
"githubusercontent.com",
|
||||||
|
"gitlab.com",
|
||||||
|
"gitlab.io",
|
||||||
|
"gog.com",
|
||||||
|
"google",
|
||||||
|
"google-analytics.com",
|
||||||
|
"google.co.jp",
|
||||||
|
"google.com",
|
||||||
|
"google.com.hk",
|
||||||
|
"googleapis.com",
|
||||||
|
"googlehosted.com",
|
||||||
|
"googletagmanager.com",
|
||||||
|
"googleusercontent.com",
|
||||||
|
"googlevideo.com",
|
||||||
|
"grok.com",
|
||||||
|
"gstatic.com",
|
||||||
|
"hf.co",
|
||||||
|
"huggingface.co",
|
||||||
|
"huggingfacecdn.com",
|
||||||
|
"hulu.com",
|
||||||
|
"imgur.com",
|
||||||
|
"instagram.com",
|
||||||
|
"itch.io",
|
||||||
|
"jsdelivr.net",
|
||||||
|
"leagueoflegends.com",
|
||||||
|
"licdn.com",
|
||||||
|
"line-apps.com",
|
||||||
|
"line-scdn.net",
|
||||||
|
"line.me",
|
||||||
|
"linkedin.com",
|
||||||
|
"linux.do",
|
||||||
|
"live.com",
|
||||||
|
"max.com",
|
||||||
|
"medium.com",
|
||||||
|
"messenger.com",
|
||||||
|
"meta.ai",
|
||||||
|
"meta.com",
|
||||||
|
"microsoft.com",
|
||||||
|
"microsoft365.com",
|
||||||
|
"microsoftonline.com",
|
||||||
|
"midjourney.com",
|
||||||
|
"msecnd.net",
|
||||||
|
"msftncsi.com",
|
||||||
|
"msn.com",
|
||||||
|
"netflix.com",
|
||||||
|
"netlify.app",
|
||||||
|
"netlify.com",
|
||||||
|
"nflxext.com",
|
||||||
|
"nflximg.net",
|
||||||
|
"nflxvideo.net",
|
||||||
|
"nico.ms",
|
||||||
|
"nicovideo.jp",
|
||||||
|
"nintendo.com",
|
||||||
|
"nintendowifi.net",
|
||||||
|
"oaistatic.com",
|
||||||
|
"oaiusercontent.com",
|
||||||
|
"office.com",
|
||||||
|
"office.net",
|
||||||
|
"openai.com",
|
||||||
|
"openaiusercontent.com",
|
||||||
|
"origin.com",
|
||||||
|
"pages.dev",
|
||||||
|
"paramountplus.com",
|
||||||
|
"patreon.com",
|
||||||
|
"paypal.com",
|
||||||
|
"paypalobjects.com",
|
||||||
|
"perplexity.ai",
|
||||||
|
"pinimg.com",
|
||||||
|
"pinterest.com",
|
||||||
|
"pixiv.net",
|
||||||
|
"playstation.com",
|
||||||
|
"poe.com",
|
||||||
|
"primevideo.com",
|
||||||
|
"proton.me",
|
||||||
|
"protonmail.com",
|
||||||
|
"protonvpn.com",
|
||||||
|
"pximg.net",
|
||||||
|
"quora.com",
|
||||||
|
"redd.it",
|
||||||
|
"reddit.com",
|
||||||
|
"redditmedia.com",
|
||||||
|
"redditstatic.com",
|
||||||
|
"riotgames.com",
|
||||||
|
"s-microsoft.com",
|
||||||
|
"s.team",
|
||||||
|
"sc-cdn.net",
|
||||||
|
"scdn.co",
|
||||||
|
"search.brave.com",
|
||||||
|
"serverfault.com",
|
||||||
|
"signal.org",
|
||||||
|
"slack-edge.com",
|
||||||
|
"slack-imgs.com",
|
||||||
|
"slack.com",
|
||||||
|
"snapchat.com",
|
||||||
|
"sonyentertainmentnetwork.com",
|
||||||
|
"spotify.com",
|
||||||
|
"spotifycdn.com",
|
||||||
|
"stackexchange.com",
|
||||||
|
"stackoverflow.com",
|
||||||
|
"statically.io",
|
||||||
|
"steam-api.com",
|
||||||
|
"steam-chat.com",
|
||||||
|
"steam.com",
|
||||||
|
"steamcdn-a.akamaihd.net",
|
||||||
|
"steamcommunity-a.akamaihd.net",
|
||||||
|
"steamcommunity.com",
|
||||||
|
"steamcontent.com",
|
||||||
|
"steamgames.com",
|
||||||
|
"steampowered.com",
|
||||||
|
"steamserver.net",
|
||||||
|
"steamstatic.com",
|
||||||
|
"steamstore-a.akamaihd.net",
|
||||||
|
"steamusercontent.com",
|
||||||
|
"steamuserimages-a.akamaihd.net",
|
||||||
|
"superuser.com",
|
||||||
|
"t.me",
|
||||||
|
"telega.one",
|
||||||
|
"telegram-cdn.org",
|
||||||
|
"telegram.org",
|
||||||
|
"threads.net",
|
||||||
|
"tiktok.com",
|
||||||
|
"tiktokcdn.com",
|
||||||
|
"tiktokv.com",
|
||||||
|
"ttvnw.net",
|
||||||
|
"tumblr.com",
|
||||||
|
"tv.apple.com",
|
||||||
|
"tvplus.apple.com",
|
||||||
|
"twimg.com",
|
||||||
|
"twitch.tv",
|
||||||
|
"twitchcdn.net",
|
||||||
|
"twitter.com",
|
||||||
|
"ubi.com",
|
||||||
|
"ubisoft.com",
|
||||||
|
"unpkg.com",
|
||||||
|
"unrealengine.com",
|
||||||
|
"valorant.com",
|
||||||
|
"valvesoftware.com",
|
||||||
|
"vercel.app",
|
||||||
|
"vercel.com",
|
||||||
|
"whatsapp.com",
|
||||||
|
"whatsapp.net",
|
||||||
|
"wikimedia.org",
|
||||||
|
"wikipedia.org",
|
||||||
|
"workers.dev",
|
||||||
|
"x.ai",
|
||||||
|
"x.com",
|
||||||
|
"xai.com",
|
||||||
|
"youtube.com",
|
||||||
|
"ytimg.com",
|
||||||
|
"zoom.com",
|
||||||
|
"zoom.us",
|
||||||
|
"zoomgov.com"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+7461
-4
File diff suppressed because it is too large
Load Diff
+1341
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
"""RRPP Lite 的无 GUI 核心逻辑。"""
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"""本地转发器的无状态报文解析与改写函数。"""
|
|
||||||
|
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
|
|
||||||
HTTP_METHODS = (
|
|
||||||
b"CONNECT ", b"GET ", b"POST ", b"PUT ", b"DELETE ",
|
|
||||||
b"HEAD ", b"OPTIONS ", b"PATCH ",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def starts_with_http_method(data: bytes) -> bool:
|
|
||||||
return any(data.startswith(method) for method in HTTP_METHODS)
|
|
||||||
|
|
||||||
|
|
||||||
def inject_proxy_auth(data: bytes, basic_auth: str) -> bytes:
|
|
||||||
"""为完整 HTTP 代理请求注入上游 Basic Authorization。"""
|
|
||||||
if not starts_with_http_method(data):
|
|
||||||
return data
|
|
||||||
end = data.find(b"\r\n\r\n")
|
|
||||||
if end == -1:
|
|
||||||
return data
|
|
||||||
head = data[:end]
|
|
||||||
first_end = head.find(b"\r\n")
|
|
||||||
request_line = head if first_end == -1 else head[:first_end]
|
|
||||||
headers = b"" if first_end == -1 else head[first_end + 2:]
|
|
||||||
for line in headers.split(b"\r\n"):
|
|
||||||
if b":" not in line:
|
|
||||||
continue
|
|
||||||
name = line[:line.index(b":")].strip().lower()
|
|
||||||
if name == b"authorization":
|
|
||||||
return data
|
|
||||||
auth_line = f"Authorization: Basic {basic_auth}\r\n".encode()
|
|
||||||
return request_line + b"\r\n" + auth_line + headers + data[end:]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_connect_authority(first_line: str):
|
|
||||||
"""从 CONNECT 首行提取 ``(host, port)``,失败时返回 ``None``。"""
|
|
||||||
parts = first_line.split(" ")
|
|
||||||
if len(parts) < 2:
|
|
||||||
return None
|
|
||||||
authority = parts[1]
|
|
||||||
if authority.startswith("[") and "]" in authority:
|
|
||||||
try:
|
|
||||||
host = authority[1:authority.index("]")]
|
|
||||||
tail = authority[authority.index("]") + 1:]
|
|
||||||
if tail.startswith(":") and tail[1:].isdigit():
|
|
||||||
return host, int(tail[1:])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if ":" not in authority:
|
|
||||||
return None
|
|
||||||
host, _, port_s = authority.rpartition(":")
|
|
||||||
if not host or not port_s.isdigit():
|
|
||||||
return None
|
|
||||||
return host, int(port_s)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_http_absolute(first_line: str):
|
|
||||||
"""从明文 HTTP 代理首行提取 ``(host, port)``,失败时返回 ``None``。"""
|
|
||||||
parts = first_line.split(" ")
|
|
||||||
if len(parts) < 2 or not parts[1].lower().startswith(("http://", "https://")):
|
|
||||||
return None
|
|
||||||
parsed = urlsplit(parts[1])
|
|
||||||
if not parsed.hostname:
|
|
||||||
return None
|
|
||||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
||||||
return parsed.hostname, port
|
|
||||||
|
|
||||||
|
|
||||||
def force_http_close(buffer: bytes) -> bytes:
|
|
||||||
"""直连明文 HTTP 时强制 Connection: close,避免错复用连接。"""
|
|
||||||
end = buffer.find(b"\r\n\r\n")
|
|
||||||
if end == -1:
|
|
||||||
return buffer
|
|
||||||
head, tail = buffer[:end], buffer[end:]
|
|
||||||
lines = head.split(b"\r\n")
|
|
||||||
out = [lines[0]]
|
|
||||||
conn_seen = False
|
|
||||||
for line in lines[1:]:
|
|
||||||
low = line.lower()
|
|
||||||
if low.startswith(b"connection:"):
|
|
||||||
out.append(b"Connection: close")
|
|
||||||
conn_seen = True
|
|
||||||
elif low.startswith(b"proxy-connection:"):
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
out.append(line)
|
|
||||||
if not conn_seen:
|
|
||||||
out.append(b"Connection: close")
|
|
||||||
return b"\r\n".join(out) + tail
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"""路由、白名单和转发器共用的网络基础函数。"""
|
|
||||||
|
|
||||||
import functools
|
|
||||||
import ipaddress
|
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
def ttl_cache(seconds: int, maxsize: int = 16384):
|
|
||||||
"""带 TTL 的小型线程安全缓存,并保留 ``cache_clear`` 兼容接口。"""
|
|
||||||
def decorator(function):
|
|
||||||
store = {}
|
|
||||||
lock = threading.Lock()
|
|
||||||
|
|
||||||
@functools.wraps(function)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
key = (args, tuple(sorted(kwargs.items())))
|
|
||||||
now = time.time()
|
|
||||||
with lock:
|
|
||||||
hit = store.get(key)
|
|
||||||
if hit and now - hit[0] < seconds:
|
|
||||||
return hit[1]
|
|
||||||
value = function(*args, **kwargs)
|
|
||||||
with lock:
|
|
||||||
store[key] = (now, value)
|
|
||||||
if len(store) > maxsize:
|
|
||||||
cutoff = now - seconds
|
|
||||||
for cached_key, (timestamp, _) in list(store.items()):
|
|
||||||
if timestamp < cutoff:
|
|
||||||
del store[cached_key]
|
|
||||||
if len(store) > maxsize:
|
|
||||||
store.clear()
|
|
||||||
return value
|
|
||||||
|
|
||||||
def cache_clear():
|
|
||||||
with lock:
|
|
||||||
store.clear()
|
|
||||||
|
|
||||||
wrapper.cache_clear = cache_clear
|
|
||||||
return wrapper
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def is_private_ipv4_int(value: int) -> bool:
|
|
||||||
"""整数形式 IPv4 是否属于常见私网、回环或链路本地段。"""
|
|
||||||
return (
|
|
||||||
(value & 0xFF000000) == 0x0A000000
|
|
||||||
or (value & 0xFFF00000) == 0xAC100000
|
|
||||||
or (value & 0xFFFF0000) == 0xC0A80000
|
|
||||||
or (value & 0xFF000000) == 0x7F000000
|
|
||||||
or (value & 0xFFFF0000) == 0xA9FE0000
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_host_ips(host: str):
|
|
||||||
"""解析主机名的 IPv4/IPv6 集合;字面量 IP 返回自身,失败返回空集合。"""
|
|
||||||
try:
|
|
||||||
return {ipaddress.ip_address(host)}
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
return {
|
|
||||||
ipaddress.ip_address(address[4][0])
|
|
||||||
for address in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
return set()
|
|
||||||
|
|
||||||
|
|
||||||
def is_private_host(host: str) -> bool:
|
|
||||||
"""目标是否命中私网、回环或 IPv6 ULA/链路本地地址。"""
|
|
||||||
for address in resolve_host_ips(host):
|
|
||||||
if address.version == 4 and is_private_ipv4_int(int(address)):
|
|
||||||
return True
|
|
||||||
if address.version == 6 and (
|
|
||||||
address.is_loopback
|
|
||||||
or address.is_link_local
|
|
||||||
or address in ipaddress.ip_network("fc00::/7")
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""节点订阅的认证请求协议。"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
|
|
||||||
class NodeAuthError(Exception):
|
|
||||||
"""订阅服务拒绝请求时携带的 HTTP 状态和用户可读原因。"""
|
|
||||||
|
|
||||||
def __init__(self, message, code=0):
|
|
||||||
super().__init__(message)
|
|
||||||
self.code = code
|
|
||||||
|
|
||||||
|
|
||||||
def b64url(value: str) -> str:
|
|
||||||
"""SSO 查询参数使用的无 padding URL-safe Base64。"""
|
|
||||||
return base64.urlsafe_b64encode(str(value).encode("utf-8")).decode("ascii").rstrip("=")
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_nodes_auth(url, user, password, timeout=10):
|
|
||||||
"""直连拉取需 SSO 查询参数认证的 nodes.json。"""
|
|
||||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
||||||
separator = "&" if "?" in url else "?"
|
|
||||||
request_url = f"{url}{separator}user={b64url(user)}&password={b64url(password)}"
|
|
||||||
try:
|
|
||||||
with opener.open(request_url, timeout=timeout) as response:
|
|
||||||
return json.loads(response.read().decode("utf-8"))
|
|
||||||
except urllib.error.HTTPError as error:
|
|
||||||
body = ""
|
|
||||||
try:
|
|
||||||
body = error.read().decode("utf-8", "replace").strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise NodeAuthError(body or f"请求被拒绝 (HTTP {error.code})", error.code) from None
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""节点与登录凭据的本机加密存储。"""
|
|
||||||
|
|
||||||
import ctypes
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
class _DpapiBlob(ctypes.Structure):
|
|
||||||
_fields_ = [
|
|
||||||
("cbData", ctypes.c_uint32),
|
|
||||||
("pbData", ctypes.POINTER(ctypes.c_char)),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class NodeSecretsStore:
|
|
||||||
"""使用 Windows DPAPI 保存节点和 SSO 凭据;非 Windows 保持明文兼容。"""
|
|
||||||
|
|
||||||
def __init__(self, user_dir_getter):
|
|
||||||
self._user_dir_getter = user_dir_getter
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def protect_bytes(data: bytes) -> bytes:
|
|
||||||
crypt32 = ctypes.WinDLL("crypt32")
|
|
||||||
kernel32 = ctypes.WinDLL("kernel32")
|
|
||||||
crypt32.CryptProtectData.argtypes = [
|
|
||||||
ctypes.POINTER(_DpapiBlob), ctypes.c_wchar_p, ctypes.POINTER(_DpapiBlob),
|
|
||||||
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob),
|
|
||||||
]
|
|
||||||
crypt32.CryptProtectData.restype = ctypes.c_bool
|
|
||||||
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
|
||||||
buffer = ctypes.create_string_buffer(data, len(data))
|
|
||||||
blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
|
|
||||||
blob_out = _DpapiBlob()
|
|
||||||
if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None,
|
|
||||||
None, None, 0, ctypes.byref(blob_out)):
|
|
||||||
raise OSError("CryptProtectData 调用失败")
|
|
||||||
try:
|
|
||||||
return ctypes.string_at(blob_out.pbData, blob_out.cbData)
|
|
||||||
finally:
|
|
||||||
kernel32.LocalFree(blob_out.pbData)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def unprotect_bytes(data: bytes) -> bytes:
|
|
||||||
crypt32 = ctypes.WinDLL("crypt32")
|
|
||||||
kernel32 = ctypes.WinDLL("kernel32")
|
|
||||||
crypt32.CryptUnprotectData.argtypes = [
|
|
||||||
ctypes.POINTER(_DpapiBlob), ctypes.POINTER(ctypes.c_wchar_p), ctypes.POINTER(_DpapiBlob),
|
|
||||||
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob),
|
|
||||||
]
|
|
||||||
crypt32.CryptUnprotectData.restype = ctypes.c_bool
|
|
||||||
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
|
||||||
buffer = ctypes.create_string_buffer(data, len(data))
|
|
||||||
blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
|
|
||||||
description = ctypes.c_wchar_p()
|
|
||||||
blob_out = _DpapiBlob()
|
|
||||||
if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(description), None,
|
|
||||||
None, None, 0, ctypes.byref(blob_out)):
|
|
||||||
raise OSError("CryptUnprotectData 调用失败")
|
|
||||||
try:
|
|
||||||
return ctypes.string_at(blob_out.pbData, blob_out.cbData)
|
|
||||||
finally:
|
|
||||||
kernel32.LocalFree(blob_out.pbData)
|
|
||||||
if description.value:
|
|
||||||
kernel32.LocalFree(description)
|
|
||||||
|
|
||||||
def encrypt_text(self, text: str) -> bytes:
|
|
||||||
if os.name != "nt":
|
|
||||||
return text.encode("utf-8")
|
|
||||||
return self.protect_bytes(text.encode("utf-8"))
|
|
||||||
|
|
||||||
def decrypt_bytes(self, blob: bytes) -> str:
|
|
||||||
if os.name != "nt":
|
|
||||||
return blob.decode("utf-8")
|
|
||||||
try:
|
|
||||||
return self.unprotect_bytes(blob).decode("utf-8")
|
|
||||||
except Exception:
|
|
||||||
return blob.decode("utf-8")
|
|
||||||
|
|
||||||
def save_auth(self, username, password):
|
|
||||||
directory = self._user_dir_getter()
|
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
|
||||||
text = json.dumps({"username": username, "password": password}, ensure_ascii=False)
|
|
||||||
(directory / "auth.json").write_bytes(self.encrypt_text(text))
|
|
||||||
|
|
||||||
def load_auth(self):
|
|
||||||
try:
|
|
||||||
path = self._user_dir_getter() / "auth.json"
|
|
||||||
if not path.is_file():
|
|
||||||
return None
|
|
||||||
data = json.loads(self.decrypt_bytes(path.read_bytes()))
|
|
||||||
if not (isinstance(data, dict) and data.get("username") and data.get("password")):
|
|
||||||
return None
|
|
||||||
return data
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""PAC 数据文件的网络传输与安全落盘。"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def read_version(path):
|
|
||||||
"""读取 PAC JSON 的 version/date;无效文件返回空字符串。"""
|
|
||||||
try:
|
|
||||||
data = json.loads(Path(path).read_text("utf-8"))
|
|
||||||
return str(data.get("version") or ""), str(data.get("date") or "")
|
|
||||||
except Exception:
|
|
||||||
return "", ""
|
|
||||||
|
|
||||||
|
|
||||||
def no_proxy_opener():
|
|
||||||
"""创建忽略环境/系统代理的直连 HTTP opener。"""
|
|
||||||
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_json(url, timeout=15, tries=2):
|
|
||||||
"""以直连方式拉取 JSON;最后一次失败时保留原始异常。"""
|
|
||||||
opener = no_proxy_opener()
|
|
||||||
for attempt in range(tries):
|
|
||||||
try:
|
|
||||||
with opener.open(url, timeout=timeout) as response:
|
|
||||||
return json.loads(response.read().decode("utf-8"))
|
|
||||||
except Exception:
|
|
||||||
if attempt == tries - 1:
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def validate_document(name, data):
|
|
||||||
"""校验各 PAC 文件的最小结构,避免错误响应覆盖有效缓存。"""
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return False
|
|
||||||
if name in ("pac_domains", "pac_direct_domains"):
|
|
||||||
return isinstance(data.get("domains"), list)
|
|
||||||
if name == "pac_ip_cn":
|
|
||||||
return isinstance(data.get("cidrs"), list)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def atomic_write(directory, name, data):
|
|
||||||
"""将 PAC JSON 以 tmp + replace 原子写入指定用户目录。"""
|
|
||||||
directory = Path(directory)
|
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
|
||||||
target = directory / f"{name}.json"
|
|
||||||
temporary_path = directory / f"{name}.json.tmp"
|
|
||||||
temporary_path.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
|
|
||||||
os.replace(temporary_path, target)
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"""PAC 路由引擎使用的纯算法,不依赖文件、网络或 Qt。"""
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def merge_ip_ranges(cidrs, version):
|
|
||||||
"""将指定 IP 版本的 CIDR 合并为整数闭区间,供二分查找。"""
|
|
||||||
ranges = []
|
|
||||||
for cidr in cidrs:
|
|
||||||
network = ipaddress.ip_network(str(cidr).strip(), strict=False)
|
|
||||||
if network.version == version:
|
|
||||||
ranges.append((int(network.network_address), int(network.broadcast_address)))
|
|
||||||
ranges.sort()
|
|
||||||
merged = []
|
|
||||||
for start, end in ranges:
|
|
||||||
if merged and start <= merged[-1][1] + 1:
|
|
||||||
if end > merged[-1][1]:
|
|
||||||
merged[-1] = (merged[-1][0], end)
|
|
||||||
else:
|
|
||||||
merged.append((start, end))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def build_suffix_trie(suffixes):
|
|
||||||
"""按反向域名标签建立 Trie,适合大规模后缀规则的 O(标签数) 匹配。"""
|
|
||||||
root = {}
|
|
||||||
for suffix in suffixes:
|
|
||||||
node = root
|
|
||||||
for label in reversed(suffix.split(".")):
|
|
||||||
node = node.setdefault(label, {})
|
|
||||||
node[""] = True
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def suffix_trie_matches(host: str, trie) -> bool:
|
|
||||||
"""判断主机名是否命中一个后缀 Trie。"""
|
|
||||||
node = trie
|
|
||||||
for label in reversed(host.rstrip(".").lower().split(".")):
|
|
||||||
node = node.get(label)
|
|
||||||
if node is None:
|
|
||||||
return False
|
|
||||||
if node.get(""):
|
|
||||||
return True
|
|
||||||
return bool(node and node.get(""))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_domain_rules(raw_rules, seed_suffixes=()):
|
|
||||||
"""解析后缀、``full:`` 与 ``regexp:`` 三种 PAC 域名规则。
|
|
||||||
|
|
||||||
返回 ``(suffixes, exact, regexes, invalid_regex_count)``。调用方决定
|
|
||||||
这些规则是“强制代理”还是“明确直连”,从而避免解析层携带业务策略。
|
|
||||||
"""
|
|
||||||
suffixes = set(seed_suffixes)
|
|
||||||
exact = set()
|
|
||||||
regexes = []
|
|
||||||
invalid_regexes = 0
|
|
||||||
for raw in raw_rules or ():
|
|
||||||
raw_rule = str(raw).strip()
|
|
||||||
rule = raw_rule.lower()
|
|
||||||
if not raw_rule:
|
|
||||||
continue
|
|
||||||
if rule.startswith("full:"):
|
|
||||||
host = rule[len("full:"):].rstrip(".")
|
|
||||||
if host:
|
|
||||||
exact.add(host)
|
|
||||||
continue
|
|
||||||
if rule.startswith("regexp:"):
|
|
||||||
pattern = raw_rule[len("regexp:"):]
|
|
||||||
if not pattern:
|
|
||||||
invalid_regexes += 1
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
regexes.append(re.compile(pattern, re.IGNORECASE))
|
|
||||||
except re.error:
|
|
||||||
invalid_regexes += 1
|
|
||||||
continue
|
|
||||||
suffix = rule.lstrip("*.").rstrip(".")
|
|
||||||
if suffix:
|
|
||||||
suffixes.add(suffix)
|
|
||||||
return suffixes, frozenset(exact), tuple(regexes), invalid_regexes
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""用户设置的 JSON 存储实现。"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
class JsonSettingsStore:
|
|
||||||
"""读取与原子写回一个 JSON 对象。
|
|
||||||
|
|
||||||
``path_getter`` 在每次操作时调用,保证测试或运行时切换用户目录后不会
|
|
||||||
意外写到旧目录。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, path_getter):
|
|
||||||
self._path_getter = path_getter
|
|
||||||
|
|
||||||
def load(self):
|
|
||||||
path = self._path_getter()
|
|
||||||
try:
|
|
||||||
if not path.is_file():
|
|
||||||
return {}
|
|
||||||
data = json.loads(path.read_text("utf-8"))
|
|
||||||
return data if isinstance(data, dict) else {}
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def save(self, data):
|
|
||||||
path = self._path_getter()
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
temporary_path = path.with_suffix(path.suffix + ".tmp")
|
|
||||||
temporary_path.write_text(
|
|
||||||
json.dumps(data, ensure_ascii=False, indent=2) + "\n", "utf-8"
|
|
||||||
)
|
|
||||||
os.replace(temporary_path, path)
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""第三方 RRPP Client JSON 订阅的下载、校验与展示辅助。"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import urllib.request
|
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_nodes(url: str, timeout=15):
|
|
||||||
"""直连读取一个第三方订阅,避免首次导入受系统代理残留影响。"""
|
|
||||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
||||||
request = urllib.request.Request(
|
|
||||||
url,
|
|
||||||
headers={"Accept": "application/json", "User-Agent": "RRPP-Lite"},
|
|
||||||
)
|
|
||||||
with opener.open(request, timeout=timeout) as response:
|
|
||||||
return validate_nodes(json.loads(response.read().decode("utf-8-sig")))
|
|
||||||
|
|
||||||
|
|
||||||
def validate_nodes(data):
|
|
||||||
"""校验 RRPP Client 节点数组,返回可安全写入本地缓存的副本。"""
|
|
||||||
if not isinstance(data, list) or not data:
|
|
||||||
raise ValueError("订阅应为非空的 JSON 节点数组")
|
|
||||||
result = []
|
|
||||||
for index, node in enumerate(data, 1):
|
|
||||||
if not isinstance(node, dict):
|
|
||||||
raise ValueError(f"第 {index} 项不是节点对象")
|
|
||||||
domain = str(node.get("domain") or "").strip()
|
|
||||||
method = (node.get("methods") or {}).get("http") or {}
|
|
||||||
port = method.get("port", node.get("port"))
|
|
||||||
try:
|
|
||||||
port = int(port)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
port = 0
|
|
||||||
if not domain:
|
|
||||||
raise ValueError(f"第 {index} 项缺少 domain")
|
|
||||||
if not 1 <= port <= 65535:
|
|
||||||
raise ValueError(f"第 {index} 项的 HTTP 代理端口无效")
|
|
||||||
result.append(dict(node))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def mask_url(url: str) -> str:
|
|
||||||
"""用于 UI 展示,隐藏查询参数和 URL 用户信息中的敏感令牌。"""
|
|
||||||
try:
|
|
||||||
parts = urlsplit(str(url))
|
|
||||||
host = parts.hostname or ""
|
|
||||||
if parts.port:
|
|
||||||
host = f"{host}:{parts.port}"
|
|
||||||
if parts.username:
|
|
||||||
host = "***@" + host
|
|
||||||
return urlunsplit((parts.scheme, host, parts.path, "…" if parts.query else "", ""))
|
|
||||||
except Exception:
|
|
||||||
return "已隐藏订阅地址"
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""线程安全的本地转发流量统计。"""
|
|
||||||
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class TrafficStats:
|
|
||||||
"""记录单次代理会话的上下行总量与采样速率。"""
|
|
||||||
|
|
||||||
def __init__(self, clock=time.time):
|
|
||||||
self._clock = clock
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._up = 0
|
|
||||||
self._down = 0
|
|
||||||
self._prev_up = 0
|
|
||||||
self._prev_down = 0
|
|
||||||
self._prev_ts = 0.0
|
|
||||||
|
|
||||||
def add(self, up=0, down=0):
|
|
||||||
with self._lock:
|
|
||||||
self._up += up
|
|
||||||
self._down += down
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
with self._lock:
|
|
||||||
self._up = self._down = 0
|
|
||||||
self._prev_up = self._prev_down = 0
|
|
||||||
self._prev_ts = self._clock()
|
|
||||||
|
|
||||||
def rate(self):
|
|
||||||
with self._lock:
|
|
||||||
now = self._clock()
|
|
||||||
if self._prev_ts > 0 and now > self._prev_ts:
|
|
||||||
elapsed = now - self._prev_ts
|
|
||||||
up_rate = int((self._up - self._prev_up) / elapsed)
|
|
||||||
down_rate = int((self._down - self._prev_down) / elapsed)
|
|
||||||
else:
|
|
||||||
up_rate = down_rate = 0
|
|
||||||
self._prev_up = self._up
|
|
||||||
self._prev_down = self._down
|
|
||||||
self._prev_ts = now
|
|
||||||
return up_rate, down_rate, self._up, self._down
|
|
||||||
|
|
||||||
def snapshot(self):
|
|
||||||
with self._lock:
|
|
||||||
return self._up, self._down
|
|
||||||
|
|
||||||
|
|
||||||
def format_bytes(value):
|
|
||||||
"""将字节数格式化为 UI 展示文本。"""
|
|
||||||
number = float(value)
|
|
||||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
||||||
if number < 1024 or unit == "TB":
|
|
||||||
return f"{int(number)} {unit}" if unit == "B" else f"{number:.1f} {unit}"
|
|
||||||
number /= 1024
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"""RRPP Lite 的 Qt 界面层。
|
|
||||||
|
|
||||||
核心代理与 PAC 逻辑不得依赖本包,以便后续在无 GUI 环境中独立测试。
|
|
||||||
"""
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
|||||||
"""RRPP Lite 界面的共享色彩常量。"""
|
|
||||||
|
|
||||||
C_TITLE = "#8899aa"
|
|
||||||
C_GREEN = "#51cf66"
|
|
||||||
C_YELLOW = "#ffd43b"
|
|
||||||
C_RED = "#ff6b6b"
|
|
||||||
C_BG = "#ffffff"
|
|
||||||
C_CARD = "#f0f2f5"
|
|
||||||
C_BORDER = "#d0d5dd"
|
|
||||||
C_TEXT = "#333333"
|
|
||||||
C_SUBTEXT = "#889099"
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"""无业务状态的可复用 Qt 控件。"""
|
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, QRectF, pyqtSignal
|
|
||||||
from PyQt5.QtGui import QColor, QPainter
|
|
||||||
from PyQt5.QtWidgets import QCheckBox, QLabel
|
|
||||||
|
|
||||||
from .theme import C_BORDER, C_GREEN, C_RED, C_TITLE
|
|
||||||
|
|
||||||
|
|
||||||
_NL_BASE = f"color: {C_TITLE}; font-size: 12px;"
|
|
||||||
|
|
||||||
|
|
||||||
class NodeLabel(QLabel):
|
|
||||||
"""可点击标签:hover 显示下划线和手型光标,点击发出 clicked 信号。"""
|
|
||||||
|
|
||||||
clicked = pyqtSignal()
|
|
||||||
|
|
||||||
def __init__(self, text="", tooltip="点击切换节点", base=None, hover=None):
|
|
||||||
super().__init__(text)
|
|
||||||
self._base = base or _NL_BASE
|
|
||||||
self._hover = hover or self._base + " text-decoration: underline;"
|
|
||||||
self.setStyleSheet(self._base)
|
|
||||||
if tooltip:
|
|
||||||
self.setToolTip(tooltip)
|
|
||||||
|
|
||||||
def enterEvent(self, event):
|
|
||||||
self.setStyleSheet(self._hover)
|
|
||||||
self.setCursor(Qt.PointingHandCursor)
|
|
||||||
super().enterEvent(event)
|
|
||||||
|
|
||||||
def leaveEvent(self, event):
|
|
||||||
self.setStyleSheet(self._base)
|
|
||||||
self.unsetCursor()
|
|
||||||
super().leaveEvent(event)
|
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event):
|
|
||||||
if event.button() == Qt.LeftButton:
|
|
||||||
self.clicked.emit()
|
|
||||||
super().mouseReleaseEvent(event)
|
|
||||||
|
|
||||||
|
|
||||||
class ToggleSwitch(QCheckBox):
|
|
||||||
"""苹果风格开关:绿/灰胶囊加滑动圆钮,自绘且不依赖图片资源。"""
|
|
||||||
|
|
||||||
def __init__(self, checked=False, parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.setChecked(checked)
|
|
||||||
self.setFixedSize(42, 22)
|
|
||||||
self.setCursor(Qt.PointingHandCursor)
|
|
||||||
|
|
||||||
def paintEvent(self, event):
|
|
||||||
painter = QPainter(self)
|
|
||||||
painter.setRenderHint(QPainter.Antialiasing)
|
|
||||||
painter.setPen(Qt.NoPen)
|
|
||||||
painter.setBrush(QColor(C_GREEN) if self.isChecked() else QColor("#c8ccd4"))
|
|
||||||
painter.drawRoundedRect(QRectF(0, 0, 42, 22), 11, 11)
|
|
||||||
knob_x = 20.0 if self.isChecked() else 2.0
|
|
||||||
painter.setBrush(QColor("#ffffff"))
|
|
||||||
painter.drawEllipse(QRectF(knob_x, 2.0, 18.0, 18.0))
|
|
||||||
painter.end()
|
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
|
||||||
if event.button() == Qt.LeftButton:
|
|
||||||
self.setChecked(not self.isChecked())
|
|
||||||
self.repaint()
|
|
||||||
event.accept()
|
|
||||||
return
|
|
||||||
super().mousePressEvent(event)
|
|
||||||
|
|
||||||
|
|
||||||
class StatusDot(QLabel):
|
|
||||||
"""代理状态小圆点。"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.setFixedSize(14, 14)
|
|
||||||
self.set_off()
|
|
||||||
|
|
||||||
def set_on(self):
|
|
||||||
self.setStyleSheet(f"""
|
|
||||||
QLabel {{
|
|
||||||
background-color: {C_GREEN};
|
|
||||||
border-radius: 7px;
|
|
||||||
border: 1px solid rgba(81, 207, 102, 50);
|
|
||||||
}}
|
|
||||||
""")
|
|
||||||
|
|
||||||
def set_off(self):
|
|
||||||
self.setStyleSheet(f"""
|
|
||||||
QLabel {{
|
|
||||||
background-color: {C_RED};
|
|
||||||
border-radius: 7px;
|
|
||||||
border: 1px solid rgba(255, 107, 107, 50);
|
|
||||||
}}
|
|
||||||
""")
|
|
||||||
|
|
||||||
def set_disabled(self):
|
|
||||||
self.setStyleSheet(f"""
|
|
||||||
QLabel {{
|
|
||||||
background-color: {C_BORDER};
|
|
||||||
border-radius: 7px;
|
|
||||||
border: 1px solid rgba(136, 153, 170, 50);
|
|
||||||
}}
|
|
||||||
""")
|
|
||||||
+41
-447
@@ -1,457 +1,51 @@
|
|||||||
# RRPP 服务端部署规范
|
# server_config — 服务器端配置(已脱敏)
|
||||||
|
|
||||||
> 一套统一的跨境代理节点部署流程,适用于 Debian/Ubuntu 系 Linux。
|
来源:**jp 服务器**(`iam.aeolianchi.me`,Azure Japan East 学生订阅,Ubuntu 24.04)
|
||||||
> 每台节点运行相同的服务栈:**BBR 拥塞控制 + nginx CONNECT 隧道 + sing-box(VLESS+REALITY / Shadowsocks) + mtg(MTProto)**。
|
采集时间:2026-08-23
|
||||||
>
|
|
||||||
> 本文档为**通用规范**,所有 IP / 域名 / 端口 / 密钥均以**占位符**表示,部署时替换为实际值。
|
|
||||||
> 凡标注 `<...>` 的均为占位符,不要原样照抄。
|
|
||||||
|
|
||||||
---
|
> 本目录只收录**服务器端**配置;客户端(转发器 / PAC / WG)见仓库根目录的 `lite/` 与 `common/`。
|
||||||
|
|
||||||
## 目录
|
## 目录结构
|
||||||
|
|
||||||
- [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/
|
server_config/
|
||||||
├── README.md ← 本文档
|
├── nginx/ # jp nginx(HTTPS CONNECT 代理 + 明文 HTTP 转发 + 伪装站)
|
||||||
├── nginx/
|
│ ├── nginx.conf # 主配置(proxy_connect 模块 + /__proxy 转发 + 自代理环保护)
|
||||||
│ ├── nginx.conf ← 完整 nginx 配置(占位符版)
|
│ ├── conf.d/ # 附加站点
|
||||||
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
|
│ │ ├── api.aeolianchi.me.conf # 流量监控状态反代
|
||||||
│ └── conf.d/ ← 预留:额外站点配置
|
│ │ └── autodiscover.aeolianchi.me.conf # autodiscover 反代(→ bj)
|
||||||
|
│ └── local_ip_rules.conf.example # 本机公网 IP 直连规则(自动生成,示例)
|
||||||
├── sing-box/
|
├── sing-box/
|
||||||
│ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
|
│ └── config.json # VLESS + REALITY 入站(8443),密钥已脱敏
|
||||||
├── systemd/
|
├── traffic-monitor/ # Azure 门户口径流量监控(/home/agent)
|
||||||
│ ├── nginx.service
|
│ ├── config.json # SMTP 凭据已脱敏
|
||||||
│ ├── sing-box.service
|
│ ├── traffic_monitor.py # 监控脚本(vnstat + Azure Usage Details API)
|
||||||
│ └── mtg.service
|
│ └── traffic-monitor.service # systemd 单元
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ └── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
|
│ ├── sync-nginx-local-ips.sh # 每 5 分钟自动同步公网 IP 直连规则(cron)
|
||||||
├── cron/
|
│ └── rrpp-stats # vnstat 统计输出(每分钟写入 stats.json)
|
||||||
│ └── sync-nginx-local-ips ← cron 定义(每 5 分钟)
|
├── pac/ # 旧版 PAC(现客户端改为内存生成,仅存档)
|
||||||
└── fake-site/
|
│ ├── proxy.pac
|
||||||
└── index.html ← 伪装站点页面(所有节点共用同一份)
|
│ └── proxy_wg.pac
|
||||||
|
└── cron/
|
||||||
|
├── crontab.txt # RRRosmontis 用户 crontab
|
||||||
|
└── sync-nginx-local-ips # /etc/cron.d 条目
|
||||||
```
|
```
|
||||||
|
|
||||||
> 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。
|
## 脱敏说明
|
||||||
|
|
||||||
|
| 原文 | 替换为 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 公网 IP(`20.48.32.251` / `20.89.52.88`) | `<PUBLIC_IP>` | 服务器公网地址(DNS 可解析,但按惯例遮蔽) |
|
||||||
|
| VLESS UUID | `<VLESS_UUID>` | sing-box 入站用户 UUID |
|
||||||
|
| REALITY 私钥 | `<REALITY_PRIVATE_KEY>` | TLS Reality 私钥(机密) |
|
||||||
|
| REALITY short_id | `<REALITY_SHORT_ID>` | TLS Reality short id |
|
||||||
|
| SMTP 账号 / 密码 / 收发地址 | `<SMTP_USER>` `<SMTP_PASSWORD>` `<SMTP_FROM>` `<SMTP_TO>` | 告警邮件凭据 |
|
||||||
|
|
||||||
|
未收录:TLS 私钥/证书(仅保留 nginx 中的路径引用)、`/etc/nginx/.htpasswd`(认证哈希,用户名为 `user`)、Azure 订阅 ID 等。
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
1. **`common/jp_client.conf`(WireGuard 客户端配置)此前已入库且含真实密钥**(客户端私钥、对端公钥、Endpoint)。若本仓库非私有,建议**轮换 WG 密钥与 sing-box REALITY 密钥**。
|
||||||
|
2. `local_ip_rules.conf` 由 `sync-nginx-local-ips.sh` 每 5 分钟自动重写(cron),仓库内为脱敏示例;公网 IP 变更后无需手动改 nginx。
|
||||||
|
3. 明文 HTTP 代理路径要求 Basic 认证(`user`);无认证返回 401,不会成为开放代理。
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* * * * * /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
@@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 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,5 +1,4 @@
|
|||||||
# nginx 本机公网 IP 直连规则(自动生成,勿手改;由 sync-nginx-local-ips.sh 维护)
|
# nginx 本机公网 IP 直连规则(自动生成,勿手改;由 sync-nginx-local-ips.sh 维护)
|
||||||
# 公网 IP 变更后本文件会被重写并 reload nginx
|
# 公网 IP 变更后本文件会被重写并 reload nginx
|
||||||
# 每条规则对应一个本机公网 IP(出站 IP + 节点域名 A 记录)
|
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
|
||||||
if ($host = "<PUBLIC_IP_1>") { rewrite ^(.*)$ /__local$1 last; }
|
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
|
||||||
if ($host = "<PUBLIC_IP_2>") { rewrite ^(.*)$ /__local$1 last; }
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
load_module /usr/lib/nginx/modules/ngx_http_proxy_connect_module.so;
|
||||||
worker_processes auto;
|
worker_processes auto;
|
||||||
worker_rlimit_nofile 65535;
|
worker_rlimit_nofile 65535;
|
||||||
events {
|
events {
|
||||||
@@ -24,15 +25,46 @@ http {
|
|||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
keepalive_requests 100;
|
keepalive_requests 100;
|
||||||
|
|
||||||
# <nginx端口>:主域名 HTTPS 站点 + 认证 CONNECT 代理
|
# 80 端口:iam.aeolianchi.me 强制跳转到 HTTPS(保留 ACME 验证路径)
|
||||||
server {
|
server {
|
||||||
listen <nginx端口> ssl;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 443 端口:iam.aeolianchi.me HTTPS 站点 + 认证 CONNECT 代理
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
http2 on;
|
http2 on;
|
||||||
server_name <节点域名>;
|
server_name iam.aeolianchi.me;
|
||||||
root /var/www/html;
|
root /var/www/html;
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
|
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem;
|
||||||
ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
|
ssl_certificate_key /etc/nginx/ssl/iam.aeolianchi.me/privkey.pem;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
ssl_session_cache shared:SSL:10m;
|
ssl_session_cache shared:SSL:10m;
|
||||||
@@ -43,14 +75,14 @@ http {
|
|||||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||||
|
|
||||||
proxy_connect;
|
proxy_connect;
|
||||||
proxy_connect_allow all;
|
proxy_connect_allow 443 563;
|
||||||
proxy_connect_connect_timeout 60s;
|
proxy_connect_connect_timeout 60s;
|
||||||
proxy_connect_read_timeout 120s;
|
proxy_connect_read_timeout 120s;
|
||||||
proxy_connect_send_timeout 120s;
|
proxy_connect_send_timeout 120s;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
auth_basic off;
|
auth_basic off;
|
||||||
if ($host != "<节点域名>") {
|
if ($host != "iam.aeolianchi.me") {
|
||||||
rewrite ^ /__proxy last;
|
rewrite ^ /__proxy last;
|
||||||
}
|
}
|
||||||
index index.html;
|
index index.html;
|
||||||
@@ -73,14 +105,14 @@ http {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# <nginx端口> fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
|
# 443 端口 fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
|
||||||
server {
|
server {
|
||||||
listen <nginx端口> ssl default_server;
|
listen 443 ssl default_server;
|
||||||
http2 on;
|
http2 on;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
|
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem;
|
||||||
ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
|
ssl_certificate_key /etc/nginx/ssl/iam.aeolianchi.me/privkey.pem;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
ssl_session_cache shared:SSL:10m;
|
ssl_session_cache shared:SSL:10m;
|
||||||
@@ -91,7 +123,7 @@ http {
|
|||||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||||
|
|
||||||
proxy_connect;
|
proxy_connect;
|
||||||
proxy_connect_allow all;
|
proxy_connect_allow 443 563;
|
||||||
proxy_connect_connect_timeout 60s;
|
proxy_connect_connect_timeout 60s;
|
||||||
proxy_connect_read_timeout 120s;
|
proxy_connect_read_timeout 120s;
|
||||||
proxy_connect_send_timeout 120s;
|
proxy_connect_send_timeout 120s;
|
||||||
|
|||||||
@@ -0,0 +1,647 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,648 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# /usr/local/bin/rrpp-stats — returns traffic stats as JSON
|
||||||
|
|
||||||
|
vnstat --json 2>/dev/null || echo '{"error":"vnstat not available"}'
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# sync-nginx-local-ips.sh
|
# sync-nginx-local-ips.sh
|
||||||
# 自动维护 nginx 本机公网 IP 直连规则(/etc/nginx/local_ip_rules.conf):
|
# 自动维护 nginx 本机公网 IP 直连规则(/etc/nginx/local_ip_rules.conf):
|
||||||
# 出站公网 IP(ipify)+ <节点域名> 的 A 记录 = 本机当前公网 IP 集合。
|
# 出站公网 IP(ipify)+ iam.aeolianchi.me 的 A 记录 = 本机当前公网 IP 集合。
|
||||||
# 公网 IP 变更时重写规则文件并 reload nginx(仅变更时 reload)。
|
# 公网 IP 变更时重写规则文件并 reload nginx(仅变更时 reload)。
|
||||||
# 由 /etc/cron.d/sync-nginx-local-ips 每 5 分钟调用一次。
|
# 由 /etc/cron.d/sync-nginx-local-ips 每 5 分钟调用一次。
|
||||||
#
|
|
||||||
# 部署时把 DOMAIN 改为本节点域名。
|
|
||||||
set -u
|
set -u
|
||||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
RULES=/etc/nginx/local_ip_rules.conf
|
RULES=/etc/nginx/local_ip_rules.conf
|
||||||
DOMAIN=<节点域名>
|
DOMAIN=iam.aeolianchi.me
|
||||||
|
|
||||||
ips=""
|
ips=""
|
||||||
# 出站公网 IP
|
# 出站公网 IP
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"log": {
|
"log": {
|
||||||
"level": "info",
|
"level": "warn",
|
||||||
"timestamp": true
|
"timestamp": true
|
||||||
},
|
},
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"type": "vless",
|
"type": "vless",
|
||||||
"tag": "vless-reality-in",
|
"tag": "vless-reality-in",
|
||||||
"listen": "0.0.0.0",
|
"listen": "0.0.0.0",
|
||||||
"listen_port": "<vless端口>",
|
"listen_port": 8443,
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"uuid": "<VLESS_UUID>",
|
"uuid": "<VLESS_UUID>",
|
||||||
@@ -17,11 +17,11 @@
|
|||||||
],
|
],
|
||||||
"tls": {
|
"tls": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"server_name": "<伪装SNI域名>",
|
"server_name": "www.apple.com",
|
||||||
"reality": {
|
"reality": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"handshake": {
|
"handshake": {
|
||||||
"server": "<伪装SNI域名>",
|
"server": "www.apple.com",
|
||||||
"server_port": 443
|
"server_port": 443
|
||||||
},
|
},
|
||||||
"private_key": "<REALITY_PRIVATE_KEY>",
|
"private_key": "<REALITY_PRIVATE_KEY>",
|
||||||
@@ -30,14 +30,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": [
|
"outbounds": [
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[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
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
#!/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())
|
||||||
Reference in New Issue
Block a user