Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be02d1b17f | ||
|
|
53c36e143f | ||
|
|
23ba368737 | ||
|
|
cb3552f595 | ||
|
|
2a20215d9e | ||
|
|
c3286593a6 | ||
|
|
9e766d8132 | ||
|
|
259a1ae41f | ||
|
|
bcb113e8c9 | ||
|
|
6d204c77fb | ||
|
|
63837eb9d8 | ||
|
|
efa7e25f66 | ||
|
|
20d2532e8f |
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -3,9 +3,13 @@
|
|||||||
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
|
> common 版本现在有许多未修复的 bug (如部分 UI 的显示问题) 、令人难以忍受的代码逻辑 (如 PAC 规则直接写在代码里)和安全性问题 (认证凭据的暴露与证书忽略),而 Lite 版本修复了这些问题,并会收到持续的更新
|
||||||
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows .exe
|
- 本程序完全没有 Linux 与 CLI 功能兼容,默认构建为带有图形界面的 Windows .exe
|
||||||
|
|
||||||
|
|
||||||
## 如何区分 common 版本与 Lite 版本
|
## 如何区分 common 版本与 Lite 版本
|
||||||
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳
|
- Lite 版本的版本号前四位固定为 9999,后续为从月份开始的构建时间戳,而 common 版本的版本号为包含年份的完整的构建时间戳
|
||||||
|
|
||||||
## 服务器侧配置手册 (部分内容过时)
|
## 服务器侧配置手册 (部分内容过时)
|
||||||
https://rrrosmontis.icu/articles/index.html?id=00005
|
- https://rrrosmontis.icu/articles/index.html?id=00005
|
||||||
|
- 更推荐参考本仓库 server_config 文件夹中的 md 文件手册
|
||||||
|
|
||||||
|
## 若要直接使用仓库客户端对应的服务
|
||||||
|
请前往 https://sso.rrrosmontis.icu 注册 Rosmanka ID,然后与管理员商定使用细则
|
||||||
|
royirin@rrrosmontis.icu
|
||||||
+461
-77
@@ -102,6 +102,7 @@ def _load_nodes():
|
|||||||
|
|
||||||
_SELECTED_NODE = 0 # 当前选中节点下标(默认第一条)
|
_SELECTED_NODE = 0 # 当前选中节点下标(默认第一条)
|
||||||
_SELECTED_RESTORED = False # 启动时是否已按设置恢复过上次选中的节点
|
_SELECTED_RESTORED = False # 启动时是否已按设置恢复过上次选中的节点
|
||||||
|
_NODES_SYNC_DONE = threading.Event() # 订阅同步线程完成信号(启动校验等它)
|
||||||
|
|
||||||
def _current_node():
|
def _current_node():
|
||||||
"""当前使用的节点:默认取列表第一个;无节点时返回 None。"""
|
"""当前使用的节点:默认取列表第一个;无节点时返回 None。"""
|
||||||
@@ -146,12 +147,173 @@ def _save_current_node(domain):
|
|||||||
data["current_node"] = str(domain or "")
|
data["current_node"] = str(domain or "")
|
||||||
_settings_save(data)
|
_settings_save(data)
|
||||||
|
|
||||||
|
# ── IPv6-only 节点可达性(客户端标注用)─────────────────────
|
||||||
|
# IPv6-only 节点的域名只有 AAAA 记录(无 A 记录),本机没有 IPv6 出口时连不上。
|
||||||
|
# 判定三级:ipv6_only 字段 → 服务端 remark 里的 "(IPv6)" 标记 → 域名解析兜底。
|
||||||
|
# 服务端 subscription?method=rrpp_client 只吐旧扁平格式,ipv6_only 字段被丢掉,
|
||||||
|
# 只留 remark 里的 "(IPv6)"(见后端 countries_to_rrpp_client),故第 2 级是现网主路径。
|
||||||
|
|
||||||
|
_IPV6_OK = {} # domain -> 本机能否连上该节点的 IPv6 地址
|
||||||
|
_IPV6_ONLY = {} # domain -> 该域名是否只有 IPv6 地址(解析兜底)
|
||||||
|
_IPV6_PROBE_TIMEOUT = 4 # 单次 TCP 探测超时(秒)
|
||||||
|
|
||||||
|
def _local_has_ipv6():
|
||||||
|
"""本机是否有全局 IPv6 地址(排除回环/链路本地/唯一本地地址)。
|
||||||
|
没有则不可能连上 IPv6-only 节点,直接短路,省去探测。"""
|
||||||
|
try:
|
||||||
|
import socket
|
||||||
|
import ipaddress
|
||||||
|
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET6):
|
||||||
|
addr = info[4][0].split("%")[0] # 去掉 zone id(fe80::1%eth0)
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(addr)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if ip.is_loopback or ip.is_link_local or ip.is_private:
|
||||||
|
continue
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _domain_ipv6_only(domain: str) -> bool:
|
||||||
|
"""域名是否「只有 IPv6 地址」:解析结果里有 IPv6 且没有 IPv4 才算。
|
||||||
|
解析失败按 False(当普通节点处理,不误伤)。结果缓存。"""
|
||||||
|
if domain in _IPV6_ONLY:
|
||||||
|
return _IPV6_ONLY[domain]
|
||||||
|
import ipaddress
|
||||||
|
only = False
|
||||||
|
try:
|
||||||
|
addrs = _resolve_host_ips(domain)
|
||||||
|
if addrs:
|
||||||
|
has6 = any(ip.version == 6 for ip in addrs)
|
||||||
|
has4 = any(ip.version == 4 for ip in addrs)
|
||||||
|
only = has6 and not has4
|
||||||
|
except Exception:
|
||||||
|
only = False
|
||||||
|
_IPV6_ONLY[domain] = only
|
||||||
|
if only:
|
||||||
|
log(f"节点 {domain} 解析结果只有 IPv6 地址,按 IPv6 专用节点处理")
|
||||||
|
return only
|
||||||
|
|
||||||
|
def _node_ipv6_only(node) -> bool:
|
||||||
|
"""节点是否 IPv6 专用。
|
||||||
|
|
||||||
|
三级判定:
|
||||||
|
1. ipv6_only 字段(服务端新格式;rrpp_client 输出里没有)
|
||||||
|
2. remark 里的 "(IPv6)" 标记(rrpp_client 现网唯一的权威线索)
|
||||||
|
3. 域名解析兜底:解析结果只有 IPv6 地址、没有 IPv4
|
||||||
|
"""
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return False
|
||||||
|
if node.get("ipv6_only"):
|
||||||
|
return True
|
||||||
|
if "ipv6" in str(node.get("remark") or "").lower():
|
||||||
|
return True
|
||||||
|
return _domain_ipv6_only(str(node.get("domain") or "").strip())
|
||||||
|
|
||||||
|
def _node_http_port(node) -> int:
|
||||||
|
"""节点的 HTTP 代理端口(不依赖当前选中节点,供可达性探测用)。"""
|
||||||
|
m = (node.get("methods") or {}).get("http") or {}
|
||||||
|
for v in (m.get("port"), node.get("port")):
|
||||||
|
if v not in (None, ""):
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return 443
|
||||||
|
|
||||||
|
def _node_reachable(node):
|
||||||
|
"""节点在本机是否可达:普通节点恒为 True;IPv6 专用节点需真的能建立 IPv6 连接
|
||||||
|
(按该节点自己的 HTTP 端口探测)。结果按域名缓存(探测一次)。"""
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return True
|
||||||
|
domain = str(node.get("domain") or "").strip()
|
||||||
|
if not _node_ipv6_only(node):
|
||||||
|
return True
|
||||||
|
if not domain:
|
||||||
|
return False
|
||||||
|
if domain in _IPV6_OK:
|
||||||
|
return _IPV6_OK[domain]
|
||||||
|
port = _node_http_port(node)
|
||||||
|
ok = False
|
||||||
|
addrs = sorted((ip for ip in _resolve_host_ips(domain) if ip.version == 6), key=str)
|
||||||
|
if addrs and _local_has_ipv6():
|
||||||
|
import socket
|
||||||
|
for ip in addrs:
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(_IPV6_PROBE_TIMEOUT)
|
||||||
|
s.connect((str(ip), port))
|
||||||
|
s.close()
|
||||||
|
ok = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_IPV6_OK[domain] = ok
|
||||||
|
return ok
|
||||||
|
|
||||||
def _reload_nodes():
|
def _reload_nodes():
|
||||||
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
|
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
|
||||||
global _NODES
|
global _NODES
|
||||||
_NODES = None
|
_NODES = None
|
||||||
|
_IPV6_OK.clear() # 节点列表变了,IPv6 可达性/专用判定缓存作废
|
||||||
|
_IPV6_ONLY.clear()
|
||||||
return _load_nodes()
|
return _load_nodes()
|
||||||
|
|
||||||
|
def _first_reachable_index(nodes):
|
||||||
|
"""第一个本机可达的节点下标;都不可达返回 0。供选中不可达节点时回退。"""
|
||||||
|
for i, n in enumerate(nodes):
|
||||||
|
if _node_reachable(n):
|
||||||
|
return i
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _rematch_saved_domain():
|
||||||
|
"""按 settings 的 current_node(域名)在最新节点列表里重匹配下标。
|
||||||
|
|
||||||
|
远程同步后服务端可能调整节点顺序:只按下标会静默连到别的节点。
|
||||||
|
返回 True 表示发生了切换。"""
|
||||||
|
global _SELECTED_NODE
|
||||||
|
nodes = _load_nodes()
|
||||||
|
if not nodes:
|
||||||
|
return False
|
||||||
|
saved = str(_settings_load().get("current_node") or "")
|
||||||
|
cur = nodes[_SELECTED_NODE] if 0 <= _SELECTED_NODE < len(nodes) else None
|
||||||
|
if saved and (not cur or str(cur.get("domain") or "") != saved):
|
||||||
|
match = next((i for i, n in enumerate(nodes)
|
||||||
|
if str(n.get("domain") or "") == saved), None)
|
||||||
|
if match is not None:
|
||||||
|
_SELECTED_NODE = match
|
||||||
|
log(f"节点列表已更新,继续使用上次选择的节点 {saved}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _verify_selected_node():
|
||||||
|
"""启动校验,返回 True 表示发生了切换(调用方据此刷新标题栏)。
|
||||||
|
|
||||||
|
两步:① 按设置记录的域名在最新节点列表里重匹配(服务端调过顺序时
|
||||||
|
下标会连到别的节点);② 选中节点在本机不可达(典型:上次选了 IPv6
|
||||||
|
专用节点,这次本机没有 IPv6 出口)时自动回退到第一个可达节点。"""
|
||||||
|
global _SELECTED_NODE
|
||||||
|
nodes = _load_nodes()
|
||||||
|
switched = _rematch_saved_domain()
|
||||||
|
if not nodes or not (0 <= _SELECTED_NODE < len(nodes)):
|
||||||
|
return switched
|
||||||
|
cur = nodes[_SELECTED_NODE]
|
||||||
|
if _node_reachable(cur):
|
||||||
|
return switched
|
||||||
|
idx = _first_reachable_index(nodes)
|
||||||
|
old = str(cur.get("domain") or "")
|
||||||
|
new = str(nodes[idx].get("domain") or "")
|
||||||
|
_SELECTED_NODE = idx
|
||||||
|
_save_current_node(new)
|
||||||
|
if new and new != old:
|
||||||
|
log_red(f"节点 {old} 在本机不可用,已自动切换到 {new}")
|
||||||
|
return True
|
||||||
|
|
||||||
def _node_val(key, default=""):
|
def _node_val(key, default=""):
|
||||||
node = _current_node()
|
node = _current_node()
|
||||||
if not node:
|
if not node:
|
||||||
@@ -162,18 +324,39 @@ def _node_val(key, default=""):
|
|||||||
def _endpoint_domain():
|
def _endpoint_domain():
|
||||||
return _node_val("domain", "???")
|
return _node_val("domain", "???")
|
||||||
|
|
||||||
|
def _http_method():
|
||||||
|
"""节点 HTTP 代理方法参数(新版 nodes.json 的 methods.http)。
|
||||||
|
|
||||||
|
返回 (node, get):get(key, default) 优先取 methods.http 下的键,
|
||||||
|
缺失时回退节点顶层——兼容旧扁平结构(port/username/password 直接挂在节点上)。
|
||||||
|
"""
|
||||||
|
node = _current_node() or {}
|
||||||
|
m = (node.get("methods") or {}).get("http") or {}
|
||||||
|
|
||||||
|
def get(key, default=""):
|
||||||
|
v = m.get(key)
|
||||||
|
if v in (None, ""):
|
||||||
|
v = node.get(key)
|
||||||
|
return default if v in (None, "") else v
|
||||||
|
|
||||||
|
return node, get
|
||||||
|
|
||||||
def _endpoint_port():
|
def _endpoint_port():
|
||||||
return int(_node_val("port", 443))
|
"""HTTP 代理端口:methods.http.port(新格式)或节点顶层 port(旧格式)。"""
|
||||||
|
_, get = _http_method()
|
||||||
|
try:
|
||||||
|
return int(get("port", 443))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 443
|
||||||
|
|
||||||
def _endpoint_country():
|
def _endpoint_country():
|
||||||
return _node_val("country", "")
|
return _node_val("country", "")
|
||||||
|
|
||||||
def _proxy_auth_basic():
|
def _proxy_auth_basic():
|
||||||
"""与 nginx 正向代理使用同一套 Basic 认证凭证(取自 nodes.json)。"""
|
"""正向代理 Basic 认证凭证:methods.http.{username,password}(新格式)
|
||||||
node = _current_node()
|
或节点顶层同名字段(旧格式);用于转发器向上游代理注入 Authorization。"""
|
||||||
if not node:
|
_, get = _http_method()
|
||||||
return ""
|
return base64.b64encode(f"{get('username')}:{get('password')}".encode()).decode()
|
||||||
return base64.b64encode(f"{node.get('username', '')}:{node.get('password', '')}".encode()).decode()
|
|
||||||
|
|
||||||
_tls_forwarder = None
|
_tls_forwarder = None
|
||||||
_pac_server = None
|
_pac_server = None
|
||||||
@@ -319,8 +502,76 @@ def _notify_proxy_change():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _reg_query_value(name):
|
||||||
|
"""查询 HKCU Internet Settings 下指定值,返回 (类型, 数据);值不存在返回 None。"""
|
||||||
|
r = _run(["reg", "query", PROXY_REG, "/v", name], warn=False)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return None
|
||||||
|
for line in (r.stdout or "").splitlines():
|
||||||
|
if name in line:
|
||||||
|
parts = line.strip().split(None, 2) # 形如 "ProxyServer REG_SZ 127.0.0.1:7897"
|
||||||
|
if len(parts) >= 3:
|
||||||
|
return parts[1], parts[2]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _is_our_pac_url(value: str) -> bool:
|
||||||
|
"""value 是否指向本程序的本地 PAC 服务(http://127.0.0.1:<端口>/proxy.pac)。"""
|
||||||
|
import re
|
||||||
|
return bool(re.fullmatch(r"http://127\.0\.0\.1:\d+/proxy\.pac", value.strip()))
|
||||||
|
|
||||||
|
_PROXY_BACKUP_KEY = "prev_proxy_state" # settings.json 里的系统代理原值备份
|
||||||
|
_proxy_prev = None # 运行期备份:None = 尚未备份
|
||||||
|
|
||||||
|
def _backup_proxy_state():
|
||||||
|
"""覆盖系统代理前把原值备份下来(内存 + settings.json,防崩溃丢失)。
|
||||||
|
|
||||||
|
只在尚未备份时执行:本次运行里第一次开启代理时的注册表状态才是用户
|
||||||
|
原有的(可能是其他代理软件的值),之后反复开/关不应覆盖首次备份。"""
|
||||||
|
global _proxy_prev
|
||||||
|
if _proxy_prev is not None:
|
||||||
|
return
|
||||||
|
state = {}
|
||||||
|
for name in ("ProxyEnable", "ProxyServer", "ProxyOverride", "AutoConfigURL"):
|
||||||
|
v = _reg_query_value(name)
|
||||||
|
state[name] = list(v) if v else None
|
||||||
|
_proxy_prev = state
|
||||||
|
try:
|
||||||
|
_settings_save({**_settings_load(), _PROXY_BACKUP_KEY: state})
|
||||||
|
except Exception:
|
||||||
|
pass # 持久化失败只影响崩溃后的恢复,不影响本次运行
|
||||||
|
|
||||||
|
def _restore_proxy_state():
|
||||||
|
"""恢复开启代理前的系统代理原值;没有备份时退化为清空(历史行为)。"""
|
||||||
|
global _proxy_prev
|
||||||
|
state = _proxy_prev
|
||||||
|
if state is None:
|
||||||
|
state = _settings_load().get(_PROXY_BACKUP_KEY)
|
||||||
|
if isinstance(state, dict):
|
||||||
|
for name in ("ProxyEnable", "ProxyServer", "ProxyOverride", "AutoConfigURL"):
|
||||||
|
v = state.get(name)
|
||||||
|
if v:
|
||||||
|
_run(["reg", "add", PROXY_REG, "/v", name, "/t", v[0], "/d", v[1], "/f"], warn=False)
|
||||||
|
else:
|
||||||
|
_run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
|
||||||
|
log("已恢复开启代理前的系统代理设置")
|
||||||
|
else:
|
||||||
|
# 无备份(旧版本升级后首次运行):清空,与历史行为一致
|
||||||
|
for name in ("ProxyServer", "ProxyOverride", "AutoConfigURL"):
|
||||||
|
_run(["reg", "delete", PROXY_REG, "/v", name, "/f"], warn=False)
|
||||||
|
_run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"])
|
||||||
|
_proxy_prev = None
|
||||||
|
# 备份已消费:清除持久化副本,避免下次启动误恢复
|
||||||
|
try:
|
||||||
|
data = _settings_load()
|
||||||
|
if isinstance(data, dict) and _PROXY_BACKUP_KEY in data:
|
||||||
|
del data[_PROXY_BACKUP_KEY]
|
||||||
|
_settings_save(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def proxy_on(pac_url=None, bypass_lan=False):
|
def proxy_on(pac_url=None, bypass_lan=False):
|
||||||
"""设置 Windows 系统代理。pac_url 不为空时启用 PAC 模式,否则使用固定代理。"""
|
"""设置 Windows 系统代理。pac_url 不为空时启用 PAC 模式,否则使用固定代理。"""
|
||||||
|
_backup_proxy_state() # 覆盖前保存用户原有代理设置,关闭时恢复而不是清空
|
||||||
if pac_url:
|
if pac_url:
|
||||||
# PAC 模式:仅设置 AutoConfigURL,ProxyEnable 置 0,
|
# PAC 模式:仅设置 AutoConfigURL,ProxyEnable 置 0,
|
||||||
# 避免程序读到"固定代理已启用但服务器为空"的混乱状态
|
# 避免程序读到"固定代理已启用但服务器为空"的混乱状态
|
||||||
@@ -341,11 +592,7 @@ def proxy_on(pac_url=None, bypass_lan=False):
|
|||||||
def proxy_off():
|
def proxy_off():
|
||||||
"""关闭系统代理并停止 TLS 转发器 / PAC 本地服务。"""
|
"""关闭系统代理并停止 TLS 转发器 / PAC 本地服务。"""
|
||||||
log("正在关闭系统代理...")
|
log("正在关闭系统代理...")
|
||||||
_run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"])
|
_restore_proxy_state() # 恢复用户原有代理设置,而不是简单清空
|
||||||
# 清除 ProxyServer / ProxyOverride / AutoConfigURL,确保两种模式都清理干净
|
|
||||||
_run(["reg", "delete", PROXY_REG, "/v", "ProxyServer", "/f"], warn=False)
|
|
||||||
_run(["reg", "delete", PROXY_REG, "/v", "ProxyOverride", "/f"], warn=False)
|
|
||||||
_run(["reg", "delete", PROXY_REG, "/v", "AutoConfigURL", "/f"], warn=False)
|
|
||||||
log("系统代理已关闭")
|
log("系统代理已关闭")
|
||||||
_notify_proxy_change()
|
_notify_proxy_change()
|
||||||
_tls_stop()
|
_tls_stop()
|
||||||
@@ -468,17 +715,28 @@ def _inject_proxy_auth(data: bytes) -> bytes:
|
|||||||
|
|
||||||
nginx 的 auth_basic 校验 Authorization 而非 Proxy-Authorization,
|
nginx 的 auth_basic 校验 Authorization 而非 Proxy-Authorization,
|
||||||
因此转发器向上游发送 Basic 认证时使用 Authorization 头。
|
因此转发器向上游发送 Basic 认证时使用 Authorization 头。
|
||||||
|
只按完整头名精确匹配(行首到冒号):Proxy-Authorization 等含
|
||||||
|
"authorization" 字样的行不算已有认证,不能阻止注入;目标站自己的
|
||||||
|
Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行,
|
||||||
|
保证上游(取首个 Authorization)读到的是我们的认证。
|
||||||
"""
|
"""
|
||||||
if not _starts_with_method(data):
|
if not _starts_with_method(data):
|
||||||
return data
|
return data
|
||||||
end = data.find(b"\r\n\r\n")
|
end = data.find(b"\r\n\r\n")
|
||||||
if end == -1:
|
if end == -1:
|
||||||
return data
|
return data
|
||||||
headers_lower = data[:end].lower()
|
head = data[:end]
|
||||||
if b"authorization:" in headers_lower:
|
first_end = head.find(b"\r\n")
|
||||||
return data
|
request_line = head if first_end == -1 else head[:first_end]
|
||||||
|
headers = b"" if first_end == -1 else head[first_end + 2:]
|
||||||
|
for line in headers.split(b"\r\n"):
|
||||||
|
if b":" not in line:
|
||||||
|
continue
|
||||||
|
name = line[:line.index(b":")].strip().lower()
|
||||||
|
if name == b"authorization":
|
||||||
|
return data # 已有目标站自己的 Authorization,不再注入
|
||||||
auth_line = f"Authorization: Basic {_proxy_auth_basic()}\r\n".encode()
|
auth_line = f"Authorization: Basic {_proxy_auth_basic()}\r\n".encode()
|
||||||
return data[:end + 2] + auth_line + data[end + 2:]
|
return request_line + b"\r\n" + auth_line + headers + data[end:]
|
||||||
|
|
||||||
def _make_request_injector():
|
def _make_request_injector():
|
||||||
"""为明文 HTTP 代理连接生成逐条注入认证的流式处理器。
|
"""为明文 HTTP 代理连接生成逐条注入认证的流式处理器。
|
||||||
@@ -501,7 +759,43 @@ def _make_request_injector():
|
|||||||
return b""
|
return b""
|
||||||
return feed
|
return feed
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=256)
|
def _ttl_cache(seconds: int, maxsize: int = 16384):
|
||||||
|
"""带过期时间的缓存装饰器(替代无 TTL 的 lru_cache)。
|
||||||
|
|
||||||
|
域名分流判定结果长期缓存会让域名换 IP 后继续沿用旧的直连/隧道判断;
|
||||||
|
到期自动失效重查。保留 cache_clear() 兼容(测试脚本在用)。"""
|
||||||
|
def deco(func):
|
||||||
|
store = {}
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
key = (args, tuple(sorted(kwargs.items())))
|
||||||
|
now = time.time()
|
||||||
|
with lock:
|
||||||
|
hit = store.get(key)
|
||||||
|
if hit and now - hit[0] < seconds:
|
||||||
|
return hit[1]
|
||||||
|
val = func(*args, **kwargs)
|
||||||
|
with lock:
|
||||||
|
store[key] = (now, val)
|
||||||
|
if len(store) > maxsize:
|
||||||
|
cutoff = now - seconds
|
||||||
|
for k, (t, _) in list(store.items()):
|
||||||
|
if t < cutoff:
|
||||||
|
del store[k]
|
||||||
|
if len(store) > maxsize:
|
||||||
|
store.clear() # 极端情况下全清:最坏只是多几次解析
|
||||||
|
return val
|
||||||
|
|
||||||
|
def cache_clear():
|
||||||
|
with lock:
|
||||||
|
store.clear()
|
||||||
|
|
||||||
|
wrapper.cache_clear = cache_clear
|
||||||
|
return wrapper
|
||||||
|
return deco
|
||||||
|
|
||||||
|
@_ttl_cache(300, maxsize=2048)
|
||||||
def _is_private_ip(host: str) -> bool:
|
def _is_private_ip(host: str) -> bool:
|
||||||
"""判断目标是否命中常用内网/回环 IPv4;域名会解析后判断。"""
|
"""判断目标是否命中常用内网/回环 IPv4;域名会解析后判断。"""
|
||||||
try:
|
try:
|
||||||
@@ -869,31 +1163,34 @@ def _auth_load():
|
|||||||
|
|
||||||
def _nodes_sync_remote():
|
def _nodes_sync_remote():
|
||||||
"""启动时用已保存凭据拉取最新节点配置(内容比对,变化才覆盖)。"""
|
"""启动时用已保存凭据拉取最新节点配置(内容比对,变化才覆盖)。"""
|
||||||
cred = _auth_load()
|
|
||||||
if not cred:
|
|
||||||
return # 从未登录过,等登录框处理
|
|
||||||
url = str(_config_val("nodes_url", "") or "").strip()
|
|
||||||
if not url:
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
import json
|
cred = _auth_load()
|
||||||
data = _fetch_nodes_auth(url, cred["username"], cred["password"])
|
if not cred:
|
||||||
if not (isinstance(data, list) and data
|
return # 从未登录过,等登录框处理
|
||||||
and all(isinstance(n, dict) for n in data)
|
url = str(_config_val("nodes_url", "") or "").strip()
|
||||||
and str(data[0].get("domain") or "").strip()):
|
if not url:
|
||||||
raise ValueError("返回的节点数据无效")
|
|
||||||
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
||||||
local = _USER_DIR / "nodes.json"
|
|
||||||
if local.is_file() and _nodes_decrypt(local.read_bytes()) == text:
|
|
||||||
log("节点配置已是最新")
|
|
||||||
return
|
return
|
||||||
_USER_DIR.mkdir(parents=True, exist_ok=True)
|
try:
|
||||||
local.write_bytes(_nodes_encrypt(text))
|
import json
|
||||||
global _NODES
|
data = _fetch_nodes_auth(url, cred["username"], cred["password"])
|
||||||
_NODES = None # 使后续读取(节点菜单/开启代理)拿到新配置
|
if not (isinstance(data, list) and data
|
||||||
log(f"节点配置已更新: {len(data)} 个节点 (nodes.json)")
|
and all(isinstance(n, dict) for n in data)
|
||||||
except Exception as e:
|
and str(data[0].get("domain") or "").strip()):
|
||||||
log(f"节点配置在线更新失败,使用本地配置: {e}")
|
raise ValueError("返回的节点数据无效")
|
||||||
|
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
||||||
|
local = _USER_DIR / "nodes.json"
|
||||||
|
if local.is_file() and _nodes_decrypt(local.read_bytes()) == text:
|
||||||
|
log("节点配置已是最新")
|
||||||
|
return
|
||||||
|
_USER_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
local.write_bytes(_nodes_encrypt(text))
|
||||||
|
global _NODES
|
||||||
|
_NODES = None # 使后续读取(节点菜单/开启代理)拿到新配置
|
||||||
|
log(f"节点配置已更新: {len(data)} 个节点 (nodes.json)")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"节点配置在线更新失败,使用本地配置: {e}")
|
||||||
|
finally:
|
||||||
|
_NODES_SYNC_DONE.set() # 无论成败都要放行启动校验
|
||||||
|
|
||||||
class _AuthError(Exception):
|
class _AuthError(Exception):
|
||||||
"""订阅接口返回的错误:code 为 HTTP 状态码,str 为服务端返回的中文提示。"""
|
"""订阅接口返回的错误:code 为 HTTP 状态码,str 为服务端返回的中文提示。"""
|
||||||
@@ -1048,7 +1345,7 @@ def _is_private_int(n: int) -> bool:
|
|||||||
(n & 0xFF000000) == 0x7F000000 or
|
(n & 0xFF000000) == 0x7F000000 or
|
||||||
(n & 0xFFFF0000) == 0xA9FE0000)
|
(n & 0xFFFF0000) == 0xA9FE0000)
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=16384)
|
@_ttl_cache(300, maxsize=16384)
|
||||||
def _is_china_ip(host: str):
|
def _is_china_ip(host: str):
|
||||||
"""判断目标是否命中中国 IP 段;域名会先解析。
|
"""判断目标是否命中中国 IP 段;域名会先解析。
|
||||||
|
|
||||||
@@ -1129,7 +1426,7 @@ def _whitelist_entry_match(host: str, entry: str) -> bool:
|
|||||||
# 域名条目:精确匹配或子域后缀匹配(与 pac_domains 同一规则)
|
# 域名条目:精确匹配或子域后缀匹配(与 pac_domains 同一规则)
|
||||||
return h == entry or h.endswith("." + entry)
|
return h == entry or h.endswith("." + entry)
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=8192)
|
@_ttl_cache(300, maxsize=8192)
|
||||||
def _is_whitelisted(host: str, version: int) -> bool:
|
def _is_whitelisted(host: str, version: int) -> bool:
|
||||||
"""host 是否命中已启用的白名单规则(按版本键控缓存,规则变更即失效)。"""
|
"""host 是否命中已启用的白名单规则(按版本键控缓存,规则变更即失效)。"""
|
||||||
entries = [r["value"] for r in _whitelist_rules() if r.get("enabled")]
|
entries = [r["value"] for r in _whitelist_rules() if r.get("enabled")]
|
||||||
@@ -1154,11 +1451,24 @@ def _decide_direct(host: str, bypass_lan: bool, smart: bool) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _parse_connect_authority(first_line: str):
|
def _parse_connect_authority(first_line: str):
|
||||||
"""从 CONNECT 首行提取 (host, port),失败返回 None。"""
|
"""从 CONNECT 首行提取 (host, port),失败返回 None。
|
||||||
|
|
||||||
|
IPv6 字面量带方括号(CONNECT [::1]:443):剥掉括号再按最后一个冒号拆分,
|
||||||
|
否则会把 "[::1]" 当成主机名,直连/白名单判定全部失效。"""
|
||||||
parts = first_line.split(" ")
|
parts = first_line.split(" ")
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
return None
|
return None
|
||||||
authority = parts[1]
|
authority = parts[1]
|
||||||
|
if authority.startswith("[") and "]" in authority:
|
||||||
|
# 括号必须紧跟在主机名部分之后([::1]:443);"[::1" 无右括号等畸形输入
|
||||||
|
# 会因 "]:" 检查失败而走下面的普通拆分,最终被 port_s.isdigit() 拦下
|
||||||
|
try:
|
||||||
|
host = authority[1:authority.index("]")]
|
||||||
|
tail = authority[authority.index("]") + 1:]
|
||||||
|
if tail.startswith(":") and tail[1:].isdigit():
|
||||||
|
return host, int(tail[1:])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
if ":" not in authority:
|
if ":" not in authority:
|
||||||
return None
|
return None
|
||||||
host, _, port_s = authority.rpartition(":")
|
host, _, port_s = authority.rpartition(":")
|
||||||
@@ -1341,27 +1651,28 @@ def _release_forwarder(started_here):
|
|||||||
|
|
||||||
def is_proxy_on():
|
def is_proxy_on():
|
||||||
"""系统代理是否生效:固定代理已开启,或 AutoConfigURL 指向本地 PAC 服务。"""
|
"""系统代理是否生效:固定代理已开启,或 AutoConfigURL 指向本地 PAC 服务。"""
|
||||||
r = _run(["reg", "query", PROXY_REG, "/v", "ProxyEnable"], warn=False)
|
v = _reg_query_value("ProxyEnable")
|
||||||
if "0x1" in (r.stdout or ""):
|
if v and "0x1" in (v[1] or ""):
|
||||||
return True
|
return True
|
||||||
r = _run(["reg", "query", PROXY_REG, "/v", "AutoConfigURL"], warn=False)
|
v = _reg_query_value("AutoConfigURL")
|
||||||
return "127.0.0.1" in (r.stdout or "")
|
return bool(v) and _is_our_pac_url(v[1] or "")
|
||||||
|
|
||||||
def _cleanup_stale_proxy():
|
def _cleanup_stale_proxy():
|
||||||
"""清理上次异常退出残留的系统代理设置。
|
"""清理上次异常退出残留的系统代理设置,并恢复用户原有设置。
|
||||||
|
|
||||||
TLS 转发器 / PAC 服务只活在进程内,程序启动时若注册表仍指向
|
TLS 转发器 / PAC 服务只活在进程内,程序启动时若注册表仍指向
|
||||||
本地转发端口或本地 PAC 地址,说明是残留——不清理会导致全网不通。
|
本地转发端口或本地 PAC 地址,说明是残留——不清理会导致全网不通。
|
||||||
|
只认本程序的精确地址:其他软件(如 Clash)的 PAC 也在 127.0.0.1 上,
|
||||||
|
不能因为地址里带 127.0.0.1 就误删。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not is_proxy_on():
|
server = _reg_query_value("ProxyServer")
|
||||||
|
pac = _reg_query_value("AutoConfigURL")
|
||||||
|
ours = (bool(server) and (server[1] or "").strip() == f"{PROXY_HOST}:{PROXY_PORT}") or \
|
||||||
|
(bool(pac) and _is_our_pac_url(pac[1] or ""))
|
||||||
|
if not ours:
|
||||||
return
|
return
|
||||||
r = _run(["reg", "query", PROXY_REG, "/v", "ProxyServer"], warn=False)
|
proxy_off() # 内部从备份恢复用户原有设置(无备份则清空)
|
||||||
r2 = _run(["reg", "query", PROXY_REG, "/v", "AutoConfigURL"], warn=False)
|
|
||||||
stale = (f"{PROXY_HOST}:{PROXY_PORT}" in (r.stdout or "")
|
|
||||||
or "127.0.0.1" in (r2.stdout or ""))
|
|
||||||
if stale:
|
|
||||||
proxy_off()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1998,6 +2309,7 @@ class MainWindow(QWidget):
|
|||||||
_update_download_done = pyqtSignal(bool, str)
|
_update_download_done = pyqtSignal(bool, str)
|
||||||
|
|
||||||
_status_ready = pyqtSignal(object)
|
_status_ready = pyqtSignal(object)
|
||||||
|
_node_switched = pyqtSignal() # 启动校验时代替用户切换了节点(刷新标题栏)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -2011,6 +2323,7 @@ class MainWindow(QWidget):
|
|||||||
self._update_done.connect(self._on_update_done)
|
self._update_done.connect(self._on_update_done)
|
||||||
self._update_download_done.connect(self._on_update_download_done)
|
self._update_download_done.connect(self._on_update_download_done)
|
||||||
self._status_ready.connect(self._apply_status)
|
self._status_ready.connect(self._apply_status)
|
||||||
|
self._node_switched.connect(self._on_node_switched)
|
||||||
|
|
||||||
_user_init_local() # 首启初始化用户数据目录(节点模板),须在 init_ui 读节点之前
|
_user_init_local() # 首启初始化用户数据目录(节点模板),须在 init_ui 读节点之前
|
||||||
|
|
||||||
@@ -2028,9 +2341,28 @@ class MainWindow(QWidget):
|
|||||||
threading.Thread(target=_user_init_remote, daemon=True).start()
|
threading.Thread(target=_user_init_remote, daemon=True).start()
|
||||||
# 后台用已保存凭据同步最新节点配置(服务端改动下次启动生效)
|
# 后台用已保存凭据同步最新节点配置(服务端改动下次启动生效)
|
||||||
threading.Thread(target=_nodes_sync_remote, daemon=True).start()
|
threading.Thread(target=_nodes_sync_remote, daemon=True).start()
|
||||||
|
# 后台校验上次选中的节点在本机是否还可用(IPv6 专用节点本机无 IPv6 时回退)
|
||||||
|
threading.Thread(target=self._verify_node_thread, daemon=True).start()
|
||||||
# 节点未配置时延迟弹出登录框(等窗口显示后)
|
# 节点未配置时延迟弹出登录框(等窗口显示后)
|
||||||
QTimer.singleShot(600, self._maybe_show_login)
|
QTimer.singleShot(600, self._maybe_show_login)
|
||||||
|
|
||||||
|
def _verify_node_thread(self):
|
||||||
|
"""等节点配置在线同步完成后,校验当前选中节点是否可用,必要时自动回退。
|
||||||
|
|
||||||
|
探测要解析 DNS 并尝试 TCP 连接,放在后台线程,避免卡住启动。
|
||||||
|
订阅同步可能较慢:等同步完成(最多 15 秒),同步后才校验,
|
||||||
|
否则服务端换过节点顺序时校验的是旧列表。"""
|
||||||
|
_NODES_SYNC_DONE.wait(15)
|
||||||
|
try:
|
||||||
|
if _verify_selected_node():
|
||||||
|
self._node_switched.emit()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"节点可用性校验失败: {e}")
|
||||||
|
|
||||||
|
def _on_node_switched(self):
|
||||||
|
"""节点被自动切换后刷新标题栏(在 UI 线程执行)。"""
|
||||||
|
self.subtitle.setText(f"{_endpoint_domain()} ({_endpoint_country()})")
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
"""初始化主界面布局与样式。"""
|
"""初始化主界面布局与样式。"""
|
||||||
self.setWindowTitle("R.R.P.P.")
|
self.setWindowTitle("R.R.P.P.")
|
||||||
@@ -2268,6 +2600,9 @@ class MainWindow(QWidget):
|
|||||||
if _endpoint_domain() == "???":
|
if _endpoint_domain() == "???":
|
||||||
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
|
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
|
||||||
raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置")
|
raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置")
|
||||||
|
if not _node_reachable(_current_node()):
|
||||||
|
# 当前选中节点是 IPv6 专用而本机没有 IPv6 出口:转发的流量全部连不上
|
||||||
|
raise RuntimeError("当前节点仅提供 IPv6 地址,本机没有 IPv6 出口,请在节点菜单切换其他节点")
|
||||||
_traffic_reset()
|
_traffic_reset()
|
||||||
log("TLS 直连模式")
|
log("TLS 直连模式")
|
||||||
ep = _endpoint_domain()
|
ep = _endpoint_domain()
|
||||||
@@ -2365,6 +2700,11 @@ class MainWindow(QWidget):
|
|||||||
"nodes.json 中没有可用节点。\n"
|
"nodes.json 中没有可用节点。\n"
|
||||||
"可在右下角「设置」中退出登录后重新登录获取节点配置。")
|
"可在右下角「设置」中退出登录后重新登录获取节点配置。")
|
||||||
return
|
return
|
||||||
|
menu = self._build_node_menu(nodes)
|
||||||
|
menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft()))
|
||||||
|
|
||||||
|
def _build_node_menu(self, nodes):
|
||||||
|
"""构建节点切换菜单:IPv6 专用节点加标注,本机无 IPv6 出口时置灰不可选。"""
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
menu.setStyleSheet(f"""
|
menu.setStyleSheet(f"""
|
||||||
QMenu {{
|
QMenu {{
|
||||||
@@ -2391,11 +2731,24 @@ class MainWindow(QWidget):
|
|||||||
text = f"{domain} ({country})"
|
text = f"{domain} ({country})"
|
||||||
if remark and remark != domain:
|
if remark and remark != domain:
|
||||||
text += f" {remark}"
|
text += f" {remark}"
|
||||||
|
ipv6_only = _node_ipv6_only(n)
|
||||||
|
reachable = _node_reachable(n)
|
||||||
|
if ipv6_only:
|
||||||
|
# 不可达时的文案由 tooltip 改为直接写在菜单项上,用户一眼可见
|
||||||
|
text += " [IPv6]" if reachable else " [不可用:本机无 IPv6]"
|
||||||
if i == cur:
|
if i == cur:
|
||||||
text = "✓ " + text
|
text = "✓ " + text
|
||||||
menu.addAction(text).triggered.connect(
|
act = menu.addAction(text)
|
||||||
lambda _=False, idx=i: self._on_node_selected(idx))
|
if not reachable:
|
||||||
menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft()))
|
# 本机无 IPv6 出口:置灰不可选,避免选中后连不上
|
||||||
|
act.setEnabled(False)
|
||||||
|
act.setToolTip("不可用:本机无 IPv6")
|
||||||
|
else:
|
||||||
|
if ipv6_only:
|
||||||
|
act.setToolTip("该节点仅提供 IPv6 地址(本机 IPv6 可用)")
|
||||||
|
act.triggered.connect(
|
||||||
|
lambda _=False, idx=i: self._on_node_selected(idx))
|
||||||
|
return menu
|
||||||
|
|
||||||
def _on_node_selected(self, idx):
|
def _on_node_selected(self, idx):
|
||||||
"""切换节点:更新标题。"""
|
"""切换节点:更新标题。"""
|
||||||
@@ -2494,6 +2847,8 @@ class MainWindow(QWidget):
|
|||||||
# 先初始化结果,任何路径(含跳过)完成后 _on_update_done 都能安全读取
|
# 先初始化结果,任何路径(含跳过)完成后 _on_update_done 都能安全读取
|
||||||
self._latest_version = ""
|
self._latest_version = ""
|
||||||
self._download_url = ""
|
self._download_url = ""
|
||||||
|
self._asset_size = 0 # release API 记录的附件大小,下载后比对
|
||||||
|
self._checksum_url = "" # SHA-256 边车附件链接(无则跳过校验)
|
||||||
try:
|
try:
|
||||||
if _skip_update_check() and not manual:
|
if _skip_update_check() and not manual:
|
||||||
return # 设置跳过(仅自动检查):不检查也不打日志
|
return # 设置跳过(仅自动检查):不检查也不打日志
|
||||||
@@ -2520,11 +2875,23 @@ class MainWindow(QWidget):
|
|||||||
req = urllib.request.Request(url)
|
req = urllib.request.Request(url)
|
||||||
data = opener.open(req, timeout=20).read().decode("utf-8-sig")
|
data = opener.open(req, timeout=20).read().decode("utf-8-sig")
|
||||||
info = json.loads(data)
|
info = json.loads(data)
|
||||||
# 基于 Gitea 仓库 releases API:版本取 tag_name,下载取 exe 附件链接
|
# 基于 Gitea 仓库 releases API:版本取 tag_name,下载取 exe 附件链接。
|
||||||
|
# 附件可能有多个(发布流程附带 .sha256 边车),按名字精确匹配而不是
|
||||||
|
# 固定取第一个,避免附件顺序变化时下载到错文件。
|
||||||
self._latest_version = str(info.get("tag_name") or "")
|
self._latest_version = str(info.get("tag_name") or "")
|
||||||
assets = info.get("assets") or []
|
assets = info.get("assets") or []
|
||||||
self._download_url = (assets[0].get("browser_download_url") or ""
|
wanted = f"RRPP_Lite_{self._latest_version}.exe" if self._latest_version else ""
|
||||||
if assets else "")
|
exe_asset = next((a for a in assets if a.get("name") == wanted), None)
|
||||||
|
if exe_asset is None:
|
||||||
|
# 老 release 命名不齐时回退到任意 exe 附件
|
||||||
|
exe_asset = next((a for a in assets
|
||||||
|
if str(a.get("name") or "").lower().endswith(".exe")), None)
|
||||||
|
if exe_asset is None:
|
||||||
|
raise RuntimeError("release 中没有可用的 EXE 附件")
|
||||||
|
self._download_url = exe_asset.get("browser_download_url") or ""
|
||||||
|
self._asset_size = int(exe_asset.get("size") or 0)
|
||||||
|
sha_asset = next((a for a in assets if a.get("name") == wanted + ".sha256"), None)
|
||||||
|
self._checksum_url = (sha_asset.get("browser_download_url") or "") if sha_asset else ""
|
||||||
log(f"当前版本: {VERSION},最新版本: {self._latest_version or '未知'}")
|
log(f"当前版本: {VERSION},最新版本: {self._latest_version or '未知'}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"更新检查失败: {e}")
|
log(f"更新检查失败: {e}")
|
||||||
@@ -2547,9 +2914,15 @@ class MainWindow(QWidget):
|
|||||||
threading.Thread(target=self._download_update, daemon=True).start()
|
threading.Thread(target=self._download_update, daemon=True).start()
|
||||||
|
|
||||||
def _download_update(self):
|
def _download_update(self):
|
||||||
"""后台下载新版本 EXE 到 EXE 所在目录。"""
|
"""后台下载新版本 EXE 到 EXE 所在目录。
|
||||||
|
|
||||||
|
落盘前校验:① 大小与 release API 记录一致(传输完整性);
|
||||||
|
② 发布流程附带 .sha256 边车时校验 SHA-256(防链路被攻破/文件被替换)。
|
||||||
|
边车缺失(老 release)只做大小校验。
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
import urllib.request, urllib.parse, base64
|
import hashlib
|
||||||
|
import urllib.request, urllib.parse
|
||||||
url = self._download_url
|
url = self._download_url
|
||||||
if not url:
|
if not url:
|
||||||
self._update_download_done.emit(False, "下载链接为空")
|
self._update_download_done.emit(False, "下载链接为空")
|
||||||
@@ -2561,29 +2934,40 @@ class MainWindow(QWidget):
|
|||||||
log(f"正在下载新版本: {url}")
|
log(f"正在下载新版本: {url}")
|
||||||
log(f"保存到: {save_path}")
|
log(f"保存到: {save_path}")
|
||||||
|
|
||||||
# 同更新检查:按「使用代理更新」设置选择隧道或直连
|
def fetch(target):
|
||||||
if _update_via_proxy():
|
# 同更新检查:按「使用代理更新」设置选择隧道或直连
|
||||||
started_here = _ensure_forwarder()
|
if _update_via_proxy():
|
||||||
try:
|
|
||||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler(
|
opener = urllib.request.build_opener(urllib.request.ProxyHandler(
|
||||||
{"http": f"{PROXY_HOST}:{PROXY_PORT}",
|
{"http": f"{PROXY_HOST}:{PROXY_PORT}",
|
||||||
"https": f"{PROXY_HOST}:{PROXY_PORT}"}))
|
"https": f"{PROXY_HOST}:{PROXY_PORT}"}))
|
||||||
req = urllib.request.Request(url)
|
else:
|
||||||
|
opener = _pac_no_proxy_opener()
|
||||||
|
with opener.open(urllib.request.Request(target), timeout=120) as resp:
|
||||||
|
return resp.read()
|
||||||
|
|
||||||
with opener.open(req, timeout=120) as resp:
|
started_here = _ensure_forwarder() if _update_via_proxy() else False
|
||||||
data = resp.read()
|
try:
|
||||||
finally:
|
data = fetch(url)
|
||||||
_release_forwarder(started_here)
|
checksum_text = ""
|
||||||
else:
|
if self._checksum_url:
|
||||||
opener = _pac_no_proxy_opener()
|
checksum_text = fetch(self._checksum_url).decode("ascii", "replace")
|
||||||
req = urllib.request.Request(url)
|
finally:
|
||||||
|
_release_forwarder(started_here)
|
||||||
with opener.open(req, timeout=120) as resp:
|
|
||||||
data = resp.read()
|
|
||||||
|
|
||||||
if len(data) < 1024:
|
if len(data) < 1024:
|
||||||
self._update_download_done.emit(False, "下载文件过小,可能不是有效的 EXE")
|
self._update_download_done.emit(False, "下载文件过小,可能不是有效的 EXE")
|
||||||
return
|
return
|
||||||
|
if self._asset_size and len(data) != self._asset_size:
|
||||||
|
self._update_download_done.emit(
|
||||||
|
False, f"下载不完整:实际 {len(data)} 字节,应为 {self._asset_size} 字节")
|
||||||
|
return
|
||||||
|
if checksum_text:
|
||||||
|
want = checksum_text.strip().split()[0].lower()
|
||||||
|
got = hashlib.sha256(data).hexdigest()
|
||||||
|
if len(want) != 64 or want != got:
|
||||||
|
self._update_download_done.emit(False, "更新包校验失败(SHA-256 不匹配),已放弃")
|
||||||
|
return
|
||||||
|
log("更新包 SHA-256 校验通过")
|
||||||
|
|
||||||
save_path.write_bytes(data)
|
save_path.write_bytes(data)
|
||||||
log(f"新版本已下载: {save_path}")
|
log(f"新版本已下载: {save_path}")
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""离屏验证节点菜单:IPv6-only 节点的标注与置灰(真实 PyQt5,不出窗口)。
|
||||||
|
|
||||||
|
需要 Windows venv 的真实 PyQt5:
|
||||||
|
..\\.venv\\Scripts\\python.exe test\\check_node_menu.py
|
||||||
|
读取 test/nodes.json 作为节点数据,只构建菜单不进事件循环。
|
||||||
|
"""
|
||||||
|
import os, sys
|
||||||
|
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LITE = Path(__file__).resolve().parent.parent
|
||||||
|
# 脚本所在目录(test/)会被解释器自动放到 sys.path 首位,那里有 PyQt5 桩,
|
||||||
|
# 会顶掉真实 PyQt5 —— 必须移除,改用本程序自己的 venv
|
||||||
|
sys.path = [str(LITE)] + [p for p in sys.path if Path(p).resolve() != Path(__file__).resolve().parent]
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
import proxy_lite as pp
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
|
# 构造窗口时不写用户数据目录(避免污染),构造后再指向真实 nodes.json
|
||||||
|
pp._user_init_local = lambda: None
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
w = pp.MainWindow()
|
||||||
|
w.timer.stop() # 停掉状态轮询,纯静态检查
|
||||||
|
|
||||||
|
pp._USER_DIR = LITE / "test" # 真实 nodes.json 所在目录
|
||||||
|
nodes = pp._reload_nodes()
|
||||||
|
print(f"本机有全局 IPv6: {pp._local_has_ipv6()}")
|
||||||
|
print(f"节点数: {len(nodes)}")
|
||||||
|
|
||||||
|
menu = w._build_node_menu(nodes)
|
||||||
|
print("\n菜单项(enabled / 文案 / tooltip):")
|
||||||
|
for act in menu.actions():
|
||||||
|
print(f" {str(act.isEnabled()):5} | {act.text()}")
|
||||||
|
if act.toolTip():
|
||||||
|
print(f" └ {act.toolTip()}")
|
||||||
|
|
||||||
|
# 点击被禁用的项不应切换节点
|
||||||
|
before = pp._selected_index()
|
||||||
|
for act in menu.actions():
|
||||||
|
if not act.isEnabled():
|
||||||
|
act.trigger()
|
||||||
|
print(f"\n点击禁用项后节点下标: {before} -> {pp._selected_index()} (应不变)")
|
||||||
|
|
||||||
|
# ── 上次选中的节点已不可用:启动时自动回退到第一个可达节点 ──────────
|
||||||
|
print("\n启动校验(_verify_selected_node):")
|
||||||
|
bad = [i for i, n in enumerate(nodes) if not pp._node_reachable(n)]
|
||||||
|
if not bad:
|
||||||
|
print(" 本机所有节点均可达,无法验证回退(本机有 IPv6?)")
|
||||||
|
else:
|
||||||
|
import json
|
||||||
|
_bak = (pp._USER_DIR / "settings.json").read_bytes() if (pp._USER_DIR / "settings.json").is_file() else None
|
||||||
|
bad_idx = bad[0]
|
||||||
|
pp._SELECTED_NODE = bad_idx # 模拟上次选了不可用节点
|
||||||
|
switched = pp._verify_selected_node()
|
||||||
|
expect = pp._first_reachable_index(nodes)
|
||||||
|
ok = switched and pp._selected_index() == expect
|
||||||
|
print(f" 上次选中 {nodes[bad_idx].get('domain')}(下标 {bad_idx}) 不可用 -> "
|
||||||
|
f"回退到下标 {pp._selected_index()} ({nodes[pp._selected_index()].get('domain')}) "
|
||||||
|
f"{'PASS' if ok else 'FAIL'}")
|
||||||
|
saved = json.loads((pp._USER_DIR / "settings.json").read_text("utf-8")).get("current_node") if (pp._USER_DIR / "settings.json").is_file() else ""
|
||||||
|
ok2 = saved == str(nodes[expect].get("domain") or "")
|
||||||
|
print(f" 回退结果已写入设置 current_node={saved!r} {'PASS' if ok2 else 'FAIL'}")
|
||||||
|
# 再校验一次:已经是可达节点,不应再切换
|
||||||
|
ok3 = pp._verify_selected_node() is False
|
||||||
|
print(f" 再次校验不重复切换 {'PASS' if ok3 else 'FAIL'}")
|
||||||
|
if _bak is None:
|
||||||
|
(pp._USER_DIR / "settings.json").unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
(pp._USER_DIR / "settings.json").write_bytes(_bak)
|
||||||
|
|
||||||
|
# ── 服务端调整节点顺序:按域名重匹配而不是死守下标 ──────────────────
|
||||||
|
print("\n服务端调序(_rematch_saved_domain):")
|
||||||
|
if len(nodes) < 2:
|
||||||
|
print(" 节点不足 2 个,跳过")
|
||||||
|
else:
|
||||||
|
import json
|
||||||
|
dom_old = nodes[0].get("domain")
|
||||||
|
ordered = [nodes[1], nodes[0]] + nodes[2:] # 模拟服务端交换了前两个节点
|
||||||
|
pp._NODES = ordered
|
||||||
|
# 设置里记录的是原下标 0 的域名,重匹配后下标应跟随域名(新列表里是 1)
|
||||||
|
pp._settings_save({**pp._settings_load(), "current_node": dom_old})
|
||||||
|
switched = pp._rematch_saved_domain()
|
||||||
|
ok = switched and ordered[pp._selected_index()].get("domain") == dom_old
|
||||||
|
print(f" current_node={dom_old} 调序后跟随域名 -> 下标 {pp._selected_index()} "
|
||||||
|
f"{'PASS' if ok else 'FAIL'}")
|
||||||
|
# 已是正确域名时再跑一次:不应重复切换
|
||||||
|
ok2 = pp._rematch_saved_domain() is False
|
||||||
|
print(f" 再次重匹配不重复切换 {'PASS' if ok2 else 'FAIL'}")
|
||||||
|
# 域名已不在列表(节点被移除):不切换,保持现状
|
||||||
|
pp._settings_save({**pp._settings_load(), "current_node": "gone.example.com"})
|
||||||
|
ok3 = pp._rematch_saved_domain() is False
|
||||||
|
print(f" 节点被移除时不动 {'PASS' if ok3 else 'FAIL'}")
|
||||||
|
pp._NODES = None # 还原缓存,后续用例重读文件
|
||||||
|
# 还原 settings.json,避免测试污染
|
||||||
|
if _bak is None:
|
||||||
|
(pp._USER_DIR / "settings.json").unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
(pp._USER_DIR / "settings.json").write_bytes(_bak)
|
||||||
|
|
||||||
|
print("OK")
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""PAC(智能分流)模式路由决策测试。
|
||||||
|
|
||||||
|
核心问题:PAC 模式下,目标域名解析为国外 IP 时,是否强制走隧道代理?
|
||||||
|
|
||||||
|
判定链(proxy_lite._decide_direct):
|
||||||
|
0. 代理白名单命中 -> 强制直连(最优先,PAC/固定模式均生效)
|
||||||
|
1. pac_domains.json 域名表命中 -> 强制隧道(优先于 IP 判定)
|
||||||
|
2. 内网 IP 且勾选 bypass_lan -> 直连
|
||||||
|
3. 智能分流:中国 IP -> 直连;其余(国外 IP)-> 走隧道
|
||||||
|
|
||||||
|
直接 import proxy_lite 调用真实决策函数,数据用真实的 pac_ip_cn.json /
|
||||||
|
pac_domains.json;PyQt5 用桩替代(决策逻辑不依赖 Qt)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TEST_DIR = Path(__file__).resolve().parent
|
||||||
|
LITE_DIR = TEST_DIR.parent
|
||||||
|
|
||||||
|
sys.path.insert(0, str(TEST_DIR)) # 假 PyQt5 桩
|
||||||
|
sys.path.insert(0, str(LITE_DIR))
|
||||||
|
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import proxy_lite as pp
|
||||||
|
from proxy_lite import _decide_direct, _is_china_ip, _is_force_proxy, _is_private_ip, _is_whitelisted
|
||||||
|
|
||||||
|
# PAC 数据已不再随项目保留:从 CDN 拉取到临时用户目录(真实数据端到端)
|
||||||
|
import tempfile
|
||||||
|
pp._USER_DIR = Path(tempfile.mkdtemp()) / "RRPP"
|
||||||
|
pp._user_init_remote(base_url="https://cdn.rrrosmontis.icu/RRPP/PAC/")
|
||||||
|
|
||||||
|
pp._load_cn_ranges()
|
||||||
|
pp._load_force_proxy_suffixes()
|
||||||
|
if not pp._CN_RANGES:
|
||||||
|
print("警告: 未能获取 pac_ip_cn.json(CDN 不可达?),中国 IP 判定将失效")
|
||||||
|
print(f"中国IP段: {len(pp._CN_RANGES or [])} 段, 强制域名表: {len(pp._FORCE_PROXY_SUFFIXES or [])} 条\n")
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_ips(host):
|
||||||
|
try:
|
||||||
|
return sorted({a[4][0] for a in socket.getaddrinfo(host, 443, socket.AF_INET)})
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def mark(ip):
|
||||||
|
if _is_private_ip(ip):
|
||||||
|
return "内网"
|
||||||
|
if _is_china_ip(ip):
|
||||||
|
return "中国IP"
|
||||||
|
return "国外IP"
|
||||||
|
|
||||||
|
|
||||||
|
PATH = {True: "直连", False: "隧道"}
|
||||||
|
MODES = [
|
||||||
|
("PAC+bypass_lan", dict(bypass_lan=True, smart=True)), # 默认勾选状态
|
||||||
|
("PAC无bypass_lan", dict(bypass_lan=False, smart=True)),
|
||||||
|
("固定代理模式", dict(bypass_lan=False, smart=False)), # 全部走隧道
|
||||||
|
]
|
||||||
|
|
||||||
|
# (名称, host, 期望 PAC+bypass_lan 决策: True=直连 / False=隧道)
|
||||||
|
cases = [
|
||||||
|
("境外域名 google.com", "google.com", False),
|
||||||
|
("境外域名 github.com", "github.com", False),
|
||||||
|
("境外域名 youtube.com", "youtube.com", False),
|
||||||
|
("域名表强制 claude.ai", "claude.ai", False),
|
||||||
|
("域名表强制 *.google 后缀", "antigravity.google", False),
|
||||||
|
("国内域名 www.baidu.com", "www.baidu.com", True),
|
||||||
|
("国内域名 www.qq.com", "www.qq.com", True),
|
||||||
|
("内网 localhost", "localhost", True),
|
||||||
|
("内网 IP 192.168.1.1", "192.168.1.1", True),
|
||||||
|
("境外 IP 8.8.8.8", "8.8.8.8", False),
|
||||||
|
("国内 IP 114.114.114.114", "114.114.114.114", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
failed = 0
|
||||||
|
for name, host, expect in cases:
|
||||||
|
ips = resolved_ips(host)
|
||||||
|
force = _is_force_proxy(host)
|
||||||
|
marks = f"[{','.join(mark(ip) for ip in ips)}]" if ips else "[解析失败]"
|
||||||
|
row = f"{name:<22} {host:<18} {str(ips) if ips else '无IP':<45} {marks}"
|
||||||
|
results = []
|
||||||
|
for mode_name, kw in MODES:
|
||||||
|
got = _decide_direct(host, **kw)
|
||||||
|
results.append(f"{mode_name}={PATH[got]}")
|
||||||
|
pac = _decide_direct(host, **MODES[0][1])
|
||||||
|
ok = pac == expect
|
||||||
|
failed += 0 if ok else 1
|
||||||
|
print(f"{row}\n {'域名表命中' if force else '域名表未命中':<10} | {' '.join(results)} | 期望 {PATH[expect]} -> {'PASS' if ok else 'FAIL'}")
|
||||||
|
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"结论: {'全部 PASS' if not failed else f'{failed} 个 FAIL'}")
|
||||||
|
print("=" * 70)
|
||||||
|
print("PAC 模式(智能分流)下: 域名解析结果全部为国外 IP -> 走隧道代理 ✓"
|
||||||
|
if not failed else "存在 FAIL,请检查上方明细")
|
||||||
|
|
||||||
|
# ── 附加测试:不在域名表的被墙境外站 ─────────────────────
|
||||||
|
# 这类站点不能靠域名表兜底,必须仅凭「解析结果为国外 IP」强制走隧道,
|
||||||
|
# 否则会尝试直连 -> 被墙。
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("附加: 不在 pac_domains.json 的被墙境外站(只能靠 IP 判定走隧道)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
extra_candidates = [
|
||||||
|
"dailymotion.com", "vimeo.com", "notion.so", "linear.app",
|
||||||
|
"quora.com", "feedly.com", "dropbox.com", "tumblr.com",
|
||||||
|
"flickr.com", "pastebin.com", "fandom.com", "archive.org",
|
||||||
|
"godaddy.com", "imgur.com", "kickstarter.com", "shopify.com",
|
||||||
|
]
|
||||||
|
|
||||||
|
extra_failed = 0
|
||||||
|
skipped = 0
|
||||||
|
for host in extra_candidates:
|
||||||
|
if _is_force_proxy(host):
|
||||||
|
print(f"{host:<20} 已在域名表中,跳过")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
ips = resolved_ips(host)
|
||||||
|
if not ips:
|
||||||
|
print(f"{host:<20} 解析失败,跳过")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
foreign = all(not _is_private_ip(ip) and not _is_china_ip(ip) for ip in ips)
|
||||||
|
if not foreign:
|
||||||
|
print(f"{host:<20} 解析到非国外 IP {ips},跳过(不是有效的境外用例)")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
got = _decide_direct(host, bypass_lan=True, smart=True) # PAC 模式
|
||||||
|
if got is not False:
|
||||||
|
# 决策函数内部会重新解析域名,CDN 轮询 DNS 可能与其上方展示的解析结果
|
||||||
|
# 不一致:清缓存重试一次,仍不一致则按 DNS 不稳定跳过(非代码问题)
|
||||||
|
_is_china_ip.cache_clear()
|
||||||
|
_is_private_ip.cache_clear()
|
||||||
|
ips = resolved_ips(host)
|
||||||
|
got = _decide_direct(host, bypass_lan=True, smart=True)
|
||||||
|
if got is not False:
|
||||||
|
print(f"{host:<20} 解析/判定随 DNS 轮询不一致 {ips},跳过(非代码问题)")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
print(f"{host:<20} 重试后走隧道(DNS 瞬时差异) PASS")
|
||||||
|
continue
|
||||||
|
extra_failed += 0
|
||||||
|
print(f"{host:<20} {str(ips):<50} [{','.join(mark(ip) for ip in ips)}] -> "
|
||||||
|
f"{PATH[got]} (期望隧道) PASS")
|
||||||
|
|
||||||
|
print(f"\n附加测试: {len(extra_candidates)} 个候选, 有效用例 {len(extra_candidates) - skipped} 个, "
|
||||||
|
f"{'全部 PASS' if not extra_failed else f'{extra_failed} 个 FAIL'}")
|
||||||
|
if extra_failed:
|
||||||
|
failed += extra_failed
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# ── 代理白名单测试(用户可配置:命中即直连,最优先,含 IPv4/IPv6/CIDR) ─────
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("代理白名单: 命中规则强制直连(优先于域名表/固定代理,支持 IPv4/IPv6/CIDR)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
wl_cases = [
|
||||||
|
# (规则, 测试host, 期望命中)
|
||||||
|
("example.com", "example.com", True),
|
||||||
|
("example.com", "www.example.com", True),
|
||||||
|
("example.com", "sub.www.example.com", True),
|
||||||
|
("example.com", "example.org", False),
|
||||||
|
("claude.ai", "claude.ai", True), # 域名表强制隧道,白名单优先
|
||||||
|
("localhost", "localhost", True),
|
||||||
|
("8.8.8.8", "8.8.8.8", True), # IPv4 字面量
|
||||||
|
("8.8.8.8", "8.8.4.4", False),
|
||||||
|
("10.0.0.0/8", "10.1.2.3", True), # IPv4 CIDR
|
||||||
|
("192.0.2.0/24", "192.0.2.55", True), # IPv4 CIDR
|
||||||
|
("2001:db8::/32", "2001:db8::1", True), # IPv6 网段
|
||||||
|
("fe80::1", "fe80::1", True), # IPv6 单地址
|
||||||
|
]
|
||||||
|
|
||||||
|
# 注入白名单规则(绕过 settings.json 直接改内存缓存;版本 +1 使匹配缓存失效),
|
||||||
|
# 另加一条"禁用"的规则验证不参与匹配
|
||||||
|
pp._WHITELIST = [{"value": "203.0.113.0/24", "enabled": False}] + \
|
||||||
|
[{"value": r, "enabled": True} for r, _, _ in wl_cases]
|
||||||
|
wl_ver = pp._WHITELIST_VERSION + 1
|
||||||
|
pp._WHITELIST_VERSION = wl_ver
|
||||||
|
|
||||||
|
wl_failed = 0
|
||||||
|
for rule, host, expect in wl_cases:
|
||||||
|
got = _is_whitelisted(host, wl_ver)
|
||||||
|
ok = got == expect
|
||||||
|
wl_failed += 0 if ok else 1
|
||||||
|
print(f"规则 {rule:<16} host {host:<22} 命中={got} 期望={expect} -> {'PASS' if ok else 'FAIL'}")
|
||||||
|
got = _is_whitelisted("203.0.113.9", wl_ver) # 禁用的规则不应命中
|
||||||
|
print(f"禁用规则 203.0.113.0/24 host 203.0.113.9 -> 命中={got} 期望=False -> {'PASS' if not got else 'FAIL'}")
|
||||||
|
wl_failed += 0 if not got else 1
|
||||||
|
|
||||||
|
# 白名单在三种模式下都强制直连(固定代理模式原本全部走隧道)
|
||||||
|
print("\n白名单在三种模式下的决策(命中白名单应为直连):")
|
||||||
|
for mode_name, kw in MODES:
|
||||||
|
got = _decide_direct("www.example.com", **kw)
|
||||||
|
ok = got is True
|
||||||
|
wl_failed += 0 if ok else 1
|
||||||
|
print(f" {mode_name:<16} www.example.com -> {PATH[got]} (期望 直连) {'PASS' if ok else 'FAIL'}")
|
||||||
|
# 白名单优先于域名表:claude.ai 在 pac_domains.json 中强制隧道,白名单命中应直连
|
||||||
|
got = _decide_direct("claude.ai", bypass_lan=True, smart=True)
|
||||||
|
ok = got is True
|
||||||
|
wl_failed += 0 if ok else 1
|
||||||
|
print(f" {'域名表冲突':<14} claude.ai -> {PATH[got]} (期望 直连,白名单优先) {'PASS' if ok else 'FAIL'}")
|
||||||
|
# 未命中白名单的境外域名仍走隧道(白名单不影响其他路由)
|
||||||
|
got = _decide_direct("google.com", bypass_lan=True, smart=True)
|
||||||
|
ok = got is False
|
||||||
|
wl_failed += 0 if ok else 1
|
||||||
|
print(f" {'未命中白名单':<14} google.com -> {PATH[got]} (期望 隧道) {'PASS' if ok else 'FAIL'}")
|
||||||
|
|
||||||
|
# 恢复空白名单,避免影响后续用例
|
||||||
|
pp._WHITELIST = None
|
||||||
|
pp._WHITELIST_VERSION += 1
|
||||||
|
if wl_failed:
|
||||||
|
failed += wl_failed
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# ── IPv6-only 节点标注 ─────────────────────────────────────
|
||||||
|
# IPv6 专用节点只提供 AAAA 记录,本机没有 IPv6 出口时连不上:
|
||||||
|
# 客户端据此在节点菜单标注并置灰。判定三级:ipv6_only 字段 →
|
||||||
|
# remark 里的 "(IPv6)"(服务端 rrpp_client 现网唯一的线索)→ 域名解析兜底。
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("IPv6 专用节点:识别(三级判定)与可达性")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
ipv6_failed = 0
|
||||||
|
has_v6 = pp._local_has_ipv6()
|
||||||
|
print(f"本机是否有全局 IPv6 地址: {has_v6}")
|
||||||
|
|
||||||
|
# 用真实 nodes.json(test 目录);没有则跳过
|
||||||
|
_nodes_file = TEST_DIR / "nodes.json"
|
||||||
|
real_nodes = json.loads(_nodes_file.read_text("utf-8")) if _nodes_file.is_file() else []
|
||||||
|
if real_nodes:
|
||||||
|
pp._IPV6_OK.clear() # 清缓存,做真实探测
|
||||||
|
for n in real_nodes:
|
||||||
|
c = n.get("country") or "?"
|
||||||
|
io = pp._node_ipv6_only(n)
|
||||||
|
reach = pp._node_reachable(n)
|
||||||
|
mark = "[IPv6]" if reach else "[不可用:本机无 IPv6]"
|
||||||
|
print(f" [{c}] {n.get('domain'):<24} ipv6_only={str(io):<6} "
|
||||||
|
f"可达={str(reach):<6} 菜单文案: {n.get('domain')} ({c})"
|
||||||
|
+ (f" {mark}" if io else ""))
|
||||||
|
# ipv6_only 节点在本机无 IPv6 时必须判定为不可达,否则必须可达
|
||||||
|
if io:
|
||||||
|
expect = has_v6 or reach # 本机有 IPv6 -> 应可达;无 IPv6 -> 不应可达
|
||||||
|
ok = reach == expect
|
||||||
|
ipv6_failed += 0 if ok else 1
|
||||||
|
print(f" IPv6 专用节点可达性: {reach} (期望 {expect}) "
|
||||||
|
f"{'PASS' if ok else 'FAIL'}")
|
||||||
|
else:
|
||||||
|
ok = reach is True
|
||||||
|
ipv6_failed += 0 if ok else 1
|
||||||
|
print(f" 普通节点恒可达: {reach} {'PASS' if ok else 'FAIL'}")
|
||||||
|
# 缓存生效:再次判定不发新探测(结果一致)
|
||||||
|
cached = list(pp._IPV6_OK.items())
|
||||||
|
if cached:
|
||||||
|
print(f" 可达性缓存: {cached}")
|
||||||
|
|
||||||
|
# 三级判定:字段 → remark 里的 "(IPv6)" 标记 → 域名解析兜底。
|
||||||
|
# 服务端 subscription?method=rrpp_client 只吐旧扁平格式,ipv6_only 字段被丢掉,
|
||||||
|
# 只在 remark 里留 "(IPv6)"(后端 countries_to_rrpp_client),故第 2 级是现网主路径。
|
||||||
|
synthetic = [
|
||||||
|
({"domain": "example.com"}, False, "无标记(example.com 有 A 记录)"),
|
||||||
|
({"domain": "example.com", "ipv6_only": False}, False, "ipv6_only=false"),
|
||||||
|
({"domain": "example.com", "ipv6_only": True}, True, "ipv6_only=true"),
|
||||||
|
({"domain": "example.com", "remark": "GlobalVM 350G (IPv6)"}, True, "remark 含 (IPv6)"),
|
||||||
|
({"domain": "example.com", "remark": "Azure 15G"}, False, "remark 无 IPv6 标记"),
|
||||||
|
({"domain": "example.com", "remark": "IPv6 专用机"}, True, "remark 含 IPv6(无括号)"),
|
||||||
|
({"domain": "example.com", "remark": "some_ipv6_thing"}, True, "remark 小写 ipv6"),
|
||||||
|
]
|
||||||
|
for node, expect, desc in synthetic:
|
||||||
|
got = pp._node_ipv6_only(node)
|
||||||
|
ok = got == expect
|
||||||
|
ipv6_failed += 0 if ok else 1
|
||||||
|
print(f" {desc:<24} _node_ipv6_only={got} (期望 {expect}) {'PASS' if ok else 'FAIL'}")
|
||||||
|
pp._IPV6_OK.clear() # 合成用例可能污染缓存
|
||||||
|
|
||||||
|
if ipv6_failed:
|
||||||
|
failed += ipv6_failed
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# ── CONNECT authority 解析:IPv6 字面量带方括号 ──────────────────
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("CONNECT authority 解析(IPv6 字面量剥方括号)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
conn_cases = [
|
||||||
|
("CONNECT [::1]:443 HTTP/1.1", ("::1", 443)),
|
||||||
|
("CONNECT example.com:443 HTTP/1.1", ("example.com", 443)),
|
||||||
|
("CONNECT iam.aeolianchi.me:443 HTTP/1.1", ("iam.aeolianchi.me", 443)),
|
||||||
|
("CONNECT [::1] HTTP/1.1", None), # 无端口
|
||||||
|
("CONNECT example.com HTTP/1.1", None), # 无端口
|
||||||
|
("CONNECT [fe80::1%25eth0]:8443 HTTP/1.1", ("fe80::1%25eth0", 8443)),
|
||||||
|
("CONNECT 127.0.0.1:1080 HTTP/1.1", ("127.0.0.1", 1080)),
|
||||||
|
]
|
||||||
|
conn_failed = 0
|
||||||
|
for line, expect in conn_cases:
|
||||||
|
got = pp._parse_connect_authority(line)
|
||||||
|
ok = got == expect
|
||||||
|
conn_failed += 0 if ok else 1
|
||||||
|
print(f" {line:<45} -> {got} (期望 {expect}) {'PASS' if ok else 'FAIL'}")
|
||||||
|
if conn_failed:
|
||||||
|
failed += conn_failed
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# ── 上游代理认证注入:按完整头名精确匹配 ────────────────────────
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("认证注入:仅精确的 authorization 头名阻止注入")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
def _auth_lines(data: bytes):
|
||||||
|
head = data.split(b"\r\n\r\n", 1)[0]
|
||||||
|
return [l for l in head.split(b"\r\n")[1:] if l.lower().startswith(b"authorization:")]
|
||||||
|
|
||||||
|
auth_cases = [
|
||||||
|
# (描述, 请求, 期望):数字=注入后 authorization 行数;"unchanged"=原样跳过
|
||||||
|
("无任何认证头", b"CONNECT iam.aeolianchi.me:443 HTTP/1.1\r\nHost: iam.aeolianchi.me\r\n\r\n", 1),
|
||||||
|
("Proxy-Authorization", b"CONNECT iam.aeolianchi.me:443 HTTP/1.1\r\nProxy-Authorization: Basic xyz\r\n\r\n", 1),
|
||||||
|
("已有 Authorization", b"GET http://example.com/ HTTP/1.1\r\nAuthorization: Bearer tok\r\n\r\n", "unchanged"),
|
||||||
|
("小写 authorization", b"GET http://example.com/ HTTP/1.1\r\nauthorization: basic abc\r\n\r\n", "unchanged"),
|
||||||
|
("X-Authorization 前缀", b"CONNECT iam.aeolianchi.me:443 HTTP/1.1\r\nX-Authorization-Go: 1\r\n\r\n", 1),
|
||||||
|
("无头空块", b"GET http://example.com/ HTTP/1.1\r\n\r\n", 1),
|
||||||
|
]
|
||||||
|
auth_failed = 0
|
||||||
|
for desc, req, expect in auth_cases:
|
||||||
|
out = pp._inject_proxy_auth(req)
|
||||||
|
if expect == "unchanged":
|
||||||
|
ok = out == req
|
||||||
|
print(f" {desc:<22} 原样跳过 {'PASS' if ok else 'FAIL'}")
|
||||||
|
else:
|
||||||
|
lines = _auth_lines(out)
|
||||||
|
ok = len(lines) == expect and out.split(b"\r\n", 2)[1] == lines[0]
|
||||||
|
# 注入位置必须紧跟请求行(上游取首个 Authorization 时读到我们的认证)
|
||||||
|
print(f" {desc:<22} authorization 行数={len(lines)} (期望 {expect}) "
|
||||||
|
f"{'PASS' if ok else 'FAIL'}")
|
||||||
|
auth_failed += 0 if ok else 1
|
||||||
|
if auth_failed:
|
||||||
|
failed += auth_failed
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# ── 本程序 PAC 地址判定(启动清理只认自己的残留) ────────────────
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("PAC 地址归属判定(_is_our_pac_url)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
pac_cases = [
|
||||||
|
("http://127.0.0.1:39453/proxy.pac", True), # 本程序 PAC 服务
|
||||||
|
("http://127.0.0.1:7897/pac?auth=xyz", False), # 其他代理软件(如 Clash)
|
||||||
|
("http://127.0.0.1:1080/other.pac", False), # 路径不符
|
||||||
|
("http://192.168.1.1:8080/proxy.pac", False), # 非本机
|
||||||
|
("", False),
|
||||||
|
]
|
||||||
|
pac_failed = 0
|
||||||
|
for url, expect in pac_cases:
|
||||||
|
got = pp._is_our_pac_url(url)
|
||||||
|
ok = got == expect
|
||||||
|
pac_failed += 0 if ok else 1
|
||||||
|
print(f" {url:<42} 命中={got} (期望 {expect}) {'PASS' if ok else 'FAIL'}")
|
||||||
|
if pac_failed:
|
||||||
|
failed += pac_failed
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# ── DNS 分流 TTL 缓存 ────────────────────────────────────────
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("TTL 缓存:cache_clear 兼容 + 缓存命中")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
ttl_failed = 0
|
||||||
|
assert hasattr(pp._is_china_ip, "cache_clear"), "cache_clear 缺失"
|
||||||
|
assert hasattr(pp._is_private_ip, "cache_clear"), "cache_clear 缺失"
|
||||||
|
assert hasattr(pp._is_whitelisted, "cache_clear"), "cache_clear 缺失"
|
||||||
|
pp._is_china_ip.cache_clear()
|
||||||
|
pp._is_private_ip.cache_clear()
|
||||||
|
pp._is_whitelisted.cache_clear()
|
||||||
|
a1, a2 = pp._is_private_ip("10.1.2.3"), pp._is_private_ip("10.1.2.3")
|
||||||
|
ok = a1 is True and a2 is True
|
||||||
|
ttl_failed += 0 if ok else 1
|
||||||
|
print(f" 内网 IP 判定缓存命中: {a1}/{a2} {'PASS' if ok else 'FAIL'}")
|
||||||
|
b1, b2 = pp._is_china_ip("114.114.114.114"), pp._is_china_ip("114.114.114.114")
|
||||||
|
ok = b1 is True and b2 is True
|
||||||
|
ttl_failed += 0 if ok else 1
|
||||||
|
print(f" 中国 IP 判定缓存命中: {b1}/{b2} {'PASS' if ok else 'FAIL'}")
|
||||||
|
pp._is_china_ip.cache_clear()
|
||||||
|
pp._is_private_ip.cache_clear()
|
||||||
|
print(f" cache_clear 后重查: {pp._is_china_ip('114.114.114.114')} "
|
||||||
|
f"{'PASS' if pp._is_china_ip('114.114.114.114') is True else 'FAIL'}")
|
||||||
|
if ttl_failed:
|
||||||
|
failed += ttl_failed
|
||||||
|
print("=" * 70)
|
||||||
+450
-19
@@ -1,26 +1,457 @@
|
|||||||
# server_config
|
# RRPP 服务端部署规范
|
||||||
|
|
||||||
|
> 一套统一的跨境代理节点部署流程,适用于 Debian/Ubuntu 系 Linux。
|
||||||
|
> 每台节点运行相同的服务栈:**BBR 拥塞控制 + nginx CONNECT 隧道 + sing-box(VLESS+REALITY / Shadowsocks) + mtg(MTProto)**。
|
||||||
|
>
|
||||||
|
> 本文档为**通用规范**,所有 IP / 域名 / 端口 / 密钥均以**占位符**表示,部署时替换为实际值。
|
||||||
|
> 凡标注 `<...>` 的均为占位符,不要原样照抄。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [0. 架构总览](#0-架构总览)
|
||||||
|
- [1. 端口与服务规划](#1-端口与服务规划)
|
||||||
|
- [2. 系统基础优化(BBR 跨境丢包优化)](#2-系统基础优化bbr-跨境丢包优化)
|
||||||
|
- [3. 伪装站点页面](#3-伪装站点页面)
|
||||||
|
- [4. HTTPS 证书(acme.sh DNS-01)](#4-https-证书acmesh-dns-01)
|
||||||
|
- [5. nginx 源码编译 + CONNECT 隧道](#5-nginx-源码编译--connect-隧道)
|
||||||
|
- [6. sing-box(VLESS+REALITY / Shadowsocks)](#6-sing-boxvlessreality--shadowsocks)
|
||||||
|
- [7. mtg(MTProto 代理)](#7-mtgmtproto-代理)
|
||||||
|
- [8. 本机公网 IP 自动识别(防自代理回环)](#8-本机公网-ip-自动识别防自代理回环)
|
||||||
|
- [9. systemd 服务文件](#9-systemd-服务文件)
|
||||||
|
- [10. 启动顺序与验证清单](#10-启动顺序与验证清单)
|
||||||
|
- [11. 目录结构与文件索引](#11-目录结构与文件索引)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 架构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
客户端 ──► nginx(443/<nginx端口>) ──┬─► CONNECT 隧道(任意端口, 全端口放行)
|
||||||
|
│ └─► 伪装页面(直接访问/IP访问)
|
||||||
|
│
|
||||||
|
├─► sing-box(<vless端口>) ──► VLESS+REALITY(vision)
|
||||||
|
├─► sing-box(<ss端口>) ──► Shadowsocks(2022-blake3-aes-256-gcm)
|
||||||
|
└─► mtg(<mtproto端口>) ──► MTProto(Fake TLS, Telegram 内置代理)
|
||||||
|
```
|
||||||
|
|
||||||
|
**四层代理各司其职:**
|
||||||
|
|
||||||
|
| 层 | 组件 | 协议 | 适用场景 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| HTTP 隧道 | nginx | CONNECT + Basic Auth | 浏览器/curl/系统代理,全端口 WebSocket 透传 |
|
||||||
|
| 通用代理 | sing-box | VLESS+REALITY | Shadowrocket/Clash/v2rayN 客户端 |
|
||||||
|
| 兼容旧客户端 | sing-box | Shadowsocks | Shadowrocket 2.x 等不支持 REALITY 的客户端 || Telegram 专用 | mtg | MTProto Fake TLS | Telegram 内置代理,抗封锁 |
|
||||||
|
|
||||||
|
**核心密钥体系(2026-09 起全部每节点独立):**
|
||||||
|
- VLESS+REALITY:每节点**独立** UUID、REALITY 密钥对、short_id;SNI 仍全节点统一(用 `<伪装SNI域名>`,选一个高流量、TLS 指纹稳定的知名站点域名)
|
||||||
|
- Shadowsocks:每节点**独立** PSK,方法 `2022-blake3-aes-256-gcm`(SIP022,抗重放探测)
|
||||||
|
- nginx CONNECT:每节点**独立** Basic Auth 密码(客户端按节点携带凭据,无兼容性问题)
|
||||||
|
- MTProto:每台**独立 secret**(Fake TLS 类型,fronting 到 `<伪装SNI域名>`)
|
||||||
|
|
||||||
|
> 独立化的目的:单节点密钥泄漏/被污染时不波及其余节点,且换钥粒度更小。
|
||||||
|
> 代价是订阅/nodes.json 必须逐节点维护,改任一项后必须做端到端验证(见 6.3、第 10 节)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 端口与服务规划
|
||||||
|
|
||||||
|
端口全部落在**同一区段**便于防火墙管理(示例用 1200–1220,可按需调整):
|
||||||
|
|
||||||
|
| 端口 | 服务 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `<nginx端口>` | nginx | HTTPS 站点 + CONNECT 隧道(示例 `1205`) |
|
||||||
|
| `<vless端口>` | sing-box | VLESS+REALITY(示例 `1206`) |
|
||||||
|
| `<ss端口>` | sing-box | Shadowsocks(示例 `1207`) |
|
||||||
|
| `<mtproto端口>` | mtg | MTProto(示例 `1210`) |
|
||||||
|
|
||||||
|
> 节点 A 用 `80xx` 区段、节点 B 用 `120xx` 区段皆可,**只要同节点内不冲突、且运营商/防火墙放行**。
|
||||||
|
> 防火墙放行:`iptables -A INPUT -p tcp -m multiport --dports <nginx端口>,<vless端口>,<ss端口>,<mtproto端口> -j ACCEPT`
|
||||||
|
|
||||||
|
**域名规划:** 每个节点一个独立域名 `<节点域名>`(如 `node1.example.com`),A 记录指向节点公网 IP。acme.sh 用 DNS-01 签证书,无需 80 端口可达。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 系统基础优化(BBR 跨境丢包优化)
|
||||||
|
|
||||||
|
BBR(Bottleneck Bandwidth and RTT)+ fq 队列,显著改善跨境 TCP 丢包/高延迟下的吞吐。
|
||||||
|
|
||||||
|
### 2.1 启用 BBR
|
||||||
|
|
||||||
|
写入 `/etc/sysctl.d/99-bbr.conf`:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
net.core.default_qdisc=fq
|
||||||
|
net.ipv4.tcp_congestion_control=bbr
|
||||||
|
```
|
||||||
|
|
||||||
|
应用并验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
modprobe tcp_bbr 2>/dev/null || true # 部分内核需手动加载模块
|
||||||
|
sysctl --system
|
||||||
|
sysctl net.ipv4.tcp_congestion_control net.core.default_qdisc
|
||||||
|
# 期望输出: bbr / fq
|
||||||
|
```
|
||||||
|
|
||||||
|
> **内核要求**:BBR 需 Linux 4.9+。`default_qdisc=fq` 在部分新内核(6.12+ cloud 内核)可能无此参数,仅保留 `tcp_congestion_control=bbr` 即可,不影响 BBR 生效。
|
||||||
|
|
||||||
|
### 2.2 文件描述符
|
||||||
|
|
||||||
|
nginx worker 高并发需要提高 limit,在 `/etc/security/limits.conf` 追加:
|
||||||
|
|
||||||
|
```
|
||||||
|
* soft nofile 65535
|
||||||
|
* hard nofile 65535
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 时区与 NTP
|
||||||
|
|
||||||
|
代理证书校验、日志排障都依赖准确时间:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
timedatectl set-timezone Asia/Shanghai
|
||||||
|
apt install -y chrony && systemctl enable --now chrony
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 PMTUD 黑洞缓解(tcp_mtu_probing)
|
||||||
|
|
||||||
|
**症状**:部分客户端网络上出现"TCP 能连上、但 TLS 握手稳定超时"
|
||||||
|
(如 `_ssl.c:1011 handshake operation timed out`),而服务器从其他网络探测一切正常。
|
||||||
|
原因是客户端↔服务器路径上某跳 MTU 变小且 ICMP Fragmentation Needed 被过滤
|
||||||
|
(PMTUD 黑洞),服务器发出的满尺寸大包永远到不了客户端。
|
||||||
|
|
||||||
|
开启 RFC 4821 PLPMTUD,让服务器在探测失败后自动降低 MSS 恢复通信。
|
||||||
|
写入 `/etc/sysctl.d/99-mtu-probing.conf`:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
net.ipv4.tcp_mtu_probing = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sysctl --system
|
||||||
|
sysctl net.ipv4.tcp_mtu_probing # 期望输出: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
> 只影响命中黑洞的连接,正常连接无感知,可安全全节点开启。
|
||||||
|
|
||||||
|
### 2.5 fail2ban(sshd 爆破防护)
|
||||||
|
|
||||||
|
节点 SSH 暴露在公网,爆破尝试是常态(实测单节点累计 3 万+ 次)。装 fail2ban 护 sshd:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install -y fail2ban
|
||||||
|
cat > /etc/fail2ban/jail.d/sshd.local << 'EOF'
|
||||||
|
[sshd]
|
||||||
|
enabled = true
|
||||||
|
backend = systemd
|
||||||
|
EOF
|
||||||
|
systemctl enable --now fail2ban
|
||||||
|
fail2ban-client status sshd # 确认 jail 在跑
|
||||||
|
```
|
||||||
|
|
||||||
|
> - `backend = systemd`:无 `/var/log/auth.log` 的最小化系统(无 rsyslog)必须加这行,
|
||||||
|
> 否则 fail2ban 启动报 "Have not found any log file for sshd jail"。
|
||||||
|
> - LXC 容器也能正常封禁(实测 ban/unban 链路可用)。
|
||||||
|
> - 只加 fail2ban、**不动 sshd 认证方式**(root 登录等现状保留)。
|
||||||
|
> - 如需同时护 nginx CONNECT 的 401 爆破,可再加 nginx jail(凭据够强时可不做)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 伪装站点页面
|
||||||
|
|
||||||
|
直接访问节点/IP 时返回一个普通网页(而非代理错误),降低被探测的特征。
|
||||||
|
|
||||||
|
放置一个静态 `index.html` 到 `/var/www/html/index.html`:
|
||||||
|
- 任意抓取的合法 HTML 页面(博客/门户首页快照均可)
|
||||||
|
- **所有节点用同一份**(md5 一致),避免逐节点特征差异
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /var/www/html
|
||||||
|
# index.html 为本仓库 fake-site/index.html 的拷贝
|
||||||
|
cp fake-site/index.html /var/www/html/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. HTTPS 证书(acme.sh DNS-01)
|
||||||
|
|
||||||
|
nginx 站点与 CONNECT 隧道共用一张证书。用 [acme.sh](https://github.com/acmesh-official/acme.sh) + DNS-01(以阿里云 DNS 为例,其他 DNS 商同理):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://get.acme.sh | sh -s email=<你的邮箱>
|
||||||
|
export Ali_Key="<阿里云AccessKey>"
|
||||||
|
export Ali_Secret="<阿里云Secret>"
|
||||||
|
|
||||||
|
~/.acme.sh/acme.sh --issue --dns dns_ali -d <节点域名> --keylength ec-256
|
||||||
|
|
||||||
|
# 安装到 nginx 读取路径
|
||||||
|
mkdir -p /etc/nginx/ssl/<节点域名>
|
||||||
|
~/.acme.sh/acme.sh --install-cert -d <节点域名> --ecc \
|
||||||
|
--fullchain-file /etc/nginx/ssl/<节点域名>/fullchain.pem \
|
||||||
|
--key-file /etc/nginx/ssl/<节点域名>/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
acme.sh 自带 cron 自动续期。证书路径固定为 `/etc/nginx/ssl/<节点域名>/{fullchain,privkey}.pem`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. nginx 源码编译 + CONNECT 隧道
|
||||||
|
|
||||||
|
标准 nginx 不自带 CONNECT 方法支持,需打 [ngx_http_proxy_connect_module](https://github.com/chobits/ngx_http_proxy_connect_module) 补丁后**源码编译**。
|
||||||
|
|
||||||
|
### 5.1 编译
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install -y build-essential libssl-dev libpcre3-dev zlib1g-dev curl wget
|
||||||
|
|
||||||
|
NGINX_VER=1.26.3
|
||||||
|
cd /usr/local/src
|
||||||
|
curl -LO http://nginx.org/download/nginx-$NGINX_VER.tar.gz
|
||||||
|
tar xzf nginx-$NGINX_VER.tar.gz
|
||||||
|
|
||||||
|
git clone https://github.com/chobits/ngx_http_proxy_connect_module.git
|
||||||
|
cd nginx-$NGINX_VER
|
||||||
|
patch -p1 < ../ngx_http_proxy_connect_module/patch/proxy_connect_rewrite_102101.patch
|
||||||
|
|
||||||
|
./configure \
|
||||||
|
--prefix=/usr/local/nginx \
|
||||||
|
--conf-path=/etc/nginx/nginx.conf \
|
||||||
|
--pid-path=/var/run/nginx.pid \
|
||||||
|
--error-log-path=/var/log/nginx/error.log \
|
||||||
|
--http-log-path=/var/log/nginx/access.log \
|
||||||
|
--with-http_ssl_module \
|
||||||
|
--with-http_v2_module \
|
||||||
|
--with-http_realip_module \
|
||||||
|
--with-http_stub_status_module \
|
||||||
|
--with-http_gzip_static_module \
|
||||||
|
--add-module=../ngx_http_proxy_connect_module
|
||||||
|
make -j$(nproc) && make install
|
||||||
|
```
|
||||||
|
|
||||||
|
> 若发行版 nginx 已自带 proxy_connect 补丁(极少见),可用 `apt install nginx` 跳过编译,但源码编译是通用做法。
|
||||||
|
|
||||||
|
### 5.2 Basic Auth 凭据
|
||||||
|
|
||||||
|
CONNECT 代理需要 Basic Auth(用户名/密码),**每节点独立密码**(客户端配置按节点携带凭据,无兼容性问题)。生成 htpasswd:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt install -y apache2-utils
|
||||||
|
htpasswd -bc /etc/nginx/.htpasswd <代理用户名> '<本节点独立密码>'
|
||||||
|
chmod 644 /etc/nginx/.htpasswd # worker 以 nobody 运行,必须可读
|
||||||
|
```
|
||||||
|
|
||||||
|
> 无 apache2-utils 的机器可用 openssl 生成(sha512-crypt,nginx 同样支持):
|
||||||
|
> `echo "<用户名>:$(openssl passwd -6 '<密码>')" > /etc/nginx/.htpasswd`
|
||||||
|
> htpasswd 是**每次请求实时读取**的,改密码无需 reload nginx。
|
||||||
|
|
||||||
|
### 5.3 nginx.conf
|
||||||
|
|
||||||
|
完整配置见 [nginx/nginx.conf](nginx/nginx.conf)。**关键设计**:
|
||||||
|
|
||||||
|
- **两个 server 块共用 `<nginx端口>`**:
|
||||||
|
- 主块 `server_name <节点域名>`:证书站点 + `location /` 返回伪装页;非本域名一律 rewrite 到 `/__proxy` 转发
|
||||||
|
- fallback 块 `default_server`:其他 SNI 也接受 CONNECT 代理;本机/私网/公网 IP 直连返回伪装页,其余走 `/__proxy`
|
||||||
|
- `proxy_connect_allow all;`:**全端口** CONNECT 放行(codex/WebSocket 走任意端口均通)。若需限制,改回 `proxy_connect_allow 443 563;`
|
||||||
|
- `auth_basic`:仅 CONNECT/正向代理需要认证,`location /` 伪装页 `auth_basic off`
|
||||||
|
- **自代理回环保护**:`/__proxy` 转发时带 `X-Proxy-Loop-Guard` 头,二次进入返回 508 防死循环
|
||||||
|
- `/__local`:本机 IP 直连时剥前缀返回伪装页
|
||||||
|
- `map $host $is_local`:私网/回环段判定;公网 IP 由 cron 脚本动态写入 `local_ip_rules.conf`
|
||||||
|
|
||||||
|
### 5.4 自代理回环保护说明
|
||||||
|
|
||||||
|
正向代理转发目标若指向**本机自己**(如域名公网 IP 变更、或代理目标就是本节点),会无限循环。两层防护:
|
||||||
|
1. `map` 判定私网/回环段 + cron 维护的本机公网 IP 规则 → 直连伪装页
|
||||||
|
2. `/__proxy` 的 Loop-Guard 头兜底 → 二次进入返回 508
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. sing-box(VLESS+REALITY / Shadowsocks)
|
||||||
|
|
||||||
|
### 6.1 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SINGBOX_VER=1.13.19
|
||||||
|
curl -LO https://github.com/SagerNet/sing-box/releases/download/v$SINGBOX_VER/sing-box-$SINGBOX_VER-linux-amd64.tar.gz
|
||||||
|
tar xzf sing-box-*.tar.gz
|
||||||
|
install -m 755 sing-box-$SINGBOX_VER-linux-amd64/sing-box /usr/local/bin/sing-box
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 配置
|
||||||
|
|
||||||
|
完整配置见 [sing-box/config.json](sing-box/config.json)。要点:
|
||||||
|
- **VLESS+REALITY**:每节点独立 UUID/密钥对/short_id,SNI 全节点统一,flow `xtls-rprx-vision`;REALITY handshake server 用 `<伪装SNI域名>:443`
|
||||||
|
- **Shadowsocks**:`2022-blake3-aes-256-gcm`(SIP022:会话子密钥 + 抗重放探测,显著优于老 AEAD `aes-256-gcm`)+ 每节点独立 PSK,独立端口(兼容不支持 REALITY 的老客户端)
|
||||||
|
- **PSK 格式要求**:密码必须是 base64 编码的 32 字节密钥(`openssl rand -base64 32`),不是任意字符串;改错格式 sing-box 启动即报错
|
||||||
|
- sing-box / Shadowrocket / mihomo 均支持 2022-blake3 系列
|
||||||
|
- 无 SOCKS5 inbound(已按需移除;如需可在 inbounds 追加 `type: socks`)
|
||||||
|
|
||||||
|
### 6.3 REALITY 密钥生成
|
||||||
|
|
||||||
|
**每台节点独立生成一套**(互不共用):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成私钥/公钥(sing-box 1.13+ 内置 reality 密钥生成)
|
||||||
|
sing-box generate reality-keypair
|
||||||
|
# 输出 private_key / public_key,记录备用
|
||||||
|
# short_id 用 8 字节随机 hex:
|
||||||
|
openssl rand -hex 8
|
||||||
|
# UUID:
|
||||||
|
sing-box generate uuid
|
||||||
|
```
|
||||||
|
|
||||||
|
> 私钥只放服务端配置;公钥给客户端订阅。UUID/密钥对/short_id 每节点独立。
|
||||||
|
|
||||||
|
**公钥必须推导验证,禁止手抄。** 订阅/节点配置里的 `public_key` 与服务端
|
||||||
|
`private_key` 必须严格配对(X25519 推导),曾发生手抄错一个字符导致全节点
|
||||||
|
REALITY 握手失败(客户端报 `reality verification failed`)的事故。改配置后验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - << 'EOF'
|
||||||
|
import base64
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
priv = base64.urlsafe_b64decode('<REALITY_PRIVATE_KEY>' + '==')
|
||||||
|
pub = X25519PrivateKey.from_private_bytes(priv).public_key().public_bytes(
|
||||||
|
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||||
|
print(base64.urlsafe_b64encode(pub).decode().rstrip('=')) # 应与下发的 public_key 逐字符一致
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. mtg(MTProto 代理)
|
||||||
|
|
||||||
|
Telegram 内置代理,Fake TLS 模式抗封锁。用 [mtg](https://github.com/9seconds/mtg)(Go 单二进制):
|
||||||
|
|
||||||
|
### 7.1 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MTG_VER=2.2.8
|
||||||
|
curl -LO https://github.com/9seconds/mtg/releases/download/v$MTG_VER/mtg-$MTG_VER-linux-amd64.tar.gz
|
||||||
|
tar xzf mtg-*.tar.gz
|
||||||
|
install -m 755 mtg-$MTG_VER-linux-amd64/mtg /usr/local/bin/mtg
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 生成 secret(每台独立)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mtg generate-secret --hex <伪装SNI域名>
|
||||||
|
# 输出 ee<32hex><域名hex>,以 ee 开头 = Fake TLS 类型
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <上面生成的secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
> `--prefer-ip=prefer-ipv4`:强制 IPv4 出站,避免容器/双栈环境下 IPv6 不可达导致回退失败。
|
||||||
|
|
||||||
|
### 7.4 Telegram 客户端参数
|
||||||
|
|
||||||
|
- 服务器:`<节点域名>`
|
||||||
|
- 端口:`<mtproto端口>`
|
||||||
|
- Secret:上面生成的 hex(Telegram 自动识别 Fake TLS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 本机公网 IP 自动识别(防自代理回环)
|
||||||
|
|
||||||
|
正向代理转发时,若目标 Host 是本机公网 IP,会被识别为"本机直连"返回伪装页(而非经代理转发本机,造成回环)。公网 IP 可能变动,用 cron 脚本每 5 分钟自动维护。
|
||||||
|
|
||||||
|
脚本见 [scripts/sync-nginx-local-ips.sh](scripts/sync-nginx-local-ips.sh),cron 见 [cron/sync-nginx-local-ips](cron/sync-nginx-local-ips):
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/5 * * * * root /usr/local/sbin/sync-nginx-local-ips.sh >/dev/null 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本逻辑:取 `api.ipify.org` 出站 IP + 节点域名 A 记录,比对重写 `/etc/nginx/local_ip_rules.conf`,**仅变更时** `nginx -t && systemctl reload nginx`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. systemd 服务文件
|
||||||
|
|
||||||
|
| 服务 | 文件 |
|
||||||
|
|---|---|
|
||||||
|
| nginx | [systemd/nginx.service](systemd/nginx.service) |
|
||||||
|
| sing-box | [systemd/sing-box.service](systemd/sing-box.service) |
|
||||||
|
| mtg | [systemd/mtg.service](systemd/mtg.service) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp systemd/*.service /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now nginx sing-box mtg
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 启动顺序与验证清单
|
||||||
|
|
||||||
|
### 启动顺序
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sysctl --system # 1. BBR
|
||||||
|
systemctl start sing-box # 2. VLESS/SS
|
||||||
|
systemctl start mtg # 3. MTProto
|
||||||
|
systemctl start nginx # 4. nginx(最后,依赖证书/伪装页/规则文件)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证清单
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# BBR
|
||||||
|
sysctl net.ipv4.tcp_congestion_control # => bbr
|
||||||
|
sysctl net.ipv4.tcp_mtu_probing # => 1(PMTUD 黑洞缓解,见 2.4)
|
||||||
|
|
||||||
|
# fail2ban
|
||||||
|
fail2ban-client status sshd # => jail 存在(见 2.5)
|
||||||
|
|
||||||
|
# sing-box
|
||||||
|
sing-box check -c /etc/sing-box/config.json
|
||||||
|
systemctl is-active sing-box
|
||||||
|
|
||||||
|
# mtg(Fake TLS:非法 ClientHello 应返回 TLS alert 0x15)
|
||||||
|
timeout 8 python3 -c "
|
||||||
|
import socket,os,struct
|
||||||
|
s=socket.connect(('<节点域名>',<mtproto端口>)) if False else socket.create_connection(('<节点域名>',<mtproto端口>),timeout=6)
|
||||||
|
s.sendall(b'\x16\x03\x01'+struct.pack('>H',100)+os.urandom(100))
|
||||||
|
print('resp:',s.recv(8).hex())" # 期望首字节 15 (TLS alert)
|
||||||
|
|
||||||
|
# nginx CONNECT(带 Basic Auth,B64 = base64('<代理用户名>:<代理密码>'))
|
||||||
|
B64=$(printf '<代理用户名>:<代理密码>' | base64 -w0)
|
||||||
|
curl -sk --proxy-insecure -x https://<节点域名>:<nginx端口> \
|
||||||
|
--proxy-header "Authorization: Basic $B64" \
|
||||||
|
-o /dev/null -w '%{http_code}\n' http://example.com/ # => 200
|
||||||
|
|
||||||
|
# 伪装页(直连 IP/域名,无认证)
|
||||||
|
curl -sk https://<节点域名>:<nginx端口>/ -o /dev/null -w '%{http_code}\n' # => 200
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 目录结构与文件索引
|
||||||
|
|
||||||
```
|
```
|
||||||
server_config/
|
server_config/
|
||||||
|
├── README.md ← 本文档
|
||||||
├── nginx/
|
├── nginx/
|
||||||
│ ├── nginx.conf
|
│ ├── nginx.conf ← 完整 nginx 配置(占位符版)
|
||||||
│ ├── conf.d/
|
│ ├── local_ip_rules.conf.example ← 本机 IP 规则文件示例(cron 自动生成,勿手改)
|
||||||
│ │ ├── api.aeolianchi.me.conf
|
│ └── conf.d/ ← 预留:额外站点配置
|
||||||
│ │ └── autodiscover.aeolianchi.me.conf
|
|
||||||
│ └── local_ip_rules.conf.example
|
|
||||||
├── sing-box/
|
├── sing-box/
|
||||||
│ └── config.json
|
│ └── config.json ← sing-box 配置(VLESS+REALITY + SS,占位符版)
|
||||||
├── traffic-monitor/
|
├── systemd/
|
||||||
│ ├── config.json
|
│ ├── nginx.service
|
||||||
│ ├── traffic_monitor.py
|
│ ├── sing-box.service
|
||||||
│ └── traffic-monitor.service
|
│ └── mtg.service
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── sync-nginx-local-ips.sh
|
│ └── sync-nginx-local-ips.sh ← 本机公网 IP 规则自动维护
|
||||||
│ └── rrpp-stats
|
├── cron/
|
||||||
├── pac/
|
│ └── sync-nginx-local-ips ← cron 定义(每 5 分钟)
|
||||||
│ ├── proxy.pac
|
└── fake-site/
|
||||||
│ └── proxy_wg.pac
|
└── index.html ← 伪装站点页面(所有节点共用同一份)
|
||||||
└── cron/
|
|
||||||
├── crontab.txt
|
|
||||||
└── sync-nginx-local-ips
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 旧的 `traffic-monitor/`、`pac/`、`conf.d/*.conf` 已下线或迁移,不再维护,已从本目录移除。
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
* * * * * /usr/local/bin/rrpp-stats | sudo tee /root/backups/stats.json > /dev/null
|
|
||||||
27 0,6,12,18 * * * "/home/RRRosmontis/.acme.sh"/acme.sh --cron --home "/home/RRRosmontis/.acme.sh" > /dev/null
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
http2 on;
|
|
||||||
server_name api.aeolianchi.me;
|
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/ssl/api.aeolianchi.me/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/nginx/ssl/api.aeolianchi.me/privkey.pem;
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
||||||
ssl_session_cache shared:SSL:10m;
|
|
||||||
ssl_session_timeout 10m;
|
|
||||||
|
|
||||||
location /vpn/status/traffic {
|
|
||||||
proxy_pass http://127.0.0.1:18080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# autodiscover.aeolianchi.me -> jp 反代 -> bj autodiscover.rrrosmontis.icu
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name autodiscover.aeolianchi.me;
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/acme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# autodiscover.aeolianchi.me 443 -> 反代 -> bj autodiscover.rrrosmontis.icu
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
http2 on;
|
|
||||||
server_name autodiscover.aeolianchi.me;
|
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/ssl/aeolianchi.me/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/nginx/ssl/aeolianchi.me/privkey.pem;
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5;
|
|
||||||
ssl_prefer_server_ciphers on;
|
|
||||||
ssl_session_cache shared:SSL:10m;
|
|
||||||
ssl_session_timeout 10m;
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000";
|
|
||||||
error_page 497 https://$host$request_uri;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass https://autodiscover.rrrosmontis.icu;
|
|
||||||
proxy_set_header Host autodiscover.rrrosmontis.icu;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_ssl_server_name on;
|
|
||||||
proxy_ssl_name autodiscover.rrrosmontis.icu;
|
|
||||||
}
|
|
||||||
|
|
||||||
access_log /var/log/nginx/autodiscover.aeolianchi.me.log;
|
|
||||||
error_log /var/log/nginx/autodiscover.aeolianchi.me.error.log;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
# nginx 本机公网 IP 直连规则(自动生成,勿手改;由 sync-nginx-local-ips.sh 维护)
|
# nginx 本机公网 IP 直连规则(自动生成,勿手改;由 sync-nginx-local-ips.sh 维护)
|
||||||
# 公网 IP 变更后本文件会被重写并 reload nginx
|
# 公网 IP 变更后本文件会被重写并 reload nginx
|
||||||
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
|
# 每条规则对应一个本机公网 IP(出站 IP + 节点域名 A 记录)
|
||||||
if ($host = "<PUBLIC_IP>") { rewrite ^(.*)$ /__local$1 last; }
|
if ($host = "<PUBLIC_IP_1>") { rewrite ^(.*)$ /__local$1 last; }
|
||||||
|
if ($host = "<PUBLIC_IP_2>") { rewrite ^(.*)$ /__local$1 last; }
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
load_module /usr/lib/nginx/modules/ngx_http_proxy_connect_module.so;
|
|
||||||
worker_processes auto;
|
worker_processes auto;
|
||||||
worker_rlimit_nofile 65535;
|
worker_rlimit_nofile 65535;
|
||||||
events {
|
events {
|
||||||
@@ -25,45 +24,15 @@ http {
|
|||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
keepalive_requests 100;
|
keepalive_requests 100;
|
||||||
|
|
||||||
# 80 端口:iam.aeolianchi.me 强制跳转到 HTTPS(保留 ACME 验证路径)
|
# <nginx端口>:主域名 HTTPS 站点 + 认证 CONNECT 代理
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen <nginx端口> ssl;
|
||||||
server_name iam.aeolianchi.me;
|
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/acme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 80 端口:默认站点只做静态伪装,不提供代理、不弹认证
|
|
||||||
server {
|
|
||||||
listen 80 default_server;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /var/www/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/acme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ =404;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
http2 on;
|
http2 on;
|
||||||
server_name XXX;
|
server_name <节点域名>;
|
||||||
root /var/www/html;
|
root /var/www/html;
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/ssl/XXX/fullchain.pem;
|
ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
|
||||||
ssl_certificate_key /etc/nginx/ssl/XXX/privkey.pem;
|
ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
ssl_session_cache shared:SSL:10m;
|
ssl_session_cache shared:SSL:10m;
|
||||||
@@ -74,14 +43,14 @@ http {
|
|||||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||||
|
|
||||||
proxy_connect;
|
proxy_connect;
|
||||||
proxy_connect_allow 443 563;
|
proxy_connect_allow all;
|
||||||
proxy_connect_connect_timeout 60s;
|
proxy_connect_connect_timeout 60s;
|
||||||
proxy_connect_read_timeout 120s;
|
proxy_connect_read_timeout 120s;
|
||||||
proxy_connect_send_timeout 120s;
|
proxy_connect_send_timeout 120s;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
auth_basic off;
|
auth_basic off;
|
||||||
if ($host != "iam.aeolianchi.me") {
|
if ($host != "<节点域名>") {
|
||||||
rewrite ^ /__proxy last;
|
rewrite ^ /__proxy last;
|
||||||
}
|
}
|
||||||
index index.html;
|
index index.html;
|
||||||
@@ -104,14 +73,14 @@ http {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 443 端口 fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
|
# <nginx端口> fallback:其他 SNI 也可用 CONNECT 代理;普通 GET 只返回伪装页
|
||||||
server {
|
server {
|
||||||
listen 443 ssl default_server;
|
listen <nginx端口> ssl default_server;
|
||||||
http2 on;
|
http2 on;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem;
|
ssl_certificate /etc/nginx/ssl/<节点域名>/fullchain.pem;
|
||||||
ssl_certificate_key /etc/nginx/ssl/iam.aeolianchi.me/privkey.pem;
|
ssl_certificate_key /etc/nginx/ssl/<节点域名>/privkey.pem;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
ssl_session_cache shared:SSL:10m;
|
ssl_session_cache shared:SSL:10m;
|
||||||
@@ -122,7 +91,7 @@ http {
|
|||||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||||
|
|
||||||
proxy_connect;
|
proxy_connect;
|
||||||
proxy_connect_allow 443 563;
|
proxy_connect_allow all;
|
||||||
proxy_connect_connect_timeout 60s;
|
proxy_connect_connect_timeout 60s;
|
||||||
proxy_connect_read_timeout 120s;
|
proxy_connect_read_timeout 120s;
|
||||||
proxy_connect_send_timeout 120s;
|
proxy_connect_send_timeout 120s;
|
||||||
|
|||||||
@@ -1,647 +0,0 @@
|
|||||||
function FindProxyForURL(url, host) {
|
|
||||||
// RRPP 规则代理列表 — 仅以下域名走加密隧道
|
|
||||||
var proxy = "PROXY 127.0.0.1:1080";
|
|
||||||
// Google 全系 + Gemini + CDN
|
|
||||||
if (shExpMatch(host, "google.com") ||
|
|
||||||
shExpMatch(host, "*.google.com") ||
|
|
||||||
shExpMatch(host, "google.com.hk") ||
|
|
||||||
shExpMatch(host, "*.google.com.hk") ||
|
|
||||||
shExpMatch(host, "google.co.jp") ||
|
|
||||||
shExpMatch(host, "*.google.co.jp") ||
|
|
||||||
shExpMatch(host, "googleapis.com") ||
|
|
||||||
shExpMatch(host, "*.googleapis.com") ||
|
|
||||||
shExpMatch(host, "googleusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.googleusercontent.com") ||
|
|
||||||
shExpMatch(host, "googletagmanager.com") ||
|
|
||||||
shExpMatch(host, "*.googletagmanager.com") ||
|
|
||||||
shExpMatch(host, "google-analytics.com") ||
|
|
||||||
shExpMatch(host, "*.google-analytics.com") ||
|
|
||||||
shExpMatch(host, "gstatic.com") ||
|
|
||||||
shExpMatch(host, "*.gstatic.com") ||
|
|
||||||
shExpMatch(host, "ggpht.com") ||
|
|
||||||
shExpMatch(host, "*.ggpht.com") ||
|
|
||||||
shExpMatch(host, "googlehosted.com") ||
|
|
||||||
shExpMatch(host, "*.googlehosted.com") ||
|
|
||||||
shExpMatch(host, "1e100.net") ||
|
|
||||||
shExpMatch(host, "*.1e100.net") ||
|
|
||||||
shExpMatch(host, "youtube.com") ||
|
|
||||||
shExpMatch(host, "*.youtube.com") ||
|
|
||||||
shExpMatch(host, "ytimg.com") ||
|
|
||||||
shExpMatch(host, "*.ytimg.com") ||
|
|
||||||
shExpMatch(host, "googlevideo.com") ||
|
|
||||||
shExpMatch(host, "*.googlevideo.com") ||
|
|
||||||
shExpMatch(host, "gemini.google.com") ||
|
|
||||||
shExpMatch(host, "*.gemini.google.com") ||
|
|
||||||
shExpMatch(host, "generativeai.googleapis.com") ||
|
|
||||||
shExpMatch(host, "*.generativeai.googleapis.com") ||
|
|
||||||
shExpMatch(host, "aistudio.google.com") ||
|
|
||||||
shExpMatch(host, "*.aistudio.google.com") ||
|
|
||||||
shExpMatch(host, "deepmind.google.com") ||
|
|
||||||
shExpMatch(host, "*.deepmind.google.com") ||
|
|
||||||
shExpMatch(host, "deepmind.com") ||
|
|
||||||
shExpMatch(host, "*.deepmind.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenAI / ChatGPT 全系 + CDN
|
|
||||||
if (shExpMatch(host, "openai.com") ||
|
|
||||||
shExpMatch(host, "*.openai.com") ||
|
|
||||||
shExpMatch(host, "chatgpt.com") ||
|
|
||||||
shExpMatch(host, "*.chatgpt.com") ||
|
|
||||||
shExpMatch(host, "oaistatic.com") ||
|
|
||||||
shExpMatch(host, "*.oaistatic.com") ||
|
|
||||||
shExpMatch(host, "oaiusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.oaiusercontent.com") ||
|
|
||||||
shExpMatch(host, "openaiusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.openaiusercontent.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grok / xAI 全系 + CDN
|
|
||||||
if (shExpMatch(host, "grok.com") ||
|
|
||||||
shExpMatch(host, "*.grok.com") ||
|
|
||||||
shExpMatch(host, "x.com") ||
|
|
||||||
shExpMatch(host, "*.x.com") ||
|
|
||||||
shExpMatch(host, "twitter.com") ||
|
|
||||||
shExpMatch(host, "*.twitter.com") ||
|
|
||||||
shExpMatch(host, "twimg.com") ||
|
|
||||||
shExpMatch(host, "*.twimg.com") ||
|
|
||||||
shExpMatch(host, "x.ai") ||
|
|
||||||
shExpMatch(host, "*.x.ai") ||
|
|
||||||
shExpMatch(host, "xai.com") ||
|
|
||||||
shExpMatch(host, "*.xai.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Meta 全系 + CDN
|
|
||||||
if (shExpMatch(host, "meta.com") ||
|
|
||||||
shExpMatch(host, "*.meta.com") ||
|
|
||||||
shExpMatch(host, "meta.ai") ||
|
|
||||||
shExpMatch(host, "*.meta.ai") ||
|
|
||||||
shExpMatch(host, "facebook.com") ||
|
|
||||||
shExpMatch(host, "*.facebook.com") ||
|
|
||||||
shExpMatch(host, "fb.com") ||
|
|
||||||
shExpMatch(host, "*.fb.com") ||
|
|
||||||
shExpMatch(host, "fbcdn.net") ||
|
|
||||||
shExpMatch(host, "*.fbcdn.net") ||
|
|
||||||
shExpMatch(host, "fbsbx.com") ||
|
|
||||||
shExpMatch(host, "*.fbsbx.com") ||
|
|
||||||
shExpMatch(host, "instagram.com") ||
|
|
||||||
shExpMatch(host, "*.instagram.com") ||
|
|
||||||
shExpMatch(host, "cdninstagram.com") ||
|
|
||||||
shExpMatch(host, "*.cdninstagram.com") ||
|
|
||||||
shExpMatch(host, "threads.net") ||
|
|
||||||
shExpMatch(host, "*.threads.net") ||
|
|
||||||
shExpMatch(host, "whatsapp.com") ||
|
|
||||||
shExpMatch(host, "*.whatsapp.com") ||
|
|
||||||
shExpMatch(host, "whatsapp.net") ||
|
|
||||||
shExpMatch(host, "*.whatsapp.net") ||
|
|
||||||
shExpMatch(host, "messenger.com") ||
|
|
||||||
shExpMatch(host, "*.messenger.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claude / Anthropic 全系 + CDN
|
|
||||||
if (shExpMatch(host, "anthropic.com") ||
|
|
||||||
shExpMatch(host, "*.anthropic.com") ||
|
|
||||||
shExpMatch(host, "claude.ai") ||
|
|
||||||
shExpMatch(host, "*.claude.ai") ||
|
|
||||||
shExpMatch(host, "claudeusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.claudeusercontent.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dify 全系
|
|
||||||
if (shExpMatch(host, "dify.ai") ||
|
|
||||||
shExpMatch(host, "*.dify.ai") ||
|
|
||||||
shExpMatch(host, "udify.app") ||
|
|
||||||
shExpMatch(host, "*.udify.app")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cloudflare 全系
|
|
||||||
if (shExpMatch(host, "cloudflare.com") ||
|
|
||||||
shExpMatch(host, "*.cloudflare.com") ||
|
|
||||||
shExpMatch(host, "cloudflare.net") ||
|
|
||||||
shExpMatch(host, "*.cloudflare.net") ||
|
|
||||||
shExpMatch(host, "cloudflareinsights.com") ||
|
|
||||||
shExpMatch(host, "*.cloudflareinsights.com") ||
|
|
||||||
shExpMatch(host, "cloudflarestatus.com") ||
|
|
||||||
shExpMatch(host, "*.cloudflarestatus.com") ||
|
|
||||||
shExpMatch(host, "cloudflarecdn.com") ||
|
|
||||||
shExpMatch(host, "*.cloudflarecdn.com") ||
|
|
||||||
shExpMatch(host, "cf-cdn.com") ||
|
|
||||||
shExpMatch(host, "*.cf-cdn.com") ||
|
|
||||||
shExpMatch(host, "workers.dev") ||
|
|
||||||
shExpMatch(host, "*.workers.dev") ||
|
|
||||||
shExpMatch(host, "pages.dev") ||
|
|
||||||
shExpMatch(host, "*.pages.dev")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitHub 全系
|
|
||||||
if (shExpMatch(host, "github.com") ||
|
|
||||||
shExpMatch(host, "*.github.com") ||
|
|
||||||
shExpMatch(host, "githubusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.githubusercontent.com") ||
|
|
||||||
shExpMatch(host, "githubassets.com") ||
|
|
||||||
shExpMatch(host, "*.githubassets.com") ||
|
|
||||||
shExpMatch(host, "github.io") ||
|
|
||||||
shExpMatch(host, "*.github.io") ||
|
|
||||||
shExpMatch(host, "githubcopilot.com") ||
|
|
||||||
shExpMatch(host, "*.githubcopilot.com") ||
|
|
||||||
shExpMatch(host, "ghcr.io") ||
|
|
||||||
shExpMatch(host, "*.ghcr.io")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hugging Face 全系 + CDN
|
|
||||||
if (shExpMatch(host, "huggingface.co") ||
|
|
||||||
shExpMatch(host, "*.huggingface.co") ||
|
|
||||||
shExpMatch(host, "hf.co") ||
|
|
||||||
shExpMatch(host, "*.hf.co") ||
|
|
||||||
shExpMatch(host, "huggingfacecdn.com") ||
|
|
||||||
shExpMatch(host, "*.huggingfacecdn.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Microsoft Copilot 全系 + Bing + Azure CDN
|
|
||||||
if (shExpMatch(host, "microsoft.com") ||
|
|
||||||
shExpMatch(host, "*.microsoft.com") ||
|
|
||||||
shExpMatch(host, "bing.com") ||
|
|
||||||
shExpMatch(host, "*.bing.com") ||
|
|
||||||
shExpMatch(host, "bingapis.com") ||
|
|
||||||
shExpMatch(host, "*.bingapis.com") ||
|
|
||||||
shExpMatch(host, "bing.net") ||
|
|
||||||
shExpMatch(host, "*.bing.net") ||
|
|
||||||
shExpMatch(host, "live.com") ||
|
|
||||||
shExpMatch(host, "*.live.com") ||
|
|
||||||
shExpMatch(host, "microsoftonline.com") ||
|
|
||||||
shExpMatch(host, "*.microsoftonline.com") ||
|
|
||||||
shExpMatch(host, "microsoft365.com") ||
|
|
||||||
shExpMatch(host, "*.microsoft365.com") ||
|
|
||||||
shExpMatch(host, "office.com") ||
|
|
||||||
shExpMatch(host, "*.office.com") ||
|
|
||||||
shExpMatch(host, "office.net") ||
|
|
||||||
shExpMatch(host, "*.office.net") ||
|
|
||||||
shExpMatch(host, "azure.com") ||
|
|
||||||
shExpMatch(host, "*.azure.com") ||
|
|
||||||
shExpMatch(host, "azureedge.net") ||
|
|
||||||
shExpMatch(host, "*.azureedge.net") ||
|
|
||||||
shExpMatch(host, "azurefd.net") ||
|
|
||||||
shExpMatch(host, "*.azurefd.net") ||
|
|
||||||
shExpMatch(host, "msn.com") ||
|
|
||||||
shExpMatch(host, "*.msn.com") ||
|
|
||||||
shExpMatch(host, "msecnd.net") ||
|
|
||||||
shExpMatch(host, "*.msecnd.net") ||
|
|
||||||
shExpMatch(host, "msftncsi.com") ||
|
|
||||||
shExpMatch(host, "*.msftncsi.com") ||
|
|
||||||
shExpMatch(host, "s-microsoft.com") ||
|
|
||||||
shExpMatch(host, "*.s-microsoft.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Steam 全系 + CDN / 社区 / 下载
|
|
||||||
if (shExpMatch(host, "steampowered.com") ||
|
|
||||||
shExpMatch(host, "*.steampowered.com") ||
|
|
||||||
shExpMatch(host, "steamcommunity.com") ||
|
|
||||||
shExpMatch(host, "*.steamcommunity.com") ||
|
|
||||||
shExpMatch(host, "steam.com") ||
|
|
||||||
shExpMatch(host, "*.steam.com") ||
|
|
||||||
shExpMatch(host, "steamgames.com") ||
|
|
||||||
shExpMatch(host, "*.steamgames.com") ||
|
|
||||||
shExpMatch(host, "steamusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.steamusercontent.com") ||
|
|
||||||
shExpMatch(host, "steamcontent.com") ||
|
|
||||||
shExpMatch(host, "*.steamcontent.com") ||
|
|
||||||
shExpMatch(host, "steamstatic.com") ||
|
|
||||||
shExpMatch(host, "*.steamstatic.com") ||
|
|
||||||
shExpMatch(host, "steam-chat.com") ||
|
|
||||||
shExpMatch(host, "*.steam-chat.com") ||
|
|
||||||
shExpMatch(host, "steam-api.com") ||
|
|
||||||
shExpMatch(host, "*.steam-api.com") ||
|
|
||||||
shExpMatch(host, "steamserver.net") ||
|
|
||||||
shExpMatch(host, "*.steamserver.net") ||
|
|
||||||
shExpMatch(host, "steamcdn-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "*.steamcdn-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "steamcommunity-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "*.steamcommunity-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "steamstore-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "*.steamstore-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "steamuserimages-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "*.steamuserimages-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "akamaihd.net") ||
|
|
||||||
shExpMatch(host, "*.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "valvesoftware.com") ||
|
|
||||||
shExpMatch(host, "*.valvesoftware.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discord 全系
|
|
||||||
if (shExpMatch(host, "discord.com") ||
|
|
||||||
shExpMatch(host, "*.discord.com") ||
|
|
||||||
shExpMatch(host, "discordapp.com") ||
|
|
||||||
shExpMatch(host, "*.discordapp.com") ||
|
|
||||||
shExpMatch(host, "discord.gg") ||
|
|
||||||
shExpMatch(host, "*.discord.gg") ||
|
|
||||||
shExpMatch(host, "discord.media") ||
|
|
||||||
shExpMatch(host, "*.discord.media") ||
|
|
||||||
shExpMatch(host, "discordapp.net") ||
|
|
||||||
shExpMatch(host, "*.discordapp.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Telegram 全系
|
|
||||||
if (shExpMatch(host, "telegram.org") ||
|
|
||||||
shExpMatch(host, "*.telegram.org") ||
|
|
||||||
shExpMatch(host, "t.me") ||
|
|
||||||
shExpMatch(host, "*.t.me") ||
|
|
||||||
shExpMatch(host, "telega.one") ||
|
|
||||||
shExpMatch(host, "*.telega.one") ||
|
|
||||||
shExpMatch(host, "telegram-cdn.org") ||
|
|
||||||
shExpMatch(host, "*.telegram-cdn.org")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reddit 全系
|
|
||||||
if (shExpMatch(host, "reddit.com") ||
|
|
||||||
shExpMatch(host, "*.reddit.com") ||
|
|
||||||
shExpMatch(host, "redd.it") ||
|
|
||||||
shExpMatch(host, "*.redd.it") ||
|
|
||||||
shExpMatch(host, "redditmedia.com") ||
|
|
||||||
shExpMatch(host, "*.redditmedia.com") ||
|
|
||||||
shExpMatch(host, "redditstatic.com") ||
|
|
||||||
shExpMatch(host, "*.redditstatic.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// LINE 全系
|
|
||||||
if (shExpMatch(host, "line.me") ||
|
|
||||||
shExpMatch(host, "*.line.me") ||
|
|
||||||
shExpMatch(host, "line-apps.com") ||
|
|
||||||
shExpMatch(host, "*.line-apps.com") ||
|
|
||||||
shExpMatch(host, "line-scdn.net") ||
|
|
||||||
shExpMatch(host, "*.line-scdn.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spotify 全系
|
|
||||||
if (shExpMatch(host, "spotify.com") ||
|
|
||||||
shExpMatch(host, "*.spotify.com") ||
|
|
||||||
shExpMatch(host, "spotifycdn.com") ||
|
|
||||||
shExpMatch(host, "*.spotifycdn.com") ||
|
|
||||||
shExpMatch(host, "scdn.co") ||
|
|
||||||
shExpMatch(host, "*.scdn.co")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Netflix 全系
|
|
||||||
if (shExpMatch(host, "netflix.com") ||
|
|
||||||
shExpMatch(host, "*.netflix.com") ||
|
|
||||||
shExpMatch(host, "nflxvideo.net") ||
|
|
||||||
shExpMatch(host, "*.nflxvideo.net") ||
|
|
||||||
shExpMatch(host, "nflximg.net") ||
|
|
||||||
shExpMatch(host, "*.nflximg.net") ||
|
|
||||||
shExpMatch(host, "nflxext.com") ||
|
|
||||||
shExpMatch(host, "*.nflxext.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disney+ 全系
|
|
||||||
if (shExpMatch(host, "disneyplus.com") ||
|
|
||||||
shExpMatch(host, "*.disneyplus.com") ||
|
|
||||||
shExpMatch(host, "disney-plus.net") ||
|
|
||||||
shExpMatch(host, "*.disney-plus.net") ||
|
|
||||||
shExpMatch(host, "dssott.com") ||
|
|
||||||
shExpMatch(host, "*.dssott.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TikTok 全系
|
|
||||||
if (shExpMatch(host, "tiktok.com") ||
|
|
||||||
shExpMatch(host, "*.tiktok.com") ||
|
|
||||||
shExpMatch(host, "tiktokcdn.com") ||
|
|
||||||
shExpMatch(host, "*.tiktokcdn.com") ||
|
|
||||||
shExpMatch(host, "tiktokv.com") ||
|
|
||||||
shExpMatch(host, "*.tiktokv.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlayStation 全系
|
|
||||||
if (shExpMatch(host, "playstation.com") ||
|
|
||||||
shExpMatch(host, "*.playstation.com") ||
|
|
||||||
shExpMatch(host, "sonyentertainmentnetwork.com") ||
|
|
||||||
shExpMatch(host, "*.sonyentertainmentnetwork.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nintendo 全系
|
|
||||||
if (shExpMatch(host, "nintendo.com") ||
|
|
||||||
shExpMatch(host, "*.nintendo.com") ||
|
|
||||||
shExpMatch(host, "nintendowifi.net") ||
|
|
||||||
shExpMatch(host, "*.nintendowifi.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Epic Games 全系
|
|
||||||
if (shExpMatch(host, "epicgames.com") ||
|
|
||||||
shExpMatch(host, "*.epicgames.com") ||
|
|
||||||
shExpMatch(host, "unrealengine.com") ||
|
|
||||||
shExpMatch(host, "*.unrealengine.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DuckDuckGo / Brave Search
|
|
||||||
if (shExpMatch(host, "duckduckgo.com") ||
|
|
||||||
shExpMatch(host, "*.duckduckgo.com") ||
|
|
||||||
shExpMatch(host, "search.brave.com") ||
|
|
||||||
shExpMatch(host, "*.search.brave.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proton 全系
|
|
||||||
if (shExpMatch(host, "proton.me") ||
|
|
||||||
shExpMatch(host, "*.proton.me") ||
|
|
||||||
shExpMatch(host, "protonmail.com") ||
|
|
||||||
shExpMatch(host, "*.protonmail.com") ||
|
|
||||||
shExpMatch(host, "protonvpn.com") ||
|
|
||||||
shExpMatch(host, "*.protonvpn.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stack Overflow / Stack Exchange 全系
|
|
||||||
if (shExpMatch(host, "stackoverflow.com") ||
|
|
||||||
shExpMatch(host, "*.stackoverflow.com") ||
|
|
||||||
shExpMatch(host, "stackexchange.com") ||
|
|
||||||
shExpMatch(host, "*.stackexchange.com") ||
|
|
||||||
shExpMatch(host, "superuser.com") ||
|
|
||||||
shExpMatch(host, "*.superuser.com") ||
|
|
||||||
shExpMatch(host, "serverfault.com") ||
|
|
||||||
shExpMatch(host, "*.serverfault.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quora / Medium
|
|
||||||
if (shExpMatch(host, "quora.com") ||
|
|
||||||
shExpMatch(host, "*.quora.com") ||
|
|
||||||
shExpMatch(host, "medium.com") ||
|
|
||||||
shExpMatch(host, "*.medium.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pixiv 全系
|
|
||||||
if (shExpMatch(host, "pixiv.net") ||
|
|
||||||
shExpMatch(host, "*.pixiv.net") ||
|
|
||||||
shExpMatch(host, "pximg.net") ||
|
|
||||||
shExpMatch(host, "*.pximg.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ニコニコ 全系
|
|
||||||
if (shExpMatch(host, "nicovideo.jp") ||
|
|
||||||
shExpMatch(host, "*.nicovideo.jp") ||
|
|
||||||
shExpMatch(host, "nico.ms") ||
|
|
||||||
shExpMatch(host, "*.nico.ms")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// EA / Origin / Apex 全系
|
|
||||||
if (shExpMatch(host, "ea.com") ||
|
|
||||||
shExpMatch(host, "*.ea.com") ||
|
|
||||||
shExpMatch(host, "origin.com") ||
|
|
||||||
shExpMatch(host, "*.origin.com") ||
|
|
||||||
shExpMatch(host, "eaassets.com") ||
|
|
||||||
shExpMatch(host, "*.eaassets.com") ||
|
|
||||||
shExpMatch(host, "apexlegends.com") ||
|
|
||||||
shExpMatch(host, "*.apexlegends.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Battle.net / Blizzard 全系
|
|
||||||
if (shExpMatch(host, "battle.net") ||
|
|
||||||
shExpMatch(host, "*.battle.net") ||
|
|
||||||
shExpMatch(host, "blizzard.com") ||
|
|
||||||
shExpMatch(host, "*.blizzard.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ubisoft 全系
|
|
||||||
if (shExpMatch(host, "ubisoft.com") ||
|
|
||||||
shExpMatch(host, "*.ubisoft.com") ||
|
|
||||||
shExpMatch(host, "ubi.com") ||
|
|
||||||
shExpMatch(host, "*.ubi.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Riot Games / 英雄联盟 / Valorant 全系
|
|
||||||
if (shExpMatch(host, "riotgames.com") ||
|
|
||||||
shExpMatch(host, "*.riotgames.com") ||
|
|
||||||
shExpMatch(host, "leagueoflegends.com") ||
|
|
||||||
shExpMatch(host, "*.leagueoflegends.com") ||
|
|
||||||
shExpMatch(host, "valorant.com") ||
|
|
||||||
shExpMatch(host, "*.valorant.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GOG / itch.io 全系
|
|
||||||
if (shExpMatch(host, "gog.com") ||
|
|
||||||
shExpMatch(host, "*.gog.com") ||
|
|
||||||
shExpMatch(host, "itch.io") ||
|
|
||||||
shExpMatch(host, "*.itch.io")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Twitch 全系
|
|
||||||
if (shExpMatch(host, "twitch.tv") ||
|
|
||||||
shExpMatch(host, "*.twitch.tv") ||
|
|
||||||
shExpMatch(host, "ttvnw.net") ||
|
|
||||||
shExpMatch(host, "*.ttvnw.net") ||
|
|
||||||
shExpMatch(host, "twitchcdn.net") ||
|
|
||||||
shExpMatch(host, "*.twitchcdn.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crunchyroll 全系
|
|
||||||
if (shExpMatch(host, "crunchyroll.com") ||
|
|
||||||
shExpMatch(host, "*.crunchyroll.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Amazon Prime Video 全系
|
|
||||||
if (shExpMatch(host, "primevideo.com") ||
|
|
||||||
shExpMatch(host, "*.primevideo.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apple TV+ 全系
|
|
||||||
if (shExpMatch(host, "tv.apple.com") ||
|
|
||||||
shExpMatch(host, "tvplus.apple.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HBO Max / Hulu / Paramount+ 全系
|
|
||||||
if (shExpMatch(host, "max.com") ||
|
|
||||||
shExpMatch(host, "*.max.com") ||
|
|
||||||
shExpMatch(host, "hulu.com") ||
|
|
||||||
shExpMatch(host, "*.hulu.com") ||
|
|
||||||
shExpMatch(host, "paramountplus.com") ||
|
|
||||||
shExpMatch(host, "*.paramountplus.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slack 全系
|
|
||||||
if (shExpMatch(host, "slack.com") ||
|
|
||||||
shExpMatch(host, "*.slack.com") ||
|
|
||||||
shExpMatch(host, "slack-edge.com") ||
|
|
||||||
shExpMatch(host, "*.slack-edge.com") ||
|
|
||||||
shExpMatch(host, "slack-imgs.com") ||
|
|
||||||
shExpMatch(host, "*.slack-imgs.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zoom 全系
|
|
||||||
if (shExpMatch(host, "zoom.us") ||
|
|
||||||
shExpMatch(host, "*.zoom.us") ||
|
|
||||||
shExpMatch(host, "zoom.com") ||
|
|
||||||
shExpMatch(host, "*.zoom.com") ||
|
|
||||||
shExpMatch(host, "zoomgov.com") ||
|
|
||||||
shExpMatch(host, "*.zoomgov.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal 全系
|
|
||||||
if (shExpMatch(host, "signal.org") ||
|
|
||||||
shExpMatch(host, "*.signal.org")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docker 全系
|
|
||||||
if (shExpMatch(host, "docker.com") ||
|
|
||||||
shExpMatch(host, "*.docker.com") ||
|
|
||||||
shExpMatch(host, "docker.io") ||
|
|
||||||
shExpMatch(host, "*.docker.io")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitLab 全系
|
|
||||||
if (shExpMatch(host, "gitlab.com") ||
|
|
||||||
shExpMatch(host, "*.gitlab.com") ||
|
|
||||||
shExpMatch(host, "gitlab.io") ||
|
|
||||||
shExpMatch(host, "*.gitlab.io")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vercel 全系
|
|
||||||
if (shExpMatch(host, "vercel.com") ||
|
|
||||||
shExpMatch(host, "*.vercel.com") ||
|
|
||||||
shExpMatch(host, "vercel.app") ||
|
|
||||||
shExpMatch(host, "*.vercel.app")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Netlify 全系
|
|
||||||
if (shExpMatch(host, "netlify.com") ||
|
|
||||||
shExpMatch(host, "*.netlify.com") ||
|
|
||||||
shExpMatch(host, "netlify.app") ||
|
|
||||||
shExpMatch(host, "*.netlify.app")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perplexity / Poe / Midjourney 全系
|
|
||||||
if (shExpMatch(host, "perplexity.ai") ||
|
|
||||||
shExpMatch(host, "*.perplexity.ai") ||
|
|
||||||
shExpMatch(host, "poe.com") ||
|
|
||||||
shExpMatch(host, "*.poe.com") ||
|
|
||||||
shExpMatch(host, "midjourney.com") ||
|
|
||||||
shExpMatch(host, "*.midjourney.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Figma / Canva 全系
|
|
||||||
if (shExpMatch(host, "figma.com") ||
|
|
||||||
shExpMatch(host, "*.figma.com") ||
|
|
||||||
shExpMatch(host, "canva.com") ||
|
|
||||||
shExpMatch(host, "*.canva.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// LinkedIn 全系
|
|
||||||
if (shExpMatch(host, "linkedin.com") ||
|
|
||||||
shExpMatch(host, "*.linkedin.com") ||
|
|
||||||
shExpMatch(host, "licdn.com") ||
|
|
||||||
shExpMatch(host, "*.licdn.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pinterest 全系
|
|
||||||
if (shExpMatch(host, "pinterest.com") ||
|
|
||||||
shExpMatch(host, "*.pinterest.com") ||
|
|
||||||
shExpMatch(host, "pinimg.com") ||
|
|
||||||
shExpMatch(host, "*.pinimg.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tumblr 全系
|
|
||||||
if (shExpMatch(host, "tumblr.com") ||
|
|
||||||
shExpMatch(host, "*.tumblr.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapchat 全系
|
|
||||||
if (shExpMatch(host, "snapchat.com") ||
|
|
||||||
shExpMatch(host, "*.snapchat.com") ||
|
|
||||||
shExpMatch(host, "sc-cdn.net") ||
|
|
||||||
shExpMatch(host, "*.sc-cdn.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wikipedia / Wikimedia 全系
|
|
||||||
if (shExpMatch(host, "wikipedia.org") ||
|
|
||||||
shExpMatch(host, "*.wikipedia.org") ||
|
|
||||||
shExpMatch(host, "wikimedia.org") ||
|
|
||||||
shExpMatch(host, "*.wikimedia.org")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PayPal 全系
|
|
||||||
if (shExpMatch(host, "paypal.com") ||
|
|
||||||
shExpMatch(host, "*.paypal.com") ||
|
|
||||||
shExpMatch(host, "paypalobjects.com") ||
|
|
||||||
shExpMatch(host, "*.paypalobjects.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patreon 全系
|
|
||||||
if (shExpMatch(host, "patreon.com") ||
|
|
||||||
shExpMatch(host, "*.patreon.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通用 CDN
|
|
||||||
if (shExpMatch(host, "cloudfront.net") ||
|
|
||||||
shExpMatch(host, "*.cloudfront.net") ||
|
|
||||||
shExpMatch(host, "fastly.net") ||
|
|
||||||
shExpMatch(host, "*.fastly.net") ||
|
|
||||||
shExpMatch(host, "fastlylb.net") ||
|
|
||||||
shExpMatch(host, "*.fastlylb.net") ||
|
|
||||||
shExpMatch(host, "jsdelivr.net") ||
|
|
||||||
shExpMatch(host, "*.jsdelivr.net") ||
|
|
||||||
shExpMatch(host, "unpkg.com") ||
|
|
||||||
shExpMatch(host, "*.unpkg.com") ||
|
|
||||||
shExpMatch(host, "statically.io") ||
|
|
||||||
shExpMatch(host, "*.statically.io") ||
|
|
||||||
shExpMatch(host, "cloudinary.com") ||
|
|
||||||
shExpMatch(host, "*.cloudinary.com") ||
|
|
||||||
shExpMatch(host, "imgur.com") ||
|
|
||||||
shExpMatch(host, "*.imgur.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// linux.do
|
|
||||||
if (shExpMatch(host, "linux.do") ||
|
|
||||||
shExpMatch(host, "*.linux.do")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "DIRECT";
|
|
||||||
}
|
|
||||||
@@ -1,648 +0,0 @@
|
|||||||
function FindProxyForURL(url, host) {
|
|
||||||
// RRPP 规则代理列表 — 仅以下域名走加密隧道
|
|
||||||
var proxy = "PROXY 127.0.0.1:1080";
|
|
||||||
// Google 全系 + Gemini + CDN
|
|
||||||
if (shExpMatch(host, "google.com") ||
|
|
||||||
shExpMatch(host, "*.google.com") ||
|
|
||||||
shExpMatch(host, "google.com.hk") ||
|
|
||||||
shExpMatch(host, "*.google.com.hk") ||
|
|
||||||
shExpMatch(host, "google.co.jp") ||
|
|
||||||
shExpMatch(host, "*.google.co.jp") ||
|
|
||||||
shExpMatch(host, "googleapis.com") ||
|
|
||||||
shExpMatch(host, "*.googleapis.com") ||
|
|
||||||
shExpMatch(host, "googleusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.googleusercontent.com") ||
|
|
||||||
shExpMatch(host, "googletagmanager.com") ||
|
|
||||||
shExpMatch(host, "*.googletagmanager.com") ||
|
|
||||||
shExpMatch(host, "google-analytics.com") ||
|
|
||||||
shExpMatch(host, "*.google-analytics.com") ||
|
|
||||||
shExpMatch(host, "gstatic.com") ||
|
|
||||||
shExpMatch(host, "*.gstatic.com") ||
|
|
||||||
shExpMatch(host, "ggpht.com") ||
|
|
||||||
shExpMatch(host, "*.ggpht.com") ||
|
|
||||||
shExpMatch(host, "googlehosted.com") ||
|
|
||||||
shExpMatch(host, "*.googlehosted.com") ||
|
|
||||||
shExpMatch(host, "1e100.net") ||
|
|
||||||
shExpMatch(host, "*.1e100.net") ||
|
|
||||||
shExpMatch(host, "youtube.com") ||
|
|
||||||
shExpMatch(host, "*.youtube.com") ||
|
|
||||||
shExpMatch(host, "ytimg.com") ||
|
|
||||||
shExpMatch(host, "*.ytimg.com") ||
|
|
||||||
shExpMatch(host, "googlevideo.com") ||
|
|
||||||
shExpMatch(host, "*.googlevideo.com") ||
|
|
||||||
shExpMatch(host, "gemini.google.com") ||
|
|
||||||
shExpMatch(host, "*.gemini.google.com") ||
|
|
||||||
shExpMatch(host, "generativeai.googleapis.com") ||
|
|
||||||
shExpMatch(host, "*.generativeai.googleapis.com") ||
|
|
||||||
shExpMatch(host, "aistudio.google.com") ||
|
|
||||||
shExpMatch(host, "*.aistudio.google.com") ||
|
|
||||||
shExpMatch(host, "deepmind.google.com") ||
|
|
||||||
shExpMatch(host, "*.deepmind.google.com") ||
|
|
||||||
shExpMatch(host, "deepmind.com") ||
|
|
||||||
shExpMatch(host, "*.deepmind.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenAI / ChatGPT 全系 + CDN
|
|
||||||
if (shExpMatch(host, "openai.com") ||
|
|
||||||
shExpMatch(host, "*.openai.com") ||
|
|
||||||
shExpMatch(host, "chatgpt.com") ||
|
|
||||||
shExpMatch(host, "*.chatgpt.com") ||
|
|
||||||
shExpMatch(host, "oaistatic.com") ||
|
|
||||||
shExpMatch(host, "*.oaistatic.com") ||
|
|
||||||
shExpMatch(host, "oaiusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.oaiusercontent.com") ||
|
|
||||||
shExpMatch(host, "openaiusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.openaiusercontent.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grok / xAI 全系 + CDN
|
|
||||||
if (shExpMatch(host, "grok.com") ||
|
|
||||||
shExpMatch(host, "*.grok.com") ||
|
|
||||||
shExpMatch(host, "x.com") ||
|
|
||||||
shExpMatch(host, "*.x.com") ||
|
|
||||||
shExpMatch(host, "twitter.com") ||
|
|
||||||
shExpMatch(host, "*.twitter.com") ||
|
|
||||||
shExpMatch(host, "twimg.com") ||
|
|
||||||
shExpMatch(host, "*.twimg.com") ||
|
|
||||||
shExpMatch(host, "x.ai") ||
|
|
||||||
shExpMatch(host, "*.x.ai") ||
|
|
||||||
shExpMatch(host, "xai.com") ||
|
|
||||||
shExpMatch(host, "*.xai.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Meta 全系 + CDN
|
|
||||||
if (shExpMatch(host, "meta.com") ||
|
|
||||||
shExpMatch(host, "*.meta.com") ||
|
|
||||||
shExpMatch(host, "meta.ai") ||
|
|
||||||
shExpMatch(host, "*.meta.ai") ||
|
|
||||||
shExpMatch(host, "facebook.com") ||
|
|
||||||
shExpMatch(host, "*.facebook.com") ||
|
|
||||||
shExpMatch(host, "fb.com") ||
|
|
||||||
shExpMatch(host, "*.fb.com") ||
|
|
||||||
shExpMatch(host, "fbcdn.net") ||
|
|
||||||
shExpMatch(host, "*.fbcdn.net") ||
|
|
||||||
shExpMatch(host, "fbsbx.com") ||
|
|
||||||
shExpMatch(host, "*.fbsbx.com") ||
|
|
||||||
shExpMatch(host, "instagram.com") ||
|
|
||||||
shExpMatch(host, "*.instagram.com") ||
|
|
||||||
shExpMatch(host, "cdninstagram.com") ||
|
|
||||||
shExpMatch(host, "*.cdninstagram.com") ||
|
|
||||||
shExpMatch(host, "threads.net") ||
|
|
||||||
shExpMatch(host, "*.threads.net") ||
|
|
||||||
shExpMatch(host, "whatsapp.com") ||
|
|
||||||
shExpMatch(host, "*.whatsapp.com") ||
|
|
||||||
shExpMatch(host, "whatsapp.net") ||
|
|
||||||
shExpMatch(host, "*.whatsapp.net") ||
|
|
||||||
shExpMatch(host, "messenger.com") ||
|
|
||||||
shExpMatch(host, "*.messenger.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claude / Anthropic 全系 + CDN
|
|
||||||
if (shExpMatch(host, "anthropic.com") ||
|
|
||||||
shExpMatch(host, "*.anthropic.com") ||
|
|
||||||
shExpMatch(host, "claude.ai") ||
|
|
||||||
shExpMatch(host, "*.claude.ai") ||
|
|
||||||
shExpMatch(host, "claudeusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.claudeusercontent.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dify 全系
|
|
||||||
if (shExpMatch(host, "dify.ai") ||
|
|
||||||
shExpMatch(host, "*.dify.ai") ||
|
|
||||||
shExpMatch(host, "udify.app") ||
|
|
||||||
shExpMatch(host, "*.udify.app")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cloudflare 全系
|
|
||||||
if (shExpMatch(host, "cloudflare.com") ||
|
|
||||||
shExpMatch(host, "*.cloudflare.com") ||
|
|
||||||
shExpMatch(host, "cloudflare.net") ||
|
|
||||||
shExpMatch(host, "*.cloudflare.net") ||
|
|
||||||
shExpMatch(host, "cloudflareinsights.com") ||
|
|
||||||
shExpMatch(host, "*.cloudflareinsights.com") ||
|
|
||||||
shExpMatch(host, "cloudflarestatus.com") ||
|
|
||||||
shExpMatch(host, "*.cloudflarestatus.com") ||
|
|
||||||
shExpMatch(host, "cloudflarecdn.com") ||
|
|
||||||
shExpMatch(host, "*.cloudflarecdn.com") ||
|
|
||||||
shExpMatch(host, "cf-cdn.com") ||
|
|
||||||
shExpMatch(host, "*.cf-cdn.com") ||
|
|
||||||
shExpMatch(host, "workers.dev") ||
|
|
||||||
shExpMatch(host, "*.workers.dev") ||
|
|
||||||
shExpMatch(host, "pages.dev") ||
|
|
||||||
shExpMatch(host, "*.pages.dev")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitHub 全系
|
|
||||||
if (shExpMatch(host, "github.com") ||
|
|
||||||
shExpMatch(host, "*.github.com") ||
|
|
||||||
shExpMatch(host, "githubusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.githubusercontent.com") ||
|
|
||||||
shExpMatch(host, "githubassets.com") ||
|
|
||||||
shExpMatch(host, "*.githubassets.com") ||
|
|
||||||
shExpMatch(host, "github.io") ||
|
|
||||||
shExpMatch(host, "*.github.io") ||
|
|
||||||
shExpMatch(host, "githubcopilot.com") ||
|
|
||||||
shExpMatch(host, "*.githubcopilot.com") ||
|
|
||||||
shExpMatch(host, "ghcr.io") ||
|
|
||||||
shExpMatch(host, "*.ghcr.io")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hugging Face 全系 + CDN
|
|
||||||
if (shExpMatch(host, "huggingface.co") ||
|
|
||||||
shExpMatch(host, "*.huggingface.co") ||
|
|
||||||
shExpMatch(host, "hf.co") ||
|
|
||||||
shExpMatch(host, "*.hf.co") ||
|
|
||||||
shExpMatch(host, "huggingfacecdn.com") ||
|
|
||||||
shExpMatch(host, "*.huggingfacecdn.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Microsoft Copilot 全系 + Bing + Azure CDN
|
|
||||||
if (shExpMatch(host, "microsoft.com") ||
|
|
||||||
shExpMatch(host, "*.microsoft.com") ||
|
|
||||||
shExpMatch(host, "bing.com") ||
|
|
||||||
shExpMatch(host, "*.bing.com") ||
|
|
||||||
shExpMatch(host, "bingapis.com") ||
|
|
||||||
shExpMatch(host, "*.bingapis.com") ||
|
|
||||||
shExpMatch(host, "bing.net") ||
|
|
||||||
shExpMatch(host, "*.bing.net") ||
|
|
||||||
shExpMatch(host, "live.com") ||
|
|
||||||
shExpMatch(host, "*.live.com") ||
|
|
||||||
shExpMatch(host, "microsoftonline.com") ||
|
|
||||||
shExpMatch(host, "*.microsoftonline.com") ||
|
|
||||||
shExpMatch(host, "microsoft365.com") ||
|
|
||||||
shExpMatch(host, "*.microsoft365.com") ||
|
|
||||||
shExpMatch(host, "office.com") ||
|
|
||||||
shExpMatch(host, "*.office.com") ||
|
|
||||||
shExpMatch(host, "office.net") ||
|
|
||||||
shExpMatch(host, "*.office.net") ||
|
|
||||||
shExpMatch(host, "azure.com") ||
|
|
||||||
shExpMatch(host, "*.azure.com") ||
|
|
||||||
shExpMatch(host, "azureedge.net") ||
|
|
||||||
shExpMatch(host, "*.azureedge.net") ||
|
|
||||||
shExpMatch(host, "azurefd.net") ||
|
|
||||||
shExpMatch(host, "*.azurefd.net") ||
|
|
||||||
shExpMatch(host, "msn.com") ||
|
|
||||||
shExpMatch(host, "*.msn.com") ||
|
|
||||||
shExpMatch(host, "msecnd.net") ||
|
|
||||||
shExpMatch(host, "*.msecnd.net") ||
|
|
||||||
shExpMatch(host, "msftncsi.com") ||
|
|
||||||
shExpMatch(host, "*.msftncsi.com") ||
|
|
||||||
shExpMatch(host, "s-microsoft.com") ||
|
|
||||||
shExpMatch(host, "*.s-microsoft.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Steam 全系 + CDN / 社区 / 下载
|
|
||||||
if (shExpMatch(host, "steampowered.com") ||
|
|
||||||
shExpMatch(host, "*.steampowered.com") ||
|
|
||||||
shExpMatch(host, "steamcommunity.com") ||
|
|
||||||
shExpMatch(host, "*.steamcommunity.com") ||
|
|
||||||
shExpMatch(host, "steam.com") ||
|
|
||||||
shExpMatch(host, "*.steam.com") ||
|
|
||||||
shExpMatch(host, "steamgames.com") ||
|
|
||||||
shExpMatch(host, "*.steamgames.com") ||
|
|
||||||
shExpMatch(host, "steamusercontent.com") ||
|
|
||||||
shExpMatch(host, "*.steamusercontent.com") ||
|
|
||||||
shExpMatch(host, "steamcontent.com") ||
|
|
||||||
shExpMatch(host, "*.steamcontent.com") ||
|
|
||||||
shExpMatch(host, "steamstatic.com") ||
|
|
||||||
shExpMatch(host, "*.steamstatic.com") ||
|
|
||||||
shExpMatch(host, "steam-chat.com") ||
|
|
||||||
shExpMatch(host, "*.steam-chat.com") ||
|
|
||||||
shExpMatch(host, "steam-api.com") ||
|
|
||||||
shExpMatch(host, "*.steam-api.com") ||
|
|
||||||
shExpMatch(host, "steamserver.net") ||
|
|
||||||
shExpMatch(host, "*.steamserver.net") ||
|
|
||||||
shExpMatch(host, "steamcdn-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "*.steamcdn-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "steamcommunity-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "*.steamcommunity-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "steamstore-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "*.steamstore-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "steamuserimages-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "*.steamuserimages-a.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "akamaihd.net") ||
|
|
||||||
shExpMatch(host, "*.akamaihd.net") ||
|
|
||||||
shExpMatch(host, "valvesoftware.com") ||
|
|
||||||
shExpMatch(host, "*.valvesoftware.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discord 全系
|
|
||||||
if (shExpMatch(host, "discord.com") ||
|
|
||||||
shExpMatch(host, "*.discord.com") ||
|
|
||||||
shExpMatch(host, "discordapp.com") ||
|
|
||||||
shExpMatch(host, "*.discordapp.com") ||
|
|
||||||
shExpMatch(host, "discord.gg") ||
|
|
||||||
shExpMatch(host, "*.discord.gg") ||
|
|
||||||
shExpMatch(host, "discord.media") ||
|
|
||||||
shExpMatch(host, "*.discord.media") ||
|
|
||||||
shExpMatch(host, "discordapp.net") ||
|
|
||||||
shExpMatch(host, "*.discordapp.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Telegram 全系
|
|
||||||
if (shExpMatch(host, "telegram.org") ||
|
|
||||||
shExpMatch(host, "*.telegram.org") ||
|
|
||||||
shExpMatch(host, "t.me") ||
|
|
||||||
shExpMatch(host, "*.t.me") ||
|
|
||||||
shExpMatch(host, "telega.one") ||
|
|
||||||
shExpMatch(host, "*.telega.one") ||
|
|
||||||
shExpMatch(host, "telegram-cdn.org") ||
|
|
||||||
shExpMatch(host, "*.telegram-cdn.org")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reddit 全系
|
|
||||||
if (shExpMatch(host, "reddit.com") ||
|
|
||||||
shExpMatch(host, "*.reddit.com") ||
|
|
||||||
shExpMatch(host, "redd.it") ||
|
|
||||||
shExpMatch(host, "*.redd.it") ||
|
|
||||||
shExpMatch(host, "redditmedia.com") ||
|
|
||||||
shExpMatch(host, "*.redditmedia.com") ||
|
|
||||||
shExpMatch(host, "redditstatic.com") ||
|
|
||||||
shExpMatch(host, "*.redditstatic.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// LINE 全系
|
|
||||||
if (shExpMatch(host, "line.me") ||
|
|
||||||
shExpMatch(host, "*.line.me") ||
|
|
||||||
shExpMatch(host, "line-apps.com") ||
|
|
||||||
shExpMatch(host, "*.line-apps.com") ||
|
|
||||||
shExpMatch(host, "line-scdn.net") ||
|
|
||||||
shExpMatch(host, "*.line-scdn.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spotify 全系
|
|
||||||
if (shExpMatch(host, "spotify.com") ||
|
|
||||||
shExpMatch(host, "*.spotify.com") ||
|
|
||||||
shExpMatch(host, "spotifycdn.com") ||
|
|
||||||
shExpMatch(host, "*.spotifycdn.com") ||
|
|
||||||
shExpMatch(host, "scdn.co") ||
|
|
||||||
shExpMatch(host, "*.scdn.co")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Netflix 全系
|
|
||||||
if (shExpMatch(host, "netflix.com") ||
|
|
||||||
shExpMatch(host, "*.netflix.com") ||
|
|
||||||
shExpMatch(host, "nflxvideo.net") ||
|
|
||||||
shExpMatch(host, "*.nflxvideo.net") ||
|
|
||||||
shExpMatch(host, "nflximg.net") ||
|
|
||||||
shExpMatch(host, "*.nflximg.net") ||
|
|
||||||
shExpMatch(host, "nflxext.com") ||
|
|
||||||
shExpMatch(host, "*.nflxext.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disney+ 全系
|
|
||||||
if (shExpMatch(host, "disneyplus.com") ||
|
|
||||||
shExpMatch(host, "*.disneyplus.com") ||
|
|
||||||
shExpMatch(host, "disney-plus.net") ||
|
|
||||||
shExpMatch(host, "*.disney-plus.net") ||
|
|
||||||
shExpMatch(host, "dssott.com") ||
|
|
||||||
shExpMatch(host, "*.dssott.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TikTok 全系
|
|
||||||
if (shExpMatch(host, "tiktok.com") ||
|
|
||||||
shExpMatch(host, "*.tiktok.com") ||
|
|
||||||
shExpMatch(host, "tiktokcdn.com") ||
|
|
||||||
shExpMatch(host, "*.tiktokcdn.com") ||
|
|
||||||
shExpMatch(host, "tiktokv.com") ||
|
|
||||||
shExpMatch(host, "*.tiktokv.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlayStation 全系
|
|
||||||
if (shExpMatch(host, "playstation.com") ||
|
|
||||||
shExpMatch(host, "*.playstation.com") ||
|
|
||||||
shExpMatch(host, "sonyentertainmentnetwork.com") ||
|
|
||||||
shExpMatch(host, "*.sonyentertainmentnetwork.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nintendo 全系
|
|
||||||
if (shExpMatch(host, "nintendo.com") ||
|
|
||||||
shExpMatch(host, "*.nintendo.com") ||
|
|
||||||
shExpMatch(host, "nintendowifi.net") ||
|
|
||||||
shExpMatch(host, "*.nintendowifi.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Epic Games 全系
|
|
||||||
if (shExpMatch(host, "epicgames.com") ||
|
|
||||||
shExpMatch(host, "*.epicgames.com") ||
|
|
||||||
shExpMatch(host, "unrealengine.com") ||
|
|
||||||
shExpMatch(host, "*.unrealengine.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DuckDuckGo / Brave Search
|
|
||||||
if (shExpMatch(host, "duckduckgo.com") ||
|
|
||||||
shExpMatch(host, "*.duckduckgo.com") ||
|
|
||||||
shExpMatch(host, "search.brave.com") ||
|
|
||||||
shExpMatch(host, "*.search.brave.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proton 全系
|
|
||||||
if (shExpMatch(host, "proton.me") ||
|
|
||||||
shExpMatch(host, "*.proton.me") ||
|
|
||||||
shExpMatch(host, "protonmail.com") ||
|
|
||||||
shExpMatch(host, "*.protonmail.com") ||
|
|
||||||
shExpMatch(host, "protonvpn.com") ||
|
|
||||||
shExpMatch(host, "*.protonvpn.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stack Overflow / Stack Exchange 全系
|
|
||||||
if (shExpMatch(host, "stackoverflow.com") ||
|
|
||||||
shExpMatch(host, "*.stackoverflow.com") ||
|
|
||||||
shExpMatch(host, "stackexchange.com") ||
|
|
||||||
shExpMatch(host, "*.stackexchange.com") ||
|
|
||||||
shExpMatch(host, "superuser.com") ||
|
|
||||||
shExpMatch(host, "*.superuser.com") ||
|
|
||||||
shExpMatch(host, "serverfault.com") ||
|
|
||||||
shExpMatch(host, "*.serverfault.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quora / Medium
|
|
||||||
if (shExpMatch(host, "quora.com") ||
|
|
||||||
shExpMatch(host, "*.quora.com") ||
|
|
||||||
shExpMatch(host, "medium.com") ||
|
|
||||||
shExpMatch(host, "*.medium.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pixiv 全系
|
|
||||||
if (shExpMatch(host, "pixiv.net") ||
|
|
||||||
shExpMatch(host, "*.pixiv.net") ||
|
|
||||||
shExpMatch(host, "pximg.net") ||
|
|
||||||
shExpMatch(host, "*.pximg.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ニコニコ 全系
|
|
||||||
if (shExpMatch(host, "nicovideo.jp") ||
|
|
||||||
shExpMatch(host, "*.nicovideo.jp") ||
|
|
||||||
shExpMatch(host, "nico.ms") ||
|
|
||||||
shExpMatch(host, "*.nico.ms")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// EA / Origin / Apex 全系
|
|
||||||
if (shExpMatch(host, "ea.com") ||
|
|
||||||
shExpMatch(host, "*.ea.com") ||
|
|
||||||
shExpMatch(host, "origin.com") ||
|
|
||||||
shExpMatch(host, "*.origin.com") ||
|
|
||||||
shExpMatch(host, "eaassets.com") ||
|
|
||||||
shExpMatch(host, "*.eaassets.com") ||
|
|
||||||
shExpMatch(host, "apexlegends.com") ||
|
|
||||||
shExpMatch(host, "*.apexlegends.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Battle.net / Blizzard 全系
|
|
||||||
if (shExpMatch(host, "battle.net") ||
|
|
||||||
shExpMatch(host, "*.battle.net") ||
|
|
||||||
shExpMatch(host, "blizzard.com") ||
|
|
||||||
shExpMatch(host, "*.blizzard.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ubisoft 全系
|
|
||||||
if (shExpMatch(host, "ubisoft.com") ||
|
|
||||||
shExpMatch(host, "*.ubisoft.com") ||
|
|
||||||
shExpMatch(host, "ubi.com") ||
|
|
||||||
shExpMatch(host, "*.ubi.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Riot Games / 英雄联盟 / Valorant 全系
|
|
||||||
if (shExpMatch(host, "riotgames.com") ||
|
|
||||||
shExpMatch(host, "*.riotgames.com") ||
|
|
||||||
shExpMatch(host, "leagueoflegends.com") ||
|
|
||||||
shExpMatch(host, "*.leagueoflegends.com") ||
|
|
||||||
shExpMatch(host, "valorant.com") ||
|
|
||||||
shExpMatch(host, "*.valorant.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GOG / itch.io 全系
|
|
||||||
if (shExpMatch(host, "gog.com") ||
|
|
||||||
shExpMatch(host, "*.gog.com") ||
|
|
||||||
shExpMatch(host, "itch.io") ||
|
|
||||||
shExpMatch(host, "*.itch.io")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Twitch 全系
|
|
||||||
if (shExpMatch(host, "twitch.tv") ||
|
|
||||||
shExpMatch(host, "*.twitch.tv") ||
|
|
||||||
shExpMatch(host, "ttvnw.net") ||
|
|
||||||
shExpMatch(host, "*.ttvnw.net") ||
|
|
||||||
shExpMatch(host, "twitchcdn.net") ||
|
|
||||||
shExpMatch(host, "*.twitchcdn.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crunchyroll 全系
|
|
||||||
if (shExpMatch(host, "crunchyroll.com") ||
|
|
||||||
shExpMatch(host, "*.crunchyroll.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Amazon Prime Video 全系
|
|
||||||
if (shExpMatch(host, "primevideo.com") ||
|
|
||||||
shExpMatch(host, "*.primevideo.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apple TV+ 全系
|
|
||||||
if (shExpMatch(host, "tv.apple.com") ||
|
|
||||||
shExpMatch(host, "tvplus.apple.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// HBO Max / Hulu / Paramount+ 全系
|
|
||||||
if (shExpMatch(host, "max.com") ||
|
|
||||||
shExpMatch(host, "*.max.com") ||
|
|
||||||
shExpMatch(host, "hulu.com") ||
|
|
||||||
shExpMatch(host, "*.hulu.com") ||
|
|
||||||
shExpMatch(host, "paramountplus.com") ||
|
|
||||||
shExpMatch(host, "*.paramountplus.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slack 全系
|
|
||||||
if (shExpMatch(host, "slack.com") ||
|
|
||||||
shExpMatch(host, "*.slack.com") ||
|
|
||||||
shExpMatch(host, "slack-edge.com") ||
|
|
||||||
shExpMatch(host, "*.slack-edge.com") ||
|
|
||||||
shExpMatch(host, "slack-imgs.com") ||
|
|
||||||
shExpMatch(host, "*.slack-imgs.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zoom 全系
|
|
||||||
if (shExpMatch(host, "zoom.us") ||
|
|
||||||
shExpMatch(host, "*.zoom.us") ||
|
|
||||||
shExpMatch(host, "zoom.com") ||
|
|
||||||
shExpMatch(host, "*.zoom.com") ||
|
|
||||||
shExpMatch(host, "zoomgov.com") ||
|
|
||||||
shExpMatch(host, "*.zoomgov.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal 全系
|
|
||||||
if (shExpMatch(host, "signal.org") ||
|
|
||||||
shExpMatch(host, "*.signal.org")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docker 全系
|
|
||||||
if (shExpMatch(host, "docker.com") ||
|
|
||||||
shExpMatch(host, "*.docker.com") ||
|
|
||||||
shExpMatch(host, "docker.io") ||
|
|
||||||
shExpMatch(host, "*.docker.io")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GitLab 全系
|
|
||||||
if (shExpMatch(host, "gitlab.com") ||
|
|
||||||
shExpMatch(host, "*.gitlab.com") ||
|
|
||||||
shExpMatch(host, "gitlab.io") ||
|
|
||||||
shExpMatch(host, "*.gitlab.io")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vercel 全系
|
|
||||||
if (shExpMatch(host, "vercel.com") ||
|
|
||||||
shExpMatch(host, "*.vercel.com") ||
|
|
||||||
shExpMatch(host, "vercel.app") ||
|
|
||||||
shExpMatch(host, "*.vercel.app")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Netlify 全系
|
|
||||||
if (shExpMatch(host, "netlify.com") ||
|
|
||||||
shExpMatch(host, "*.netlify.com") ||
|
|
||||||
shExpMatch(host, "netlify.app") ||
|
|
||||||
shExpMatch(host, "*.netlify.app")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perplexity / Poe / Midjourney 全系
|
|
||||||
if (shExpMatch(host, "perplexity.ai") ||
|
|
||||||
shExpMatch(host, "*.perplexity.ai") ||
|
|
||||||
shExpMatch(host, "poe.com") ||
|
|
||||||
shExpMatch(host, "*.poe.com") ||
|
|
||||||
shExpMatch(host, "midjourney.com") ||
|
|
||||||
shExpMatch(host, "*.midjourney.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Figma / Canva 全系
|
|
||||||
if (shExpMatch(host, "figma.com") ||
|
|
||||||
shExpMatch(host, "*.figma.com") ||
|
|
||||||
shExpMatch(host, "canva.com") ||
|
|
||||||
shExpMatch(host, "*.canva.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// LinkedIn 全系
|
|
||||||
if (shExpMatch(host, "linkedin.com") ||
|
|
||||||
shExpMatch(host, "*.linkedin.com") ||
|
|
||||||
shExpMatch(host, "licdn.com") ||
|
|
||||||
shExpMatch(host, "*.licdn.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pinterest 全系
|
|
||||||
if (shExpMatch(host, "pinterest.com") ||
|
|
||||||
shExpMatch(host, "*.pinterest.com") ||
|
|
||||||
shExpMatch(host, "pinimg.com") ||
|
|
||||||
shExpMatch(host, "*.pinimg.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tumblr 全系
|
|
||||||
if (shExpMatch(host, "tumblr.com") ||
|
|
||||||
shExpMatch(host, "*.tumblr.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapchat 全系
|
|
||||||
if (shExpMatch(host, "snapchat.com") ||
|
|
||||||
shExpMatch(host, "*.snapchat.com") ||
|
|
||||||
shExpMatch(host, "sc-cdn.net") ||
|
|
||||||
shExpMatch(host, "*.sc-cdn.net")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wikipedia / Wikimedia 全系
|
|
||||||
if (shExpMatch(host, "wikipedia.org") ||
|
|
||||||
shExpMatch(host, "*.wikipedia.org") ||
|
|
||||||
shExpMatch(host, "wikimedia.org") ||
|
|
||||||
shExpMatch(host, "*.wikimedia.org")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PayPal 全系
|
|
||||||
if (shExpMatch(host, "paypal.com") ||
|
|
||||||
shExpMatch(host, "*.paypal.com") ||
|
|
||||||
shExpMatch(host, "paypalobjects.com") ||
|
|
||||||
shExpMatch(host, "*.paypalobjects.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patreon 全系
|
|
||||||
if (shExpMatch(host, "patreon.com") ||
|
|
||||||
shExpMatch(host, "*.patreon.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通用 CDN
|
|
||||||
if (shExpMatch(host, "cloudfront.net") ||
|
|
||||||
shExpMatch(host, "*.cloudfront.net") ||
|
|
||||||
shExpMatch(host, "fastly.net") ||
|
|
||||||
shExpMatch(host, "*.fastly.net") ||
|
|
||||||
shExpMatch(host, "fastlylb.net") ||
|
|
||||||
shExpMatch(host, "*.fastlylb.net") ||
|
|
||||||
shExpMatch(host, "jsdelivr.net") ||
|
|
||||||
shExpMatch(host, "*.jsdelivr.net") ||
|
|
||||||
shExpMatch(host, "unpkg.com") ||
|
|
||||||
shExpMatch(host, "*.unpkg.com") ||
|
|
||||||
shExpMatch(host, "statically.io") ||
|
|
||||||
shExpMatch(host, "*.statically.io") ||
|
|
||||||
shExpMatch(host, "cloudinary.com") ||
|
|
||||||
shExpMatch(host, "*.cloudinary.com") ||
|
|
||||||
shExpMatch(host, "imgur.com") ||
|
|
||||||
shExpMatch(host, "*.imgur.com")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
// linux.do
|
|
||||||
if (shExpMatch(host, "linux.do") ||
|
|
||||||
shExpMatch(host, "*.linux.do")) {
|
|
||||||
return proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "DIRECT";
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# /usr/local/bin/rrpp-stats — returns traffic stats as JSON
|
|
||||||
|
|
||||||
vnstat --json 2>/dev/null || echo '{"error":"vnstat not available"}'
|
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# sync-nginx-local-ips.sh
|
# sync-nginx-local-ips.sh
|
||||||
# 自动维护 nginx 本机公网 IP 直连规则(/etc/nginx/local_ip_rules.conf):
|
# 自动维护 nginx 本机公网 IP 直连规则(/etc/nginx/local_ip_rules.conf):
|
||||||
# 出站公网 IP(ipify)+ iam.aeolianchi.me 的 A 记录 = 本机当前公网 IP 集合。
|
# 出站公网 IP(ipify)+ <节点域名> 的 A 记录 = 本机当前公网 IP 集合。
|
||||||
# 公网 IP 变更时重写规则文件并 reload nginx(仅变更时 reload)。
|
# 公网 IP 变更时重写规则文件并 reload nginx(仅变更时 reload)。
|
||||||
# 由 /etc/cron.d/sync-nginx-local-ips 每 5 分钟调用一次。
|
# 由 /etc/cron.d/sync-nginx-local-ips 每 5 分钟调用一次。
|
||||||
|
#
|
||||||
|
# 部署时把 DOMAIN 改为本节点域名。
|
||||||
set -u
|
set -u
|
||||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
RULES=/etc/nginx/local_ip_rules.conf
|
RULES=/etc/nginx/local_ip_rules.conf
|
||||||
DOMAIN=iam.aeolianchi.me
|
DOMAIN=<节点域名>
|
||||||
|
|
||||||
ips=""
|
ips=""
|
||||||
# 出站公网 IP
|
# 出站公网 IP
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"log": {
|
"log": {
|
||||||
"level": "warn",
|
"level": "info",
|
||||||
"timestamp": true
|
"timestamp": true
|
||||||
},
|
},
|
||||||
"inbounds": [
|
"inbounds": [
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"type": "vless",
|
"type": "vless",
|
||||||
"tag": "vless-reality-in",
|
"tag": "vless-reality-in",
|
||||||
"listen": "0.0.0.0",
|
"listen": "0.0.0.0",
|
||||||
"listen_port": 8443,
|
"listen_port": "<vless端口>",
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"uuid": "<VLESS_UUID>",
|
"uuid": "<VLESS_UUID>",
|
||||||
@@ -17,11 +17,11 @@
|
|||||||
],
|
],
|
||||||
"tls": {
|
"tls": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"server_name": "www.apple.com",
|
"server_name": "<伪装SNI域名>",
|
||||||
"reality": {
|
"reality": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"handshake": {
|
"handshake": {
|
||||||
"server": "www.apple.com",
|
"server": "<伪装SNI域名>",
|
||||||
"server_port": 443
|
"server_port": 443
|
||||||
},
|
},
|
||||||
"private_key": "<REALITY_PRIVATE_KEY>",
|
"private_key": "<REALITY_PRIVATE_KEY>",
|
||||||
@@ -30,6 +30,14 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "shadowsocks",
|
||||||
|
"tag": "ss-in",
|
||||||
|
"listen": "0.0.0.0",
|
||||||
|
"listen_port": "<ss端口>",
|
||||||
|
"method": "2022-blake3-aes-256-gcm",
|
||||||
|
"password": "<SS_PSK_BASE64_32字节>"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outbounds": [
|
"outbounds": [
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=MTProto proxy (mtg) for Telegram
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/mtg simple-run --prefer-ip=prefer-ipv4 0.0.0.0:<mtproto端口> <MTPROTO_SECRET>
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=nginx (RRPP proxy, compiled with ngx_http_proxy_connect_module)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=forking
|
||||||
|
PIDFile=/var/run/nginx.pid
|
||||||
|
ExecStartPre=/usr/local/nginx/sbin/nginx -t
|
||||||
|
ExecStart=/usr/local/nginx/sbin/nginx
|
||||||
|
ExecReload=/usr/local/nginx/sbin/nginx -s reload
|
||||||
|
ExecStop=/usr/local/nginx/sbin/nginx -s quit
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=sing-box (VLESS+REALITY / Shadowsocks)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/sing-box -D /var/lib/sing-box -C /etc/sing-box run
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"vnstat_bin": "vnstat",
|
|
||||||
"interface": "eth0",
|
|
||||||
"data_dir": "/home/agent/data",
|
|
||||||
"alert_state_path": "/home/agent/alert_state.json",
|
|
||||||
"poll_interval_sec": 60,
|
|
||||||
"schedule_interval_sec": 30,
|
|
||||||
"http_host": "127.0.0.1",
|
|
||||||
"http_port": 18080,
|
|
||||||
"quota_bytes": 15000000000,
|
|
||||||
"alert_thresholds_pct": [
|
|
||||||
50,
|
|
||||||
80,
|
|
||||||
90,
|
|
||||||
95,
|
|
||||||
99,
|
|
||||||
100
|
|
||||||
],
|
|
||||||
"effective_domain": "iam.aeolianchi.me",
|
|
||||||
"log_level": "INFO",
|
|
||||||
"smtp": {
|
|
||||||
"host": "mail.rrrosmontis.icu",
|
|
||||||
"port": 465,
|
|
||||||
"user": "<SMTP_USER>",
|
|
||||||
"password": "<SMTP_PASSWORD>",
|
|
||||||
"from_name": "Royirin Rosmontis Private Proxy JP 流量监控",
|
|
||||||
"from_addr": "<SMTP_FROM>",
|
|
||||||
"to_addr": "<SMTP_TO>",
|
|
||||||
"starttls": false,
|
|
||||||
"skip_cert_verify": false,
|
|
||||||
"timeout_sec": 30,
|
|
||||||
"ssl": true
|
|
||||||
},
|
|
||||||
"current_ip": "<PUBLIC_IP>",
|
|
||||||
"billing_cycle_start_day": 1,
|
|
||||||
"azure_usage_enabled": true,
|
|
||||||
"azure_usage_fetch_interval_sec": 21600,
|
|
||||||
"azure_usage_cache_path": "/home/agent/data/azure_usage.json",
|
|
||||||
"azure_meter_name_match": "Data Transfer Out",
|
|
||||||
"azure_free_limit_gb": 15
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Royirin Rosmontis Private Proxy JP Traffic Monitor
|
|
||||||
After=network-online.target nginx.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
WorkingDirectory=/home/agent
|
|
||||||
ExecStart=/usr/bin/python3 /home/agent/traffic_monitor.py --config /home/agent/config.json
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
User=root
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,666 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Royirin Rosmontis Private Proxy JP 流量监控(Azure 门户口径版)
|
|
||||||
- 主计量口径:Azure 门户免费服务页 "Data Transfer Out (GB)"(Standard Data Transfer Out - Free,MGN 路由)
|
|
||||||
—— 通过 VM 托管身份调用 Consumption Usage Details API 拉取,按自然月聚合,缓存至 data/azure_usage.json
|
|
||||||
- 参考口径:vnstat -i eth0(实时原始出站,仅作参考展示)
|
|
||||||
- 数据源(参考):vnstat -i <interface> --json m/d(eth0 为 bond 主接口,覆盖 nginx 443 与 sing-box 8443 全部流量)
|
|
||||||
- 按 年/月 聚合成 JSON 存入 data/YYYY/YYYY-MM.json(弃用 SQLite)
|
|
||||||
- HTTP 服务: GET /vpn/status/traffic 返回当月用量 JSON(Azure 口径为主)
|
|
||||||
- 阈值告警: 达到配额 50/80/90/95/99/100% 时发邮件(每档每月只发一次,跳档补发;基于 Azure 口径)
|
|
||||||
- 月报: 每月 1 日 00:05 起发送上个月报告(失败重试;基于 Azure 口径)
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import smtplib
|
|
||||||
import ssl
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
|
||||||
from email.message import EmailMessage
|
|
||||||
from email.utils import formataddr
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
|
|
||||||
log = logging.getLogger('traffic_monitor')
|
|
||||||
|
|
||||||
GB = 1_000_000_000 # Azure 数据转移按十进制 GB 计费
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow():
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def load_json(path, default):
|
|
||||||
try:
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
return json.load(f)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return default
|
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
|
||||||
log.warning('read %s failed: %s; using default', path, e)
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def save_json(path, obj):
|
|
||||||
tmp = path + '.tmp'
|
|
||||||
with open(tmp, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(obj, f, ensure_ascii=False, indent=2)
|
|
||||||
os.replace(tmp, path)
|
|
||||||
|
|
||||||
|
|
||||||
def gb_str(n_bytes):
|
|
||||||
return f'{n_bytes / GB:.2f} GB'
|
|
||||||
|
|
||||||
|
|
||||||
def month_struct(year, month):
|
|
||||||
return {
|
|
||||||
'year': int(year),
|
|
||||||
'month': int(month),
|
|
||||||
'rx_bytes': 0,
|
|
||||||
'tx_bytes': 0,
|
|
||||||
'days': [],
|
|
||||||
'updated_at': utcnow().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Monitor:
|
|
||||||
def __init__(self, config):
|
|
||||||
self.config = config
|
|
||||||
self.lock = threading.RLock()
|
|
||||||
self.months = {}
|
|
||||||
self.alert_state = None
|
|
||||||
|
|
||||||
os.makedirs(self.config['data_dir'], exist_ok=True)
|
|
||||||
self._load_all_months()
|
|
||||||
self._load_alert_state()
|
|
||||||
# Azure 计费用量(Usage Details API,VM 托管身份鉴权)
|
|
||||||
self.azure_usage = None
|
|
||||||
self._azure_last_fetch = 0.0
|
|
||||||
cache_path = self.config.get('azure_usage_cache_path')
|
|
||||||
if cache_path and os.path.exists(cache_path):
|
|
||||||
self.azure_usage = load_json(cache_path, None)
|
|
||||||
log.info('azure usage cache loaded: %s', self.azure_usage)
|
|
||||||
|
|
||||||
# ---------- 基础 ----------
|
|
||||||
@property
|
|
||||||
def _config_path(self):
|
|
||||||
return os.environ.get('TRAFFIC_CONFIG', '/home/agent/config.json')
|
|
||||||
|
|
||||||
def _load_all_months(self):
|
|
||||||
root = self.config['data_dir']
|
|
||||||
if not os.path.isdir(root):
|
|
||||||
return
|
|
||||||
for year_dir in os.listdir(root):
|
|
||||||
ypath = os.path.join(root, year_dir)
|
|
||||||
if not os.path.isdir(ypath):
|
|
||||||
continue
|
|
||||||
for fn in os.listdir(ypath):
|
|
||||||
if not fn.endswith('.json'):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
with open(os.path.join(ypath, fn), 'r', encoding='utf-8') as f:
|
|
||||||
m = json.load(f)
|
|
||||||
mk = f"{m['year']:04d}-{m['month']:02d}"
|
|
||||||
self.months[mk] = m
|
|
||||||
except Exception as e:
|
|
||||||
log.warning('load %s/%s failed: %s', ypath, fn, e)
|
|
||||||
|
|
||||||
def _load_alert_state(self):
|
|
||||||
self.alert_state = load_json(
|
|
||||||
self.config['alert_state_path'],
|
|
||||||
{'alert_thresholds_sent': {}, 'monthly_reports_sent': []})
|
|
||||||
self.alert_state.setdefault('alert_thresholds_sent', {})
|
|
||||||
self.alert_state.setdefault('monthly_reports_sent', [])
|
|
||||||
|
|
||||||
def _save_alert_state(self):
|
|
||||||
save_json(self.config['alert_state_path'], self.alert_state)
|
|
||||||
|
|
||||||
def _month_path(self, year, month):
|
|
||||||
return os.path.join(self.config['data_dir'], str(year), f'{year:04d}-{month:02d}.json')
|
|
||||||
|
|
||||||
def _persist_month(self, mk):
|
|
||||||
m = self.months.get(mk)
|
|
||||||
if m is None:
|
|
||||||
return
|
|
||||||
path = self._month_path(m['year'], m['month'])
|
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
||||||
save_json(path, m)
|
|
||||||
|
|
||||||
# ---------- vnstat 读取 ----------
|
|
||||||
def _vnstat(self, what):
|
|
||||||
bin_path = self.config.get('vnstat_bin', 'vnstat')
|
|
||||||
iface = self.config.get('interface', 'eth0')
|
|
||||||
cmd = [bin_path, '-i', iface, '--json', what]
|
|
||||||
try:
|
|
||||||
out = subprocess.run(cmd, capture_output=True, timeout=20, check=True)
|
|
||||||
return json.loads(out.stdout.decode('utf-8'))
|
|
||||||
except FileNotFoundError:
|
|
||||||
log.error('vnstat binary not found: %s', bin_path)
|
|
||||||
return None
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
log.error('vnstat --json %s timed out', what)
|
|
||||||
return None
|
|
||||||
except (subprocess.CalledProcessError, json.JSONDecodeError, OSError) as e:
|
|
||||||
log.error('vnstat --json %s failed: %s', what, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def poll_vnstat(self):
|
|
||||||
"""拉取 vnstat 月/日数据,更新并落盘。返回更新的月数。"""
|
|
||||||
monthly = self._vnstat('m')
|
|
||||||
daily = self._vnstat('d')
|
|
||||||
touched = set()
|
|
||||||
|
|
||||||
with self.lock:
|
|
||||||
if monthly:
|
|
||||||
try:
|
|
||||||
for itf in monthly.get('interfaces', []):
|
|
||||||
for item in itf.get('traffic', {}).get('month', []):
|
|
||||||
d = item.get('date', {})
|
|
||||||
mk = f"{int(d['year']):04d}-{int(d['month']):02d}"
|
|
||||||
m = self.months.get(mk)
|
|
||||||
if m is None:
|
|
||||||
m = month_struct(d['year'], d['month'])
|
|
||||||
self.months[mk] = m
|
|
||||||
m['rx_bytes'] = int(item.get('rx', 0))
|
|
||||||
m['tx_bytes'] = int(item.get('tx', 0))
|
|
||||||
m['updated_at'] = utcnow().isoformat()
|
|
||||||
touched.add(mk)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception('parse vnstat monthly failed: %s', e)
|
|
||||||
|
|
||||||
if daily:
|
|
||||||
try:
|
|
||||||
by_month = {}
|
|
||||||
for itf in daily.get('interfaces', []):
|
|
||||||
for item in itf.get('traffic', {}).get('day', []):
|
|
||||||
d = item.get('date', {})
|
|
||||||
mk = f"{int(d['year']):04d}-{int(d['month']):02d}"
|
|
||||||
by_month.setdefault(mk, []).append({
|
|
||||||
'day': int(d['day']),
|
|
||||||
'rx_bytes': int(item.get('rx', 0)),
|
|
||||||
'tx_bytes': int(item.get('tx', 0)),
|
|
||||||
})
|
|
||||||
for mk, days in by_month.items():
|
|
||||||
m = self.months.get(mk)
|
|
||||||
if m is None:
|
|
||||||
y, mon = mk.split('-')
|
|
||||||
m = month_struct(int(y), int(mon))
|
|
||||||
self.months[mk] = m
|
|
||||||
days.sort(key=lambda x: x['day'])
|
|
||||||
m['days'] = days
|
|
||||||
m['updated_at'] = utcnow().isoformat()
|
|
||||||
touched.add(mk)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception('parse vnstat daily failed: %s', e)
|
|
||||||
|
|
||||||
for mk in touched:
|
|
||||||
self._persist_month(mk)
|
|
||||||
return len(touched)
|
|
||||||
|
|
||||||
# ---------- 用量(按 Azure 计费周期) ----------
|
|
||||||
def current_month_key(self):
|
|
||||||
return utcnow().strftime('%Y-%m')
|
|
||||||
|
|
||||||
def cycle_start(self, now):
|
|
||||||
"""返回当前计费周期的起始 datetime(每月 billing_cycle_start_day 00:00 UTC)。"""
|
|
||||||
day = int(self.config.get('billing_cycle_start_day', 1))
|
|
||||||
day = max(1, min(28, day))
|
|
||||||
if now.day >= day:
|
|
||||||
return now.replace(day=day, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
prev = (now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
- timedelta(days=1))
|
|
||||||
return prev.replace(day=day, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
|
|
||||||
def cycle_end(self, start):
|
|
||||||
"""周期结束 = 下个月同一天的 00:00(不含)。"""
|
|
||||||
nxt = (start.replace(day=1) + timedelta(days=32)).replace(day=1)
|
|
||||||
return nxt.replace(day=start.day, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
|
|
||||||
def _day_totals(self):
|
|
||||||
"""返回 [{date, rx, tx}],date 为 UTC date。"""
|
|
||||||
out = []
|
|
||||||
for m in self.months.values():
|
|
||||||
for d in m.get('days', []):
|
|
||||||
out.append((date(m['year'], m['month'], d['day']),
|
|
||||||
int(d.get('rx_bytes', 0)), int(d.get('tx_bytes', 0))))
|
|
||||||
return out
|
|
||||||
|
|
||||||
def cycle_usage(self):
|
|
||||||
"""返回当前计费周期 {rx_bytes, tx_bytes, start, end}。"""
|
|
||||||
now = utcnow()
|
|
||||||
start = self.cycle_start(now)
|
|
||||||
end = self.cycle_end(start)
|
|
||||||
rx = tx = 0
|
|
||||||
with self.lock:
|
|
||||||
for dt, r, t in self._day_totals():
|
|
||||||
if start.date() <= dt <= now.date():
|
|
||||||
rx += r
|
|
||||||
tx += t
|
|
||||||
return {'rx_bytes': rx, 'tx_bytes': tx, 'start': start, 'end': end}
|
|
||||||
|
|
||||||
def tx_bytes_for(self, mk):
|
|
||||||
with self.lock:
|
|
||||||
m = self.months.get(mk, {})
|
|
||||||
return int(m.get('tx_bytes', 0))
|
|
||||||
|
|
||||||
def current_status(self):
|
|
||||||
"""当前状态:以 Azure 门户口径(Standard Data Transfer Out - Free)为主计量。"""
|
|
||||||
now = utcnow()
|
|
||||||
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
|
|
||||||
cu = self.cycle_usage()
|
|
||||||
az = self.azure_usage or {}
|
|
||||||
used_gb = az.get('portal_data_transfer_out_gb')
|
|
||||||
used_pct = round(used_gb / quota_gb * 100, 4) if (used_gb is not None and quota_gb) else None
|
|
||||||
with self.lock:
|
|
||||||
cal_mk = now.strftime('%Y-%m')
|
|
||||||
m = self.months.get(cal_mk)
|
|
||||||
cal_tx = int(m.get('tx_bytes', 0)) if m else 0
|
|
||||||
cal_rx = int(m.get('rx_bytes', 0)) if m else 0
|
|
||||||
vnstat_updated = (m or {}).get('updated_at')
|
|
||||||
return {
|
|
||||||
'year': now.year,
|
|
||||||
'month': now.month,
|
|
||||||
'meter': 'azure_portal',
|
|
||||||
'used_gb': used_gb,
|
|
||||||
'quota_gb': quota_gb,
|
|
||||||
'quota_used_pct': used_pct,
|
|
||||||
'billing_cycle_start_day': int(self.config.get('billing_cycle_start_day', 1)),
|
|
||||||
'cycle_start': cu['start'].strftime('%Y-%m-%d'),
|
|
||||||
'cycle_end': cu['end'].strftime('%Y-%m-%d'),
|
|
||||||
'azure_portal_data_transfer_out_gb': az.get('portal_data_transfer_out_gb'),
|
|
||||||
'azure_bandwidth_out_gb': az.get('bandwidth_out_gb'),
|
|
||||||
'azure_usage_cost_cny': az.get('cost_cny'),
|
|
||||||
'azure_usage_month': az.get('month'),
|
|
||||||
'azure_usage_updated_at': az.get('updated_at'),
|
|
||||||
'vnstat_calendar_month_tx_bytes': cal_tx,
|
|
||||||
'vnstat_calendar_month_rx_bytes': cal_rx,
|
|
||||||
'vnstat_updated_at': vnstat_updated,
|
|
||||||
'updated_at': now.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _vnstat_calendar_tx_gb(self):
|
|
||||||
"""参考值:vnstat 自然月出站(GB,十进制)。"""
|
|
||||||
now = utcnow()
|
|
||||||
with self.lock:
|
|
||||||
m = self.months.get(now.strftime('%Y-%m'), {})
|
|
||||||
return int(m.get('tx_bytes', 0)) / GB
|
|
||||||
|
|
||||||
# ---------- Azure 计费用量(Usage Details API) ----------
|
|
||||||
def _azure_token(self):
|
|
||||||
"""通过 VM 托管身份从 IMDS 获取 management.azure.com 的访问令牌。"""
|
|
||||||
url = ('http://169.254.169.254/metadata/identity/oauth2/token'
|
|
||||||
'?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F')
|
|
||||||
req = urllib.request.Request(url, headers={'Metadata': 'true'})
|
|
||||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
||||||
return json.loads(resp.read().decode('utf-8'))['access_token']
|
|
||||||
|
|
||||||
def _azure_subscription_id(self):
|
|
||||||
url = 'http://169.254.169.254/metadata/instance/compute?api-version=2021-02-01'
|
|
||||||
req = urllib.request.Request(url, headers={'Metadata': 'true'})
|
|
||||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
||||||
return json.loads(resp.read().decode('utf-8')).get('subscriptionId')
|
|
||||||
|
|
||||||
def fetch_azure_usage(self, month_offset=0):
|
|
||||||
"""拉取指定月份 usageDetails,聚合与门户免费服务页同源的 Data Transfer Out 用量。
|
|
||||||
month_offset: 0=当月(写入缓存),-1=上月(用于月报,不覆盖缓存)。"""
|
|
||||||
sub = self._azure_subscription_id()
|
|
||||||
if not sub:
|
|
||||||
raise RuntimeError('cannot read subscriptionId from IMDS')
|
|
||||||
token = self._azure_token()
|
|
||||||
now = utcnow()
|
|
||||||
month_start = (now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
+ timedelta(days=month_offset * 32)).replace(day=1)
|
|
||||||
nxt = (month_start.replace(day=1) + timedelta(days=32)).replace(day=1)
|
|
||||||
flt = ("properties/usageStart ge '{0}T00:00:00Z' and "
|
|
||||||
"properties/usageStart lt '{1}T00:00:00Z'").format(
|
|
||||||
month_start.strftime('%Y-%m-%d'), nxt.strftime('%Y-%m-%d'))
|
|
||||||
base = ('https://management.azure.com/subscriptions/{sub}/providers/'
|
|
||||||
'Microsoft.Consumption/usageDetails?api-version=2023-11-01'
|
|
||||||
'&metric=actualcost&$filter={f}')
|
|
||||||
url = base.format(sub=sub, f=urllib.parse.quote(flt, safe=''))
|
|
||||||
portal_gb = 0.0
|
|
||||||
bw_out_gb = 0.0
|
|
||||||
cost_cny = 0.0
|
|
||||||
rows = 0
|
|
||||||
pages = 0
|
|
||||||
while url:
|
|
||||||
req = urllib.request.Request(url, headers={'Authorization': 'Bearer ' + token})
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
||||||
d = json.loads(resp.read().decode('utf-8'))
|
|
||||||
for item in d.get('value', []):
|
|
||||||
p = item.get('properties', {}) or {}
|
|
||||||
qty = float(p.get('quantity') or 0)
|
|
||||||
rows += 1
|
|
||||||
name = str(p.get('meterName') or '')
|
|
||||||
subcat = str(p.get('meterSubCategory') or '')
|
|
||||||
if str(p.get('meterCategory') or '') == 'Bandwidth' and 'Data Transfer Out' in name:
|
|
||||||
bw_out_gb += qty
|
|
||||||
# 门户免费服务页 "Data Transfer Out (GB)" = MGN 路由的出站免费计费表
|
|
||||||
if 'MGN' in subcat or '- Free' in name:
|
|
||||||
portal_gb += qty
|
|
||||||
cost_cny += float(p.get('costInBillingCurrency') or 0)
|
|
||||||
pages += 1
|
|
||||||
url = d.get('nextLink')
|
|
||||||
if pages > 30:
|
|
||||||
log.warning('azure usage pagination limit reached')
|
|
||||||
break
|
|
||||||
result = {
|
|
||||||
'month': month_start.strftime('%Y-%m'),
|
|
||||||
'portal_data_transfer_out_gb': round(portal_gb, 4),
|
|
||||||
'bandwidth_out_gb': round(bw_out_gb, 4),
|
|
||||||
'cost_cny': round(cost_cny, 4),
|
|
||||||
'rows': rows,
|
|
||||||
'updated_at': utcnow().isoformat(),
|
|
||||||
}
|
|
||||||
self.azure_usage = result
|
|
||||||
if month_offset == 0:
|
|
||||||
path = self.config.get('azure_usage_cache_path')
|
|
||||||
if path:
|
|
||||||
save_json(path, result)
|
|
||||||
log.info('azure usage fetched (offset=%d): %s', month_offset, result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def check_azure_usage(self):
|
|
||||||
if not self.config.get('azure_usage_enabled', False):
|
|
||||||
return
|
|
||||||
interval = float(self.config.get('azure_usage_fetch_interval_sec', 21600))
|
|
||||||
now = time.time()
|
|
||||||
if now - self._azure_last_fetch < interval:
|
|
||||||
return
|
|
||||||
self._azure_last_fetch = now
|
|
||||||
try:
|
|
||||||
self.fetch_azure_usage()
|
|
||||||
except Exception as e:
|
|
||||||
log.warning('azure usage fetch failed: %s', e)
|
|
||||||
|
|
||||||
# ---------- IP / 邮件 ----------
|
|
||||||
def get_current_ip(self):
|
|
||||||
configured = self.config.get('current_ip')
|
|
||||||
if configured:
|
|
||||||
return str(configured)
|
|
||||||
try:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
try:
|
|
||||||
s.connect(('8.8.8.8', 80))
|
|
||||||
return s.getsockname()[0]
|
|
||||||
finally:
|
|
||||||
s.close()
|
|
||||||
except OSError:
|
|
||||||
return '0.0.0.0'
|
|
||||||
|
|
||||||
def send_email(self, subject, body):
|
|
||||||
cfg = self.config['smtp']
|
|
||||||
msg = EmailMessage()
|
|
||||||
msg['Subject'] = subject
|
|
||||||
msg['From'] = formataddr((cfg.get('from_name', ''), cfg.get('from_addr')))
|
|
||||||
msg['To'] = cfg.get('to_addr')
|
|
||||||
msg.set_content(body)
|
|
||||||
|
|
||||||
context = None
|
|
||||||
if cfg.get('skip_cert_verify'):
|
|
||||||
context = ssl._create_unverified_context()
|
|
||||||
host = cfg['host']
|
|
||||||
port = int(cfg.get('port', 465))
|
|
||||||
timeout = int(cfg.get('timeout_sec', 30))
|
|
||||||
if cfg.get('ssl', False):
|
|
||||||
smtp_cls = smtplib.SMTP_SSL
|
|
||||||
kwargs = {'context': context}
|
|
||||||
else:
|
|
||||||
smtp_cls = smtplib.SMTP
|
|
||||||
kwargs = {}
|
|
||||||
with smtp_cls(host, port, timeout=timeout, **kwargs) as smtp:
|
|
||||||
if not cfg.get('ssl', False):
|
|
||||||
smtp.ehlo()
|
|
||||||
if cfg.get('starttls', True):
|
|
||||||
smtp.starttls(context=context)
|
|
||||||
smtp.ehlo()
|
|
||||||
smtp.login(cfg['user'], cfg['password'])
|
|
||||||
smtp.send_message(msg)
|
|
||||||
log.info('email sent: %s', subject)
|
|
||||||
|
|
||||||
def build_alert_mail(self, mk, used_gb, quota_gb, pct, az):
|
|
||||||
ip = self.get_current_ip()
|
|
||||||
month = (az or {}).get('month', mk.replace('azure:', ''))
|
|
||||||
az_updated = (az or {}).get('updated_at', '')
|
|
||||||
vnstat_tx = self._vnstat_calendar_tx_gb()
|
|
||||||
body = (
|
|
||||||
'Royirin Rosmontis Private Proxy JP 流量监控\n'
|
|
||||||
'==========\n'
|
|
||||||
'计量口径:Azure 门户(Standard Data Transfer Out - Free)\n'
|
|
||||||
f'统计月份:{month}(自然月)\n'
|
|
||||||
f'当前用量:{used_gb:.2f} GB / {quota_gb:.0f} GB({pct:.2f}%)\n'
|
|
||||||
f'AZURE 数据更新:{az_updated}\n'
|
|
||||||
f'参考(vnstat 自然月出站):{vnstat_tx:.2f} GB\n'
|
|
||||||
f'当前 IP:{ip}\n'
|
|
||||||
f'生效域名:{self.config.get("effective_domain", "")}\n'
|
|
||||||
'==========\n'
|
|
||||||
f'已达阈值 {pct:.2f}% (Azure 门户口径),请注意使用情况'
|
|
||||||
)
|
|
||||||
subject = f'[流量告警] {month} 已用 {pct:.2f}% ({used_gb:.2f}/{quota_gb:.0f} GB)'
|
|
||||||
return subject, body
|
|
||||||
|
|
||||||
def build_monthly_mail(self, month, used_gb, quota_gb, pct, az):
|
|
||||||
ip = self.get_current_ip()
|
|
||||||
over = '是' if used_gb > quota_gb else '否'
|
|
||||||
az_updated = (az or {}).get('updated_at', '')
|
|
||||||
vnstat_tx = self._vnstat_calendar_tx_gb()
|
|
||||||
body = (
|
|
||||||
'Royirin Rosmontis Private Proxy JP 流量监控\n'
|
|
||||||
'月度报告\n'
|
|
||||||
'==========\n'
|
|
||||||
'计量口径:Azure 门户(Standard Data Transfer Out - Free)\n'
|
|
||||||
f'报告月份:{month}(自然月)\n'
|
|
||||||
f'当月用量:{used_gb:.2f} GB / {quota_gb:.0f} GB({pct:.2f}%)\n'
|
|
||||||
f'AZURE 数据更新:{az_updated}\n'
|
|
||||||
f'参考(vnstat 自然月出站):{vnstat_tx:.2f} GB\n'
|
|
||||||
f'当前 IP:{ip}\n'
|
|
||||||
f'生效域名:{self.config.get("effective_domain", "")}\n'
|
|
||||||
'==========\n'
|
|
||||||
f'上月使用 {over} 超限, 使用量为限额的 {pct:.2f}%'
|
|
||||||
)
|
|
||||||
subject = f'[流量月报] {month} 用量 {used_gb:.2f} GB ({pct:.2f}%)'
|
|
||||||
return subject, body
|
|
||||||
|
|
||||||
# ---------- 定时任务 ----------
|
|
||||||
def check_alerts(self):
|
|
||||||
"""阈值告警:以 Azure 门户口径为准(无 Azure 数据时跳过,避免误报)。"""
|
|
||||||
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
|
|
||||||
if quota_gb <= 0:
|
|
||||||
return
|
|
||||||
az = self.azure_usage or {}
|
|
||||||
used_gb = az.get('portal_data_transfer_out_gb')
|
|
||||||
if used_gb is None:
|
|
||||||
log.warning('alerts skipped: no azure usage data yet')
|
|
||||||
return
|
|
||||||
mk = 'azure:' + str(az.get('month') or utcnow().strftime('%Y-%m'))
|
|
||||||
pct = used_gb / quota_gb * 100
|
|
||||||
sent = self.alert_state['alert_thresholds_sent'].setdefault(mk, [])
|
|
||||||
if not sent:
|
|
||||||
# 新周期/口径切换:已越过的档位直接标记,避免切换瞬间补发一堆邮件
|
|
||||||
for t in sorted(self.config.get('alert_thresholds_pct', [])):
|
|
||||||
if pct >= t:
|
|
||||||
sent.append(t)
|
|
||||||
self._save_alert_state()
|
|
||||||
log.info('seeded thresholds for %s at %.2f%%: %s', mk, pct, sent)
|
|
||||||
for t in sorted(self.config.get('alert_thresholds_pct', [])):
|
|
||||||
if pct >= t and t not in sent:
|
|
||||||
subject, body = self.build_alert_mail(mk, used_gb, quota_gb, pct, az)
|
|
||||||
try:
|
|
||||||
self.send_email(subject, body)
|
|
||||||
sent.append(t)
|
|
||||||
self._save_alert_state()
|
|
||||||
log.info('threshold %s%% alert sent for %s (used %.2f%%)', t, mk, pct)
|
|
||||||
except Exception as e:
|
|
||||||
log.error('send threshold %s%% alert failed for %s: %s; will retry',
|
|
||||||
t, mk, e)
|
|
||||||
break
|
|
||||||
|
|
||||||
def check_monthly_report(self):
|
|
||||||
"""月度报告:以 Azure 门户口径为准(上月数据,当月 1 号后发送)。"""
|
|
||||||
now = utcnow()
|
|
||||||
cur_start = self.cycle_start(now)
|
|
||||||
prev_start = (cur_start.replace(day=1) - timedelta(days=1)).replace(day=1)
|
|
||||||
prev_mk = 'azure:' + prev_start.strftime('%Y-%m')
|
|
||||||
if prev_mk in self.alert_state['monthly_reports_sent']:
|
|
||||||
return
|
|
||||||
# 口径切换迁移:旧键(cycle:)已发过则直接标记,避免重复发信
|
|
||||||
old_mk = 'cycle:' + prev_start.strftime('%Y-%m')
|
|
||||||
if old_mk in self.alert_state['monthly_reports_sent']:
|
|
||||||
self.alert_state['monthly_reports_sent'].append(prev_mk)
|
|
||||||
self._save_alert_state()
|
|
||||||
log.info('monthly report key migrated: %s already sent as %s', prev_mk, old_mk)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
az_prev = self.fetch_azure_usage(month_offset=-1)
|
|
||||||
except Exception as e:
|
|
||||||
log.error('fetch prev month azure usage failed: %s; will retry', e)
|
|
||||||
return
|
|
||||||
quota_gb = float(self.config.get('azure_free_limit_gb', 15))
|
|
||||||
used_gb = az_prev.get('portal_data_transfer_out_gb') or 0.0
|
|
||||||
pct = used_gb / quota_gb * 100 if quota_gb else 0.0
|
|
||||||
subject, body = self.build_monthly_mail(
|
|
||||||
az_prev.get('month', prev_start.strftime('%Y-%m')), used_gb, quota_gb, pct, az_prev)
|
|
||||||
try:
|
|
||||||
self.send_email(subject, body)
|
|
||||||
self.alert_state['monthly_reports_sent'].append(prev_mk)
|
|
||||||
self._save_alert_state()
|
|
||||||
log.info('monthly report sent for %s (used %.2f GB, %.2f%%)',
|
|
||||||
prev_mk, used_gb, pct)
|
|
||||||
except Exception as e:
|
|
||||||
log.error('send monthly report for %s failed: %s; will retry', prev_mk, e)
|
|
||||||
|
|
||||||
|
|
||||||
def build_http_handler(monitor):
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
if self.path == '/vpn/status/traffic':
|
|
||||||
try:
|
|
||||||
payload = json.dumps(monitor.current_status(), ensure_ascii=False)
|
|
||||||
body = payload.encode('utf-8')
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
|
||||||
self.send_header('Content-Length', str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception('status endpoint error')
|
|
||||||
body = b'{"error":"internal"}'
|
|
||||||
self.send_response(500)
|
|
||||||
self.send_header('Content-Type', 'application/json')
|
|
||||||
self.send_header('Content-Length', str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
elif self.path in ('/', '/healthz'):
|
|
||||||
body = b'ok\n'
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-Type', 'text/plain')
|
|
||||||
self.send_header('Content-Length', str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
else:
|
|
||||||
body = b'{"error":"not found"}\n'
|
|
||||||
self.send_response(404)
|
|
||||||
self.send_header('Content-Type', 'application/json')
|
|
||||||
self.send_header('Content-Length', str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def log_message(self, fmt, *args):
|
|
||||||
log.debug('http: ' + fmt, *args)
|
|
||||||
|
|
||||||
return Handler
|
|
||||||
|
|
||||||
|
|
||||||
def run_service(config):
|
|
||||||
monitor = Monitor(config)
|
|
||||||
|
|
||||||
def poll_loop():
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
n = monitor.poll_vnstat()
|
|
||||||
log.debug('vnstat poll updated %d month(s)', n)
|
|
||||||
except Exception:
|
|
||||||
log.exception('poll loop error')
|
|
||||||
time.sleep(int(config.get('poll_interval_sec', 60)))
|
|
||||||
|
|
||||||
def schedule_loop():
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
monitor.check_azure_usage() # 先刷新 Azure 口径数据,再评估告警
|
|
||||||
monitor.check_alerts()
|
|
||||||
monitor.check_monthly_report()
|
|
||||||
except Exception:
|
|
||||||
log.exception('schedule loop error')
|
|
||||||
time.sleep(int(config.get('schedule_interval_sec', 30)))
|
|
||||||
|
|
||||||
threading.Thread(target=poll_loop, daemon=True, name='vnstat-poll').start()
|
|
||||||
threading.Thread(target=schedule_loop, daemon=True, name='scheduler').start()
|
|
||||||
|
|
||||||
host = config.get('http_host', '127.0.0.1')
|
|
||||||
port = int(config.get('http_port', 18080))
|
|
||||||
server = ThreadingHTTPServer((host, port), build_http_handler(monitor))
|
|
||||||
log.info('HTTP server listening on http://%s:%d/vpn/status/traffic', host, port)
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser(description='vnstat traffic monitor')
|
|
||||||
ap.add_argument('--config', default=os.environ.get('TRAFFIC_CONFIG', '/home/agent/config.json'))
|
|
||||||
ap.add_argument('--once', action='store_true', help='拉取一次 vnstat 后退出')
|
|
||||||
ap.add_argument('--status', action='store_true', help='打印当前状态 JSON 后退出')
|
|
||||||
ap.add_argument('--test-email', action='store_true', help='发送一封测试邮件后退出')
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
os.environ['TRAFFIC_CONFIG'] = args.config
|
|
||||||
with open(args.config, 'r', encoding='utf-8') as f:
|
|
||||||
config = json.load(f)
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=getattr(logging, config.get('log_level', 'INFO').upper(), logging.INFO),
|
|
||||||
format='%(asctime)s %(levelname)s %(name)s: %(message)s')
|
|
||||||
|
|
||||||
if args.once:
|
|
||||||
m = Monitor(config)
|
|
||||||
n = m.poll_vnstat()
|
|
||||||
print(f'updated {n} month(s)')
|
|
||||||
return 0
|
|
||||||
if args.status:
|
|
||||||
m = Monitor(config)
|
|
||||||
m.poll_vnstat()
|
|
||||||
print(json.dumps(m.current_status(), ensure_ascii=False, indent=2))
|
|
||||||
return 0
|
|
||||||
if args.test_email:
|
|
||||||
m = Monitor(config)
|
|
||||||
m.poll_vnstat()
|
|
||||||
quota_gb = float(config.get('azure_free_limit_gb', 15))
|
|
||||||
az = m.azure_usage or {}
|
|
||||||
used_gb = az.get('portal_data_transfer_out_gb')
|
|
||||||
if used_gb is None:
|
|
||||||
try:
|
|
||||||
m.fetch_azure_usage()
|
|
||||||
az = m.azure_usage or {}
|
|
||||||
used_gb = az.get('portal_data_transfer_out_gb')
|
|
||||||
except Exception as e:
|
|
||||||
print('azure usage fetch failed:', e)
|
|
||||||
mk = 'azure:' + str(az.get('month') or utcnow().strftime('%Y-%m'))
|
|
||||||
pct = used_gb / quota_gb * 100 if (used_gb is not None and quota_gb) else 0.0
|
|
||||||
subject, body = m.build_alert_mail(mk, used_gb or 0.0, quota_gb, pct, az)
|
|
||||||
print('sending test email:')
|
|
||||||
print('Subject:', subject)
|
|
||||||
print(body)
|
|
||||||
m.send_email(subject, body)
|
|
||||||
print('sent')
|
|
||||||
return 0
|
|
||||||
run_service(config)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.exit(main())
|
|
||||||
Reference in New Issue
Block a user