添加 server_config:jp 服务器端配置(nginx/sing-box/流量监控/脚本/存档),已脱敏

This commit is contained in:
RRRosmontis
2026-08-23 01:50:35 +08:00
parent 00a0af03e9
commit 0df9037407
15 changed files with 2412 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
# server_config — 服务器端配置(已脱敏)
来源:**jp 服务器**(`iam.aeolianchi.me`,Azure Japan East 学生订阅,Ubuntu 24.04)
采集时间:2026-08-23
> 本目录只收录**服务器端**配置;客户端(转发器 / PAC / WG)见仓库根目录的 `lite/` 与 `common/`。
## 目录结构
```
server_config/
├── nginx/ # jp nginx(HTTPS CONNECT 代理 + 明文 HTTP 转发 + 伪装站)
│ ├── nginx.conf # 主配置(proxy_connect 模块 + /__proxy 转发 + 自代理环保护)
│ ├── conf.d/ # 附加站点
│ │ ├── api.aeolianchi.me.conf # 流量监控状态反代
│ │ └── autodiscover.aeolianchi.me.conf # autodiscover 反代(→ bj)
│ └── local_ip_rules.conf.example # 本机公网 IP 直连规则(自动生成,示例)
├── sing-box/
│ └── config.json # VLESS + REALITY 入站(8443),密钥已脱敏
├── traffic-monitor/ # Azure 门户口径流量监控(/home/agent)
│ ├── config.json # SMTP 凭据已脱敏
│ ├── traffic_monitor.py # 监控脚本(vnstat + Azure Usage Details API)
│ └── traffic-monitor.service # systemd 单元
├── scripts/
│ ├── sync-nginx-local-ips.sh # 每 5 分钟自动同步公网 IP 直连规则(cron)
│ └── rrpp-stats # vnstat 统计输出(每分钟写入 stats.json)
├── pac/ # 旧版 PAC(现客户端改为内存生成,仅存档)
│ ├── proxy.pac
│ └── proxy_wg.pac
└── cron/
├── crontab.txt # RRRosmontis 用户 crontab
└── sync-nginx-local-ips # /etc/cron.d 条目
```
## 脱敏说明
| 原文 | 替换为 | 说明 |
|---|---|---|
| 公网 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,不会成为开放代理。
+2
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
*/5 * * * * root /usr/local/sbin/sync-nginx-local-ips.sh >/dev/null 2>&1
@@ -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;
}
@@ -0,0 +1,4 @@
# nginx 本机公网 IP 直连规则(自动生成,勿手改;由 sync-nginx-local-ips.sh 维护)
# 公网 IP 变更后本文件会被重写并 reload nginx
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
+179
View File
@@ -0,0 +1,179 @@
load_module /usr/lib/nginx/modules/ngx_http_proxy_connect_module.so;
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 8192;
multi_accept on;
}
http {
server_tokens off;
resolver 8.8.8.8 ipv6=off valid=300s;
resolver_timeout 10s;
# 默认站点分流:本机/私网直连返回伪装页;其余 Host(正向代理目标)走 /__proxy 转发
# 公网 IP 无需手改:cron(sync-nginx-local-ips.sh)自动写入 local_ip_rules.conf;
# 另有 /__proxy 自代理环保护兜底,即使公网 IP 变更也不会死循环。
map $host $is_local {
default 0;
"~^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)" 1;
}
include mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 100;
# 80 端口:iam.aeolianchi.me 强制跳转到 HTTPS(保留 ACME 验证路径)
server {
listen 80;
server_name iam.aeolianchi.me;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://$host$request_uri;
}
}
# 80 端口:默认站点只做静态伪装,不提供代理、不弹认证
server {
listen 80 default_server;
server_name _;
root /var/www/html;
index index.html;
location /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
try_files $uri $uri/ =404;
}
}
# 443 端口:iam.aeolianchi.me HTTPS 站点 + 认证 CONNECT 代理
server {
listen 443 ssl;
http2 on;
server_name iam.aeolianchi.me;
root /var/www/html;
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/iam.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;
# 仅 CONNECT/正向代理需要认证,普通页面不需要
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_connect;
proxy_connect_allow 443 563;
proxy_connect_connect_timeout 60s;
proxy_connect_read_timeout 120s;
proxy_connect_send_timeout 120s;
location / {
auth_basic off;
if ($host != "iam.aeolianchi.me") {
rewrite ^ /__proxy last;
}
index index.html;
try_files $uri $uri/ =404;
}
location /__proxy {
internal;
proxy_intercept_errors on;
error_page 502 503 504 =444 @drop;
resolver 8.8.8.8 ipv6=off valid=300s;
proxy_pass $scheme://$host$request_uri;
proxy_set_header Host $host;
proxy_ssl_name $host;
proxy_ssl_server_name on;
}
location @drop {
return 444;
}
}
# 443 端口 fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
server {
listen 443 ssl default_server;
http2 on;
server_name _;
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/iam.aeolianchi.me/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# 仅 CONNECT/正向代理需要认证,普通页面不需要
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_connect;
proxy_connect_allow 443 563;
proxy_connect_connect_timeout 60s;
proxy_connect_read_timeout 120s;
proxy_connect_send_timeout 120s;
location / {
auth_basic off;
# 自动判定本机:连接本地地址 / 无 Host / 私网回环 / cron 维护的公网 IP 规则
# 命中则 rewrite 到 /__local<原路径>(保留 $uri),其余走 /__proxy 转发
if ($host = $server_addr) { rewrite ^(.*)$ /__local$1 last; }
if ($host = "_") { rewrite ^(.*)$ /__local$1 last; }
if ($is_local) { rewrite ^(.*)$ /__local$1 last; }
include /etc/nginx/local_ip_rules.conf;
rewrite ^ /__proxy last;
}
# 本机 IP/域名直连:剥掉前缀后按原路径服务伪装页(保持原有行为)
location /__local {
internal;
auth_basic off;
rewrite ^/__local(.*)$ $1 break;
root /var/www/html;
index index.html;
try_files $uri $uri/ =404;
}
# 明文 HTTP 正向代理:绝对形式请求(Host=目标域名)在此转发
location /__proxy {
internal;
# 自代理环保护:转发请求带标记头,二次进入说明代理目标指向本机(未知/新公网 IP),
# 返回 508 Loop Detected 而非死循环
if ($http_x_proxy_loop_guard = "1") {
return 508;
}
proxy_set_header X-Proxy-Loop-Guard "1";
resolver 8.8.8.8 ipv6=off valid=300s;
proxy_pass http://$http_host$request_uri;
proxy_set_header Host $http_host;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 10s;
proxy_read_timeout 120s;
proxy_intercept_errors on;
# 目标不可达/无法解析:直接断开连接(444),不返回任何页面
error_page 502 503 504 =444 @drop;
}
location @drop {
return 444;
}
}
include /etc/nginx/conf.d/*.conf;
}
+647
View File
@@ -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";
}
+648
View File
@@ -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";
}
+4
View File
@@ -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"}'
@@ -0,0 +1,50 @@
#!/bin/bash
# sync-nginx-local-ips.sh
# 自动维护 nginx 本机公网 IP 直连规则(/etc/nginx/local_ip_rules.conf):
# 出站公网 IP(ipify)+ iam.aeolianchi.me 的 A 记录 = 本机当前公网 IP 集合。
# 公网 IP 变更时重写规则文件并 reload nginx(仅变更时 reload)。
# 由 /etc/cron.d/sync-nginx-local-ips 每 5 分钟调用一次。
set -u
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RULES=/etc/nginx/local_ip_rules.conf
DOMAIN=iam.aeolianchi.me
ips=""
# 出站公网 IP
out=$(curl -s --max-time 8 https://api.ipify.org 2>/dev/null | tr -dc '0-9.')
# 域名 A 记录(入站公网 IP)
dns=$(getent ahostsv4 "$DOMAIN" 2>/dev/null | awk 'NR==1{print $1}')
for ip in "$out" "$dns"; do
case "$ip" in
''|*[!0-9.]*) continue ;;
esac
case "|$ips|" in
*"|$ip|"*) ;;
*) ips="${ips}|${ip}" ;;
esac
done
tmp="$RULES.tmp"
{
echo "# nginx 本机公网 IP 直连规则(自动生成,勿手改;由 sync-nginx-local-ips.sh 维护)"
echo "# 公网 IP 变更后本文件会被重写并 reload nginx"
IFS='|'
for ip in $ips; do
[ -n "$ip" ] && echo "if (\$host = \"$ip\") { rewrite ^(.*)\$ /__local\$1 last; }"
done
unset IFS
} > "$tmp"
if ! cmp -s "$RULES" "$tmp"; then
mv "$tmp" "$RULES"
if nginx -t >/dev/null 2>&1; then
systemctl reload nginx
logger -t sync-nginx-local-ips "nginx local-IP rules updated: [${ips#|}]"
else
logger -t sync-nginx-local-ips "nginx -t FAILED, rules NOT applied; keeping previous rules"
rm -f "$tmp"
fi
else
rm -f "$tmp"
fi
+41
View File
@@ -0,0 +1,41 @@
{
"log": {
"level": "warn",
"timestamp": true
},
"inbounds": [
{
"type": "vless",
"tag": "vless-reality-in",
"listen": "0.0.0.0",
"listen_port": 8443,
"users": [
{
"uuid": "<VLESS_UUID>",
"flow": "xtls-rprx-vision"
}
],
"tls": {
"enabled": true,
"server_name": "www.apple.com",
"reality": {
"enabled": true,
"handshake": {
"server": "www.apple.com",
"server_port": 443
},
"private_key": "<REALITY_PRIVATE_KEY>",
"short_id": [
"<REALITY_SHORT_ID>"
]
}
}
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct"
}
]
}
+41
View File
@@ -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())