From 0df90374073ebd64952785097e0c0de38a51c5f6 Mon Sep 17 00:00:00 2001 From: RRRosmontis Date: Sun, 23 Aug 2026 01:50:35 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20server=5Fconfig=EF=BC=9Ajp?= =?UTF-8?q?=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=AB=AF=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=EF=BC=88nginx/sing-box/=E6=B5=81=E9=87=8F=E7=9B=91=E6=8E=A7/?= =?UTF-8?q?=E8=84=9A=E6=9C=AC/=E5=AD=98=E6=A1=A3=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=B7=B2=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server_config/README.md | 51 ++ server_config/cron/crontab.txt | 2 + server_config/cron/sync-nginx-local-ips | 1 + .../nginx/conf.d/api.aeolianchi.me.conf | 19 + .../conf.d/autodiscover.aeolianchi.me.conf | 44 ++ .../nginx/local_ip_rules.conf.example | 4 + server_config/nginx/nginx.conf | 179 +++++ server_config/pac/proxy.pac | 647 +++++++++++++++++ server_config/pac/proxy_wg.pac | 648 +++++++++++++++++ server_config/scripts/rrpp-stats | 4 + server_config/scripts/sync-nginx-local-ips.sh | 50 ++ server_config/sing-box/config.json | 41 ++ server_config/traffic-monitor/config.json | 41 ++ .../traffic-monitor/traffic-monitor.service | 15 + .../traffic-monitor/traffic_monitor.py | 666 ++++++++++++++++++ 15 files changed, 2412 insertions(+) create mode 100644 server_config/README.md create mode 100644 server_config/cron/crontab.txt create mode 100644 server_config/cron/sync-nginx-local-ips create mode 100644 server_config/nginx/conf.d/api.aeolianchi.me.conf create mode 100644 server_config/nginx/conf.d/autodiscover.aeolianchi.me.conf create mode 100644 server_config/nginx/local_ip_rules.conf.example create mode 100644 server_config/nginx/nginx.conf create mode 100644 server_config/pac/proxy.pac create mode 100644 server_config/pac/proxy_wg.pac create mode 100644 server_config/scripts/rrpp-stats create mode 100644 server_config/scripts/sync-nginx-local-ips.sh create mode 100644 server_config/sing-box/config.json create mode 100644 server_config/traffic-monitor/config.json create mode 100644 server_config/traffic-monitor/traffic-monitor.service create mode 100644 server_config/traffic-monitor/traffic_monitor.py diff --git a/server_config/README.md b/server_config/README.md new file mode 100644 index 0000000..1aabe11 --- /dev/null +++ b/server_config/README.md @@ -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`) | `` | 服务器公网地址(DNS 可解析,但按惯例遮蔽) | +| VLESS UUID | `` | sing-box 入站用户 UUID | +| REALITY 私钥 | `` | TLS Reality 私钥(机密) | +| REALITY short_id | `` | TLS Reality short id | +| SMTP 账号 / 密码 / 收发地址 | `` `` `` `` | 告警邮件凭据 | + +未收录: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,不会成为开放代理。 diff --git a/server_config/cron/crontab.txt b/server_config/cron/crontab.txt new file mode 100644 index 0000000..907aaf8 --- /dev/null +++ b/server_config/cron/crontab.txt @@ -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 diff --git a/server_config/cron/sync-nginx-local-ips b/server_config/cron/sync-nginx-local-ips new file mode 100644 index 0000000..553122b --- /dev/null +++ b/server_config/cron/sync-nginx-local-ips @@ -0,0 +1 @@ +*/5 * * * * root /usr/local/sbin/sync-nginx-local-ips.sh >/dev/null 2>&1 diff --git a/server_config/nginx/conf.d/api.aeolianchi.me.conf b/server_config/nginx/conf.d/api.aeolianchi.me.conf new file mode 100644 index 0000000..1388be5 --- /dev/null +++ b/server_config/nginx/conf.d/api.aeolianchi.me.conf @@ -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; + } +} diff --git a/server_config/nginx/conf.d/autodiscover.aeolianchi.me.conf b/server_config/nginx/conf.d/autodiscover.aeolianchi.me.conf new file mode 100644 index 0000000..7b48573 --- /dev/null +++ b/server_config/nginx/conf.d/autodiscover.aeolianchi.me.conf @@ -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; +} diff --git a/server_config/nginx/local_ip_rules.conf.example b/server_config/nginx/local_ip_rules.conf.example new file mode 100644 index 0000000..2676bd2 --- /dev/null +++ b/server_config/nginx/local_ip_rules.conf.example @@ -0,0 +1,4 @@ +# nginx 本机公网 IP 直连规则(自动生成,勿手改;由 sync-nginx-local-ips.sh 维护) +# 公网 IP 变更后本文件会被重写并 reload nginx +if ($host = "") { rewrite ^(.*)$ /__local$1 last; } +if ($host = "") { rewrite ^(.*)$ /__local$1 last; } diff --git a/server_config/nginx/nginx.conf b/server_config/nginx/nginx.conf new file mode 100644 index 0000000..d1a042d --- /dev/null +++ b/server_config/nginx/nginx.conf @@ -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; +} diff --git a/server_config/pac/proxy.pac b/server_config/pac/proxy.pac new file mode 100644 index 0000000..df26da4 --- /dev/null +++ b/server_config/pac/proxy.pac @@ -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"; +} diff --git a/server_config/pac/proxy_wg.pac b/server_config/pac/proxy_wg.pac new file mode 100644 index 0000000..70db7fa --- /dev/null +++ b/server_config/pac/proxy_wg.pac @@ -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"; +} + diff --git a/server_config/scripts/rrpp-stats b/server_config/scripts/rrpp-stats new file mode 100644 index 0000000..9f509ff --- /dev/null +++ b/server_config/scripts/rrpp-stats @@ -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"}' diff --git a/server_config/scripts/sync-nginx-local-ips.sh b/server_config/scripts/sync-nginx-local-ips.sh new file mode 100644 index 0000000..fab6c98 --- /dev/null +++ b/server_config/scripts/sync-nginx-local-ips.sh @@ -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 diff --git a/server_config/sing-box/config.json b/server_config/sing-box/config.json new file mode 100644 index 0000000..333232a --- /dev/null +++ b/server_config/sing-box/config.json @@ -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": "", + "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": "", + "short_id": [ + "" + ] + } + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ] +} diff --git a/server_config/traffic-monitor/config.json b/server_config/traffic-monitor/config.json new file mode 100644 index 0000000..9e944af --- /dev/null +++ b/server_config/traffic-monitor/config.json @@ -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": "", + "password": "", + "from_name": "Royirin Rosmontis Private Proxy JP 流量监控", + "from_addr": "", + "to_addr": "", + "starttls": false, + "skip_cert_verify": false, + "timeout_sec": 30, + "ssl": true + }, + "current_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 +} diff --git a/server_config/traffic-monitor/traffic-monitor.service b/server_config/traffic-monitor/traffic-monitor.service new file mode 100644 index 0000000..686be02 --- /dev/null +++ b/server_config/traffic-monitor/traffic-monitor.service @@ -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 diff --git a/server_config/traffic-monitor/traffic_monitor.py b/server_config/traffic-monitor/traffic_monitor.py new file mode 100644 index 0000000..d832ab4 --- /dev/null +++ b/server_config/traffic-monitor/traffic_monitor.py @@ -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 --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())