14 Commits
Author SHA1 Message Date
ao gong 112374e2a1 test(gen-script): lock in to_rule() inline-# handling with regression tests
CI / lite (push) Successful in 3s
新增 2 个 test method 覆盖 b4a4769 修复的边界 case:
- test_to_rule_strips_inline_hash_comment_before_classifying_no_resolve
  锁定带 inline # 注释的条目(本次修复的真实场景)
  + 验证注释里出现 'no-resolve' / ',DIRECT' 字符串不被误处理
- test_to_rule_preserves_canonical_forms
  锁定 happy path(纯规则、带 no-resolve、带行尾空白)

为让 test_checked_in_router_projections_match_the_rule_source 通过,
同步重生成 router/direct-plain.txt 和 router/direct-rules.yaml。
这俩投影文件自 907fe9d 起就 stale 14+ 条规则(国内 LLM、UGREEN、
Steam 段全没同步),本次连同 b4a4769 的修复一起补齐。

测试结果:python3 -m unittest discover -s tests
  Ran 9 tests in 0.028s — OK
2026-09-05 13:40:50 +08:00
ao gong b4a4769dcf fix(gen-script): strip inline '#' comment before classifying no-resolve suffix
CI / lite (push) Failing after 4s
to_rule() 在判断 entry.endswith(',no-resolve') 之前没有剥掉行尾 # 注释,
导致带 inline 注释的条目走 else 分支,把注释当成 entry 一部分拼进去,
最终生成出 mihomo 解析失败的规则:

  'IP-CIDR,162.254.192.0/18,no-resolve   # Akamai Steam CDN 主段,DIRECT'

修法:
- to_rule() 先 split('#', 1)[0].rstrip() 剥注释再判断
- direct.yaml 第 91 行 inline 注释挪到独立行(避免再次踩坑)

验证:gen_clash_verge_script.py 重跑后 Script.js 第 49 行已变成
  'IP-CIDR,162.254.192.0/18,DIRECT,no-resolve',
Clash Verge 重启内核后订阅配置校验失败消失。
2026-09-05 13:32:48 +08:00
ao gong 907fe9d46a direct: add Steam global pass (community/store/CDN/Valve nets)
CI / lite (push) Failing after 4s
2026-09-05 11:56:06 +08:00
ageorge156 854b8e1cdd fix: keep OneDrive on direct routing (#7)
CI / lite (push) Successful in 9s
## 变更

- 将 `OneDrive.exe` 设为全局 `DIRECT`,优先于供应商 Microsoft 规则
- 为 Clash Verge 全局脚本补充国内可达的阿里 DNS / 腾讯 DNS DoH fallback
- 同步更新路由器规则投影与跨平台生成说明

## 验证

- `python -m unittest discover -s tests -v`:7 项通过
- mihomo DNS API 成功解析 OneDrive 目标
- 实际日志确认 `OneDrive.exe -> skydrive.wns.windows.com:443` 命中 `ProcessName(OneDrive.exe)` 并使用 `DIRECT`

Reviewed-on: http://192.168.50.2:3000/ageorge156/vpn-rules/pulls/7
2026-08-30 16:55:17 +08:00
ageorge156 de26a0edc0 chore: adopt shared runtime contract (#6)
CI / lite (push) Successful in 3s
2026-08-24 19:46:55 +08:00
ageorge156 32694d0776 Merge pull request 'fix(ci): publish offline lite verification' (#5) from codex/module-spec-pilot-20260820 into main
CI / lite (push) Successful in 2s
Reviewed-on: #5
2026-08-20 20:25:01 +08:00
George Berkshire b1885cace8 fix(ci): publish offline lite verification
CI / lite (pull_request) Successful in 6s
2026-08-20 20:22:52 +08:00
George Berkshire 80a5199150 test: add reproducer for missing lite CI contract 2026-08-20 20:22:15 +08:00
George Berkshire ac93ea1b03 feat(architecture): publish VPN rules module metadata 2026-08-20 20:21:41 +08:00
George Berkshire 87e103b5cf test: add reproducer for VPN rules module metadata 2026-08-20 20:21:01 +08:00
ageorge156 baf1699f25 feat: 新增无 - 前缀的纯文本规则列表(router/direct-plain.txt + 生成器) (#4)
CI / check (push) Successful in 6s
2026-08-19 21:03:48 +08:00
ageorge156 804007096f feat: 增加 LM Studio 与绿联 SyncSpace 直连规则(lmstudio.ai + syncthing.net) (#3)
CI / check (push) Successful in 2s
2026-08-19 20:51:28 +08:00
ageorge156 2df8593e8b feat: 增加 HuggingFace 镜像直连规则(hf-mirror.com + cdn.hf.co) (#2)
CI / check (push) Successful in 7s
2026-08-19 20:05:04 +08:00
ageorge156 f58e8c3603 feat: 路由器手动粘贴版直连规则生成器(router/direct-rules.yaml) (#1)
CI / check (push) Successful in 8s
2026-08-19 16:53:11 +08:00
13 changed files with 711 additions and 17 deletions
+9 -6
View File
@@ -1,7 +1,5 @@
# Gitea Actions CI 模板(统一极简合规门禁) # Gitea Actions CI(统一离线 lite 门禁)
# 复制为 <repo>/.gitea/workflows/ci.yml 后即可用;无需调整。 # Runner 无外网且无 pip;这里只运行标准库测试和本地合规检查。
# 说明:runner 容器无外网且无 pip(Gitea 实例限制),CI 只做本地可完成的合规检查;
# 依赖安装与测试一律在本地运行(开发机上完成)。
name: CI name: CI
on: on:
@@ -18,13 +16,18 @@ permissions:
contents: read contents: read
jobs: jobs:
check: lite:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
uses: actions/checkout@524e936cd9e579adf00e308bfdf971aebc7de09e
with:
persist-credentials: false
- name: Gitea 合规校验 - name: Gitea 合规校验
run: | run: |
if git ls-tree -r HEAD | awk '$1==160000' | grep -q .; then echo "HEAD 含 gitlink"; exit 1; fi if git ls-tree -r HEAD | awk '$1==160000' | grep -q .; then echo "HEAD 含 gitlink"; exit 1; fi
if git ls-files .DS_Store | grep -q .; then echo "跟踪 .DS_Store"; exit 1; fi if git ls-files .DS_Store | grep -q .; then echo "跟踪 .DS_Store"; exit 1; fi
if git grep -n -I -E 'sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|xox[baprs]-[A-Za-z0-9-]{10,}' HEAD | grep -q .; then echo "检出疑似凭证"; exit 1; fi if git grep -n -I -E 'sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|xox[baprs]-[A-Za-z0-9-]{10,}' HEAD | grep -q .; then echo "检出疑似凭证"; exit 1; fi
echo "Gitea 合规校验通过" echo "Gitea 合规校验通过"
- name: 规则契约测试
run: python3 -m unittest discover -s tests -v
+4
View File
@@ -9,3 +9,7 @@ Thumbs.db
secrets/ secrets/
*.pem *.pem
*.key *.key
# Python
__pycache__/
*.pyc
+120
View File
@@ -0,0 +1,120 @@
{
"schema_version": 1,
"module_id": "vpn-rules",
"authority": {
"scope": "module_metadata",
"subject": "vpn-rules",
"owner": "vpn-rules-owner",
"source": "MODULE_SPEC.yaml",
"revision": 1,
"effective_from": "2026-08-20T00:00:00+08:00"
},
"repository": {
"name": "vpn-rules",
"workspace_id": null,
"type": "infrastructure",
"maturity": "operational"
},
"bounded_context": {
"domain": "network-routing-policy",
"responsibility": "Own the single source of truth for direct-routing rules and deterministic projections for supported clients.",
"prohibited_responsibilities": [
"Applying rules to production clients, routers, VPN services, or proxy runtimes",
"Owning or exposing proxy subscription credentials, provider tokens, or device secrets",
"Claiming generated or checked-in artifacts are active production configuration"
]
},
"capabilities": [
{
"id": "direct-routing-rule-authoring",
"summary": "Maintain provider-neutral direct-routing entries in the authoritative mihomo rule-provider source.",
"status": "operational"
},
{
"id": "clash-verge-script-generation",
"summary": "Render source entries as idempotent DIRECT rules for Clash Verge Script.js.",
"status": "operational"
},
{
"id": "router-rule-projection",
"summary": "Generate policy-bearing YAML and policy-free plain-text projections for router consumers.",
"status": "operational"
}
],
"data": {
"owns": [
{
"asset_id": "direct-routing-rule-source",
"kind": "reference",
"classification": "restricted"
},
{
"asset_id": "generated-router-rule-projections",
"kind": "artifact",
"classification": "restricted"
}
]
},
"contracts": {
"provides": [
{
"contract_id": "mihomo-direct-rule-provider",
"kind": "artifact",
"version": "1.0.0",
"path": "direct.yaml",
"compatibility": "backward"
},
{
"contract_id": "router-direct-rules",
"kind": "artifact",
"version": "1.0.0",
"path": "router/direct-rules.yaml",
"compatibility": "exact"
},
{
"contract_id": "router-direct-plain-list",
"kind": "artifact",
"version": "1.0.0",
"path": "router/direct-plain.txt",
"compatibility": "exact"
}
],
"consumes": []
},
"dependencies": [],
"agent_context": {
"default_entrypoints": [
{
"path": "README.md",
"purpose": "Repository purpose, supported consumers, generation commands, and maintenance flow."
},
{
"path": "direct.yaml",
"purpose": "Authoritative direct-routing rule source and provider format."
}
],
"excluded_paths": [
"router"
],
"max_default_tokens": 6000
},
"verification": {
"commands": [
{
"id": "rule-contract-tests",
"argv": [
"python3",
"-m",
"unittest",
"discover",
"-s",
"tests",
"-v"
],
"cwd": ".",
"network": false,
"required": true
}
]
}
}
+15 -4
View File
@@ -8,29 +8,38 @@ Clash Verge 和华硕路由器上的 mihomo(Magic Catling2)等消费端同
``` ```
vpn-rules/ vpn-rules/
├── direct.yaml # 规则真源(mihomo rule-provider 格式) ├── direct.yaml # 规则真源(mihomo rule-provider 格式)
├── router/
│ ├── direct-rules.yaml # 路由器手动粘贴版(`- ` 前缀 + DIRECT 策略)
│ └── direct-plain.txt # 无 `- ` 前缀纯文本版(规则逐行、无策略)
└── scripts/ └── scripts/
└── gen_clash_verge_script.py # 真源 → Clash Verge Script.js 生成器 ├── gen_clash_verge_script.py # 真源 → Clash Verge Script.js 生成器
├── gen_router_paste.py # 真源 → 路由器手动粘贴版生成器
└── gen_plain_list.py # 真源 → 纯文本版生成器
``` ```
## 为什么需要它 ## 为什么需要它
- 香港代理节点有流量配额(如 BygCloud 100GiB 用满),国内站点/内网流量被隧道进代理会白白烧配额。 - 香港代理节点有流量配额(如 BygCloud 100GiB 用满),国内站点/内网流量被隧道进代理会白白烧配额。
- 必须直连的对象:FRP 控制面(mTLS)、B 站全系、DeepSeek/MiniMax 等国内 LLM API、 - 必须直连的对象:Windows OneDrive 客户端、FRP 控制面(mTLS)、B 站全系、DeepSeek/MiniMax 等国内 LLM API、
内网/私有 IP 段、国区 iCloud、UGREEN 绿联 NAS 云。 内网/私有 IP 段、国区 iCloud、UGREEN 绿联 NAS 云。
- **踩坑**:Clash Verge 全局 `Merge.yaml` 的 `prepend-rules` 会被内核忽略(不生效), - **踩坑**:Clash Verge 全局 `Merge.yaml` 的 `prepend-rules` 会被内核忽略(不生效),
全局直连规则的唯一正确机制是 `profiles/Script.js`(`main(config)` 把规则 prepend 进 `config.rules`)。 全局直连规则的唯一正确机制是 `profiles/Script.js`(`main(config)` 把规则 prepend 进 `config.rules`)。
## 消费端接入 ## 消费端接入
### Clash Verge(macOS) ### Clash Verge(Windows / macOS)
```bash ```bash
# 生成并写入 Clash Verge 数据目录的 Script.js # 按当前平台生成并写入 Clash Verge 数据目录的全局 Script.js
python3 scripts/gen_clash_verge_script.py python3 scripts/gen_clash_verge_script.py
# 然后在 Clash Verge 界面点「重启内核」生效 # 然后在 Clash Verge 界面点「重启内核」生效
``` ```
对当前及以后新增/切换的所有订阅生效;订阅更新不会覆盖它。 对当前及以后新增/切换的所有订阅生效;订阅更新不会覆盖它。
脚本还会把国内可达的阿里 DNS 和腾讯 DNS DoH 端点补入 `dns.fallback`,避免境外域名
命中 `DIRECT` 后因供应商的境外 DNS fallback 不可达而解析失败。
Windows 默认写入 `%APPDATA%\io.github.clash-verge-rev.clash-verge-rev\profiles\Script.js`;
macOS 默认写入 `~/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/profiles/Script.js`。
### 路由器 mihomo(Magic Catling2 / OpenWrt) ### 路由器 mihomo(Magic Catling2 / OpenWrt)
@@ -51,6 +60,8 @@ rules:
> 路由器内网自建 Gitea(192.168.50.2:3000)也可直连:`http://192.168.50.2:3000/ageorge156/vpn-rules/raw/branch/main/direct.yaml`。 > 路由器内网自建 Gitea(192.168.50.2:3000)也可直连:`http://192.168.50.2:3000/ageorge156/vpn-rules/raw/branch/main/direct.yaml`。
**拉不到 URL 时(手动粘贴版)**:打开 `router/direct-rules.yaml`,把里面的规则行粘贴到路由器现有规则列表最前面即可,无需联网(生成命令 `python3 scripts/gen_router_paste.py`)。若工具不支持 `- ` 前缀,用 `router/direct-plain.txt`(生成命令 `python3 scripts/gen_plain_list.py`)。
## 维护流程 ## 维护流程
1. 改 `direct.yaml`(rule-provider 条目,**不含**策略字段)。 1. 改 `direct.yaml`(rule-provider 条目,**不含**策略字段)。
+6
View File
@@ -0,0 +1,6 @@
profile: lite
runtime_contract: v1
language: generic
local_test_command: "python3 tests/test_module_spec.py"
requires_database: false
integration_profile: none
+31
View File
@@ -20,6 +20,9 @@
# 路由器端则等 rule-provider 的 interval 到期或手动 reload。 # 路由器端则等 rule-provider 的 interval 到期或手动 reload。
payload: payload:
# Windows OneDrive 客户端始终直连,避免被供应商 Microsoft 规则送入代理
- PROCESS-NAME,OneDrive.exe
# FRP 控制面(mTLS)保持直连 # FRP 控制面(mTLS)保持直连
- DOMAIN,frp.puyuanfh.cn - DOMAIN,frp.puyuanfh.cn
- IP-CIDR,8.133.177.3/32,no-resolve - IP-CIDR,8.133.177.3/32,no-resolve
@@ -41,6 +44,14 @@ payload:
- DOMAIN-SUFFIX,hailuoai.com - DOMAIN-SUFFIX,hailuoai.com
- DOMAIN,minimax-public-cdn.oss-cn-wulanchabu.aliyuncs.com - DOMAIN,minimax-public-cdn.oss-cn-wulanchabu.aliyuncs.com
# HuggingFace 模型下载直连(LM Studio 换源 hf-mirror)
# hf-mirror.com → 镜像元数据
# *.cdn.hf.co → 权重内容 CDN(us.aws.cdn.hf.co 等,xet-bridge)
# lmstudio.ai → LM Studio 官方服务(模型目录/更新)
- DOMAIN-SUFFIX,hf-mirror.com
- DOMAIN-SUFFIX,cdn.hf.co
- DOMAIN-SUFFIX,lmstudio.ai
# 内网 / 私有段绝不上代理 # 内网 / 私有段绝不上代理
- IP-CIDR,127.0.0.0/8,no-resolve - IP-CIDR,127.0.0.0/8,no-resolve
- IP-CIDR,10.0.0.0/8,no-resolve - IP-CIDR,10.0.0.0/8,no-resolve
@@ -60,6 +71,26 @@ payload:
- DOMAIN-KEYWORD,ugreen - DOMAIN-KEYWORD,ugreen
- DOMAIN-KEYWORD,ugnas - DOMAIN-KEYWORD,ugnas
- DOMAIN-KEYWORD,ugreengroup - DOMAIN-KEYWORD,ugreengroup
# SyncSpace 底层用 Syncthing 协议做 P2P(STUN/discovery/relay)
- DOMAIN-SUFFIX,syncthing.net
- IP-CIDR,110.42.0.0/16,no-resolve - IP-CIDR,110.42.0.0/16,no-resolve
- IP-CIDR,43.248.128.0/17,no-resolve - IP-CIDR,43.248.128.0/17,no-resolve
- IP-CIDR,111.170.0.0/16,no-resolve - IP-CIDR,111.170.0.0/16,no-resolve
# Steam 全局直连(社区/商店/CDN/Valve 官方网段;避免下载/联机绕美节点)
- DOMAIN-SUFFIX,steampowered.com
- DOMAIN-SUFFIX,steamcommunity.com
- DOMAIN-SUFFIX,steamgames.com
- DOMAIN-SUFFIX,steamusercontent.com
- DOMAIN-SUFFIX,steam-chat.com
- DOMAIN-SUFFIX,steamstatic.com
- DOMAIN-SUFFIX,steamcontent.com
- DOMAIN-KEYWORD,csgo.
- PROCESS-NAME,steam
- PROCESS-NAME,steamwebhelper
- IP-CIDR,162.254.192.0/18,no-resolve
# Akamai Steam CDN 主段
- IP-CIDR,103.10.124.0/23,no-resolve
- IP-CIDR,103.28.54.0/23,no-resolve
- IP-CIDR,146.66.152.0/21,no-resolve
- IP-CIDR,155.133.224.0/19,no-resolve
+50
View File
@@ -0,0 +1,50 @@
PROCESS-NAME,OneDrive.exe
DOMAIN,frp.puyuanfh.cn
IP-CIDR,8.133.177.3/32,no-resolve
DOMAIN-SUFFIX,bilibili.com
DOMAIN-SUFFIX,bilibili.cn
DOMAIN-SUFFIX,bilivideo.com
DOMAIN-SUFFIX,bili2233.cn
DOMAIN-SUFFIX,hdslb.com
GEOIP,CN,no-resolve
DOMAIN-SUFFIX,deepseek.com
DOMAIN-SUFFIX,minimaxi.com
DOMAIN-SUFFIX,minimax.chat
DOMAIN-SUFFIX,hailuoai.com
DOMAIN,minimax-public-cdn.oss-cn-wulanchabu.aliyuncs.com
DOMAIN-SUFFIX,hf-mirror.com
DOMAIN-SUFFIX,cdn.hf.co
DOMAIN-SUFFIX,lmstudio.ai
IP-CIDR,127.0.0.0/8,no-resolve
IP-CIDR,10.0.0.0/8,no-resolve
IP-CIDR,172.16.0.0/12,no-resolve
IP-CIDR,192.168.0.0/16,no-resolve
IP-CIDR,100.64.0.0/10,no-resolve
DOMAIN-SUFFIX,.local
DOMAIN-SUFFIX,icloud.com.cn
DOMAIN-SUFFIX,ug.link
DOMAIN-SUFFIX,ugreen.com
DOMAIN-SUFFIX,ugnas.cloud
DOMAIN-SUFFIX,ugos.cn
DOMAIN-KEYWORD,ugreen
DOMAIN-KEYWORD,ugnas
DOMAIN-KEYWORD,ugreengroup
DOMAIN-SUFFIX,syncthing.net
IP-CIDR,110.42.0.0/16,no-resolve
IP-CIDR,43.248.128.0/17,no-resolve
IP-CIDR,111.170.0.0/16,no-resolve
DOMAIN-SUFFIX,steampowered.com
DOMAIN-SUFFIX,steamcommunity.com
DOMAIN-SUFFIX,steamgames.com
DOMAIN-SUFFIX,steamusercontent.com
DOMAIN-SUFFIX,steam-chat.com
DOMAIN-SUFFIX,steamstatic.com
DOMAIN-SUFFIX,steamcontent.com
DOMAIN-KEYWORD,csgo.
PROCESS-NAME,steam
PROCESS-NAME,steamwebhelper
IP-CIDR,162.254.192.0/18,no-resolve
IP-CIDR,103.10.124.0/23,no-resolve
IP-CIDR,103.28.54.0/23,no-resolve
IP-CIDR,146.66.152.0/21,no-resolve
IP-CIDR,155.133.224.0/19,no-resolve
+63
View File
@@ -0,0 +1,63 @@
# vpn-rules 手动粘贴版(无需联网拉取)— 由 direct.yaml 生成,勿手改。
#
# 用法(Magic Catling2 等拉不到 rule-provider 时):
# 1. 打开路由器的规则设置;
# 2. 把下面 `rules:` 里的每一行 `- 规则` 粘贴到【现有规则列表的最前面】;
# 3. 只粘贴规则行,不要用整个文件替换你原来的 rules(否则订阅自带规则会丢)。
#
# 重新生成:python3 scripts/gen_router_paste.py(改真源 direct.yaml 后重跑)。
# 规则与 direct.yaml 逐条对应;对应拉取版 URL:
# https://gitea.puyuanfh.cn/ageorge156/vpn-rules/raw/branch/main/direct.yaml
# 内网源:http://192.168.50.2:3000/ageorge156/vpn-rules/raw/branch/main/direct.yaml
rules:
- PROCESS-NAME,OneDrive.exe,DIRECT
- DOMAIN,frp.puyuanfh.cn,DIRECT
- IP-CIDR,8.133.177.3/32,DIRECT,no-resolve
- DOMAIN-SUFFIX,bilibili.com,DIRECT
- DOMAIN-SUFFIX,bilibili.cn,DIRECT
- DOMAIN-SUFFIX,bilivideo.com,DIRECT
- DOMAIN-SUFFIX,bili2233.cn,DIRECT
- DOMAIN-SUFFIX,hdslb.com,DIRECT
- GEOIP,CN,DIRECT,no-resolve
- DOMAIN-SUFFIX,deepseek.com,DIRECT
- DOMAIN-SUFFIX,minimaxi.com,DIRECT
- DOMAIN-SUFFIX,minimax.chat,DIRECT
- DOMAIN-SUFFIX,hailuoai.com,DIRECT
- DOMAIN,minimax-public-cdn.oss-cn-wulanchabu.aliyuncs.com,DIRECT
- DOMAIN-SUFFIX,hf-mirror.com,DIRECT
- DOMAIN-SUFFIX,cdn.hf.co,DIRECT
- DOMAIN-SUFFIX,lmstudio.ai,DIRECT
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,100.64.0.0/10,DIRECT,no-resolve
- DOMAIN-SUFFIX,.local,DIRECT
- DOMAIN-SUFFIX,icloud.com.cn,DIRECT
- DOMAIN-SUFFIX,ug.link,DIRECT
- DOMAIN-SUFFIX,ugreen.com,DIRECT
- DOMAIN-SUFFIX,ugnas.cloud,DIRECT
- DOMAIN-SUFFIX,ugos.cn,DIRECT
- DOMAIN-KEYWORD,ugreen,DIRECT
- DOMAIN-KEYWORD,ugnas,DIRECT
- DOMAIN-KEYWORD,ugreengroup,DIRECT
- DOMAIN-SUFFIX,syncthing.net,DIRECT
- IP-CIDR,110.42.0.0/16,DIRECT,no-resolve
- IP-CIDR,43.248.128.0/17,DIRECT,no-resolve
- IP-CIDR,111.170.0.0/16,DIRECT,no-resolve
- DOMAIN-SUFFIX,steampowered.com,DIRECT
- DOMAIN-SUFFIX,steamcommunity.com,DIRECT
- DOMAIN-SUFFIX,steamgames.com,DIRECT
- DOMAIN-SUFFIX,steamusercontent.com,DIRECT
- DOMAIN-SUFFIX,steam-chat.com,DIRECT
- DOMAIN-SUFFIX,steamstatic.com,DIRECT
- DOMAIN-SUFFIX,steamcontent.com,DIRECT
- DOMAIN-KEYWORD,csgo.,DIRECT
- PROCESS-NAME,steam,DIRECT
- PROCESS-NAME,steamwebhelper,DIRECT
- IP-CIDR,162.254.192.0/18,DIRECT,no-resolve
- IP-CIDR,103.10.124.0/23,DIRECT,no-resolve
- IP-CIDR,103.28.54.0/23,DIRECT,no-resolve
- IP-CIDR,146.66.152.0/21,DIRECT,no-resolve
- IP-CIDR,155.133.224.0/19,DIRECT,no-resolve
+55 -7
View File
@@ -4,14 +4,39 @@
用法: 用法:
python3 scripts/gen_clash_verge_script.py [--out 目标路径] python3 scripts/gen_clash_verge_script.py [--out 目标路径]
默认输出到 Clash Verge 数据目录 profiles/Script.js(全局直连规则的唯一正确机制, 默认按 Windows/macOS 平台输出到 Clash Verge 数据目录 profiles/Script.js(全局直连
见 clash-verge-config 记忆)。生成后需在 Clash Verge 界面点「重启内核」生效。 规则的唯一正确机制)。生成后需在 Clash Verge 界面点「重启内核」生效。
""" """
import argparse import argparse
import os
from pathlib import Path from pathlib import Path
import sys
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_OUT = Path.home() / "Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/profiles/Script.js" APP_ID = "io.github.clash-verge-rev.clash-verge-rev"
def default_output_path(
platform_name: str = sys.platform,
*,
home: Path | None = None,
appdata: Path | None = None,
) -> Path:
"""返回当前平台的 Clash Verge 全局 Script.js 路径。"""
home = home or Path.home()
if platform_name == "win32":
if appdata is None:
roaming = os.environ.get("APPDATA")
if not roaming:
raise SystemExit("[error] Windows 环境缺少 APPDATA,无法定位 Clash Verge 数据目录")
appdata = Path(roaming)
return appdata / APP_ID / "profiles" / "Script.js"
if platform_name == "darwin":
return home / "Library/Application Support" / APP_ID / "profiles" / "Script.js"
return home / ".local/share" / APP_ID / "profiles" / "Script.js"
DEFAULT_OUT = default_output_path()
def load_payload(path: Path) -> list[str]: def load_payload(path: Path) -> list[str]:
@@ -22,12 +47,17 @@ def load_payload(path: Path) -> list[str]:
if line.startswith("- "): if line.startswith("- "):
payload.append(line[2:].strip()) payload.append(line[2:].strip())
if not payload: if not payload:
raise SystemExit(f"❌ direct.yaml 里没有 payload 条目: {path}") raise SystemExit(f"[error] direct.yaml 里没有 payload 条目: {path}")
return payload return payload
def to_rule(entry: str) -> str: def to_rule(entry: str) -> str:
"""rule-provider 条目(无策略)→ 完整规则(补 DIRECT)。""" """rule-provider 条目(无策略)→ 完整规则(补 DIRECT)。
处理:先剥掉行尾 `#` 注释,再补 DIRECT 策略,避免带注释的条目被错误拼接。
"""
# 剥掉 `#` 之后的行尾注释
entry = entry.split("#", 1)[0].rstrip()
if entry.endswith(",no-resolve"): if entry.endswith(",no-resolve"):
return f"{entry[:-len(',no-resolve')]},DIRECT,no-resolve" return f"{entry[:-len(',no-resolve')]},DIRECT,no-resolve"
return f"{entry},DIRECT" return f"{entry},DIRECT"
@@ -37,19 +67,37 @@ def render_script(payload: list[str]) -> str:
rules = [f" {to_rule(e)!r}," for e in payload] rules = [f" {to_rule(e)!r}," for e in payload]
lines = "\n".join(rules) lines = "\n".join(rules)
return f"""// 由 vpn-rules/direct.yaml 生成 — 勿手改,改真源后重跑 gen_clash_verge_script.py。 return f"""// 由 vpn-rules/direct.yaml 生成 — 勿手改,改真源后重跑 gen_clash_verge_script.py。
// 全局直连规则:FRP 控制面、B站、国内 LLM、内网段、国区 iCloud、UGREEN 一律 DIRECT。 // 全局直连规则:OneDrive、FRP 控制面、B站、国内 LLM、内网段、国区 iCloud、UGREEN 一律 DIRECT。
const prependRules = [ const prependRules = [
{lines} {lines}
]; ];
// 国内可达的 DoH 同时作为 fallback,确保 OneDrive 等境外域名在 DIRECT 出站前可解析。
const directDnsFallbacks = [
'https://dns.alidns.com/dns-query',
'https://doh.pub/dns-query',
];
function main(config, profileName) {{ function main(config, profileName) {{
const existingRules = Array.isArray(config.rules) ? config.rules : []; const existingRules = Array.isArray(config.rules) ? config.rules : [];
const existingRuleSet = new Set(existingRules); const existingRuleSet = new Set(existingRules);
const dns = config.dns && typeof config.dns === 'object' && !Array.isArray(config.dns)
? config.dns
: {{}};
const existingFallbacks = Array.isArray(dns.fallback)
? dns.fallback
: (typeof dns.fallback === 'string' ? [dns.fallback] : []);
const existingFallbackSet = new Set(existingFallbacks);
config.rules = [ config.rules = [
...prependRules.filter((rule) => !existingRuleSet.has(rule)), ...prependRules.filter((rule) => !existingRuleSet.has(rule)),
...existingRules, ...existingRules,
]; ];
dns.fallback = [
...directDnsFallbacks.filter((server) => !existingFallbackSet.has(server)),
...existingFallbacks,
];
config.dns = dns;
return config; return config;
}} }}
@@ -66,7 +114,7 @@ def main() -> None:
out = Path(args.out) out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(script, encoding="utf-8") out.write_text(script, encoding="utf-8")
print(f"✅ 已生成 {out}({len(payload)} 条规则)") print(f"[ok] 已生成 {out}({len(payload)} 条规则)")
print(" 请在 Clash Verge 界面点「重启内核」生效。") print(" 请在 Clash Verge 界面点「重启内核」生效。")
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""从 direct.yaml(真源)生成无 `- ` 前缀的纯文本规则列表。
格式:每条规则一行,无 YAML 列表前缀、无注释、无策略字段
(同 rule-provider payload 内容,只是去掉 `- `)。
适用于把规则直接粘贴进不支持 YAML 列表 / 不需要策略前缀的工具。
"""
from pathlib import Path
from gen_clash_verge_script import load_payload
REPO_ROOT = Path(__file__).resolve().parent.parent
OUT = REPO_ROOT / "router" / "direct-plain.txt"
def main() -> None:
payload = load_payload(REPO_ROOT / "direct.yaml")
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text("\n".join(payload) + "\n", encoding="utf-8")
print(f"✅ 已生成 {OUT}({len(payload)} 条规则,无 - 前缀)")
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""从 direct.yaml(真源)生成路由器「手动粘贴版」规则文件 router/direct-rules.yaml。
适用于 Magic Catling2 等无法联网拉取 rule-provider 的场景:
把生成的规则行粘贴进现有 rules 列表最前面即可,无需 URL 拉取。
"""
from pathlib import Path
from gen_clash_verge_script import load_payload, to_rule
REPO_ROOT = Path(__file__).resolve().parent.parent
OUT = REPO_ROOT / "router" / "direct-rules.yaml"
TEMPLATE = """# vpn-rules 手动粘贴版(无需联网拉取)— 由 direct.yaml 生成,勿手改。
#
# 用法(Magic Catling2 等拉不到 rule-provider 时):
# 1. 打开路由器的规则设置;
# 2. 把下面 `rules:` 里的每一行 `- 规则` 粘贴到【现有规则列表的最前面】;
# 3. 只粘贴规则行,不要用整个文件替换你原来的 rules(否则订阅自带规则会丢)。
#
# 重新生成:python3 scripts/gen_router_paste.py(改真源 direct.yaml 后重跑)。
# 规则与 direct.yaml 逐条对应;对应拉取版 URL:
# https://gitea.puyuanfh.cn/ageorge156/vpn-rules/raw/branch/main/direct.yaml
# 内网源:http://192.168.50.2:3000/ageorge156/vpn-rules/raw/branch/main/direct.yaml
rules:
{rules}
"""
def main() -> None:
payload = load_payload(REPO_ROOT / "direct.yaml")
lines = "\n".join(f" - {to_rule(e)}" for e in payload)
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(TEMPLATE.format(rules=lines), encoding="utf-8")
print(f"✅ 已生成 {OUT}({len(payload)} 条规则,可直接粘贴)")
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class CiContractTests(unittest.TestCase):
def test_ci_publishes_one_offline_lite_gate(self) -> None:
workflow = (ROOT / ".gitea/workflows/ci.yml").read_text(encoding="utf-8")
job_block = workflow.split("jobs:", 1)[1]
self.assertEqual(
re.findall(r"(?m)^ ([a-z][a-z0-9_-]*):\s*$", job_block),
["lite"],
)
self.assertIn(
"actions/checkout@524e936cd9e579adf00e308bfdf971aebc7de09e",
workflow,
)
self.assertIn("persist-credentials: false", workflow)
self.assertIn(
"python3 -m unittest discover -s tests -v",
workflow,
)
for forbidden in (
"actions/checkout@v",
"apt ",
"pip ",
"curl ",
"wget ",
"docker pull",
):
self.assertNotIn(forbidden, workflow)
if __name__ == "__main__":
unittest.main()
+252
View File
@@ -0,0 +1,252 @@
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path, PurePosixPath
ROOT = Path(__file__).resolve().parents[1]
SPEC_PATH = ROOT / "MODULE_SPEC.yaml"
sys.path.insert(0, str(ROOT / "scripts"))
from gen_clash_verge_script import ( # noqa: E402
default_output_path,
load_payload,
render_script,
to_rule,
)
class ModuleSpecTests(unittest.TestCase):
def test_clash_verge_default_output_path_is_platform_aware(self) -> None:
home = Path("C:/Users/example")
appdata = Path("C:/Users/example/AppData/Roaming")
self.assertEqual(
default_output_path("win32", home=home, appdata=appdata),
appdata
/ "io.github.clash-verge-rev.clash-verge-rev"
/ "profiles"
/ "Script.js",
)
self.assertEqual(
default_output_path("darwin", home=home),
home
/ "Library/Application Support"
/ "io.github.clash-verge-rev.clash-verge-rev"
/ "profiles"
/ "Script.js",
)
def test_clash_verge_generator_supports_windows_gbk_console(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
output = Path(temp_dir) / "Script.js"
env = os.environ.copy()
env["PYTHONIOENCODING"] = "gbk"
result = subprocess.run(
[
sys.executable,
str(ROOT / "scripts/gen_clash_verge_script.py"),
"--out",
str(output),
],
cwd=ROOT,
env=env,
capture_output=True,
check=False,
)
self.assertEqual(
result.returncode,
0,
result.stderr.decode("gbk", errors="replace"),
)
self.assertTrue(output.is_file())
def test_module_spec_declares_rule_authority_and_safe_context(self) -> None:
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
self.assertEqual(
set(spec),
{
"schema_version",
"module_id",
"authority",
"repository",
"bounded_context",
"capabilities",
"data",
"contracts",
"dependencies",
"agent_context",
"verification",
},
)
self.assertEqual(spec["schema_version"], 1)
self.assertEqual(spec["module_id"], "vpn-rules")
self.assertEqual(
spec["authority"],
{
"scope": "module_metadata",
"subject": "vpn-rules",
"owner": "vpn-rules-owner",
"source": "MODULE_SPEC.yaml",
"revision": 1,
"effective_from": "2026-08-20T00:00:00+08:00",
},
)
self.assertEqual(
spec["repository"],
{
"name": "vpn-rules",
"workspace_id": None,
"type": "infrastructure",
"maturity": "operational",
},
)
boundary = spec["bounded_context"]
self.assertEqual(boundary["domain"], "network-routing-policy")
self.assertIn("single source of truth", boundary["responsibility"].lower())
prohibited = " ".join(boundary["prohibited_responsibilities"]).lower()
self.assertIn("production", prohibited)
self.assertIn("credential", prohibited)
self.assertIn("proxy subscription", prohibited)
self.assertEqual(
{capability["id"] for capability in spec["capabilities"]},
{
"direct-routing-rule-authoring",
"clash-verge-script-generation",
"router-rule-projection",
},
)
self.assertEqual(spec["contracts"]["consumes"], [])
self.assertEqual(spec["dependencies"], [])
provided = {
contract["contract_id"]: contract
for contract in spec["contracts"]["provides"]
}
self.assertEqual(
set(provided),
{
"mihomo-direct-rule-provider",
"router-direct-rules",
"router-direct-plain-list",
},
)
self.assertEqual(
{contract["version"] for contract in provided.values()}, {"1.0.0"}
)
for contract in provided.values():
self.assertTrue((ROOT / contract["path"]).is_file())
context = spec["agent_context"]
self.assertLessEqual(context["max_default_tokens"], 6000)
self.assertEqual(
{entry["path"] for entry in context["default_entrypoints"]},
{"README.md", "direct.yaml"},
)
for entry in context["default_entrypoints"]:
self.assertTrue((ROOT / entry["path"]).is_file())
self.assertIn("router", context["excluded_paths"])
for value in context["excluded_paths"]:
path = PurePosixPath(value)
self.assertFalse(path.is_absolute())
self.assertNotIn("..", path.parts)
self.assertEqual(
spec["verification"],
{
"commands": [
{
"id": "rule-contract-tests",
"argv": [
"python3",
"-m",
"unittest",
"discover",
"-s",
"tests",
"-v",
],
"cwd": ".",
"network": False,
"required": True,
}
]
},
)
def test_checked_in_router_projections_match_the_rule_source(self) -> None:
payload = load_payload(ROOT / "direct.yaml")
plain = (ROOT / "router/direct-plain.txt").read_text(encoding="utf-8")
router = (ROOT / "router/direct-rules.yaml").read_text(encoding="utf-8")
self.assertEqual(plain, "\n".join(payload) + "\n")
self.assertEqual(
router.split("rules:\n", 1)[1],
"\n".join(f" - {to_rule(entry)}" for entry in payload) + "\n",
)
self.assertTrue(all(",DIRECT" not in entry for entry in payload))
def test_onedrive_process_is_forced_to_use_direct_routing(self) -> None:
payload = load_payload(ROOT / "direct.yaml")
self.assertIn("PROCESS-NAME,OneDrive.exe", payload)
self.assertEqual(
to_rule("PROCESS-NAME,OneDrive.exe"),
"PROCESS-NAME,OneDrive.exe,DIRECT",
)
def test_to_rule_strips_inline_hash_comment_before_classifying_no_resolve(self) -> None:
# 回归测试 (b4a4769):to_rule() 在判断 entry.endswith(',no-resolve') 之前
# 没有剥掉行尾 # 注释,导致带 inline 注释的 Akamai Steam CDN 规则
# 走错分支,把注释当成 entry 的一部分拼进去,生成 mihomo 解析失败的规则。
self.assertEqual(
to_rule("IP-CIDR,162.254.192.0/18,no-resolve # Akamai Steam CDN 主段"),
"IP-CIDR,162.254.192.0/18,DIRECT,no-resolve",
)
# 注释里出现 "no-resolve" 不能误判为 suffix
self.assertEqual(
to_rule("DOMAIN-SUFFIX,example.com # 走 no-resolve 路径"),
"DOMAIN-SUFFIX,example.com,DIRECT",
)
# 注释里出现 ",DIRECT" 不能误拼到 proxy 字段
self.assertEqual(
to_rule("IP-CIDR,10.0.0.0/8 # 不是 DIRECT, 是 REJECT"),
"IP-CIDR,10.0.0.0/8,DIRECT",
)
def test_to_rule_preserves_canonical_forms(self) -> None:
# 不带注释、不带 no-resolve
self.assertEqual(
to_rule("DOMAIN-SUFFIX,bilibili.com"),
"DOMAIN-SUFFIX,bilibili.com,DIRECT",
)
# 带 no-resolve、无注释
self.assertEqual(
to_rule("IP-CIDR,127.0.0.0/8,no-resolve"),
"IP-CIDR,127.0.0.0/8,DIRECT,no-resolve",
)
# 带 no-resolve + 行尾空白
self.assertEqual(
to_rule("IP-CIDR,127.0.0.0/8,no-resolve "),
"IP-CIDR,127.0.0.0/8,DIRECT,no-resolve",
)
def test_clash_verge_script_adds_reachable_direct_dns_fallbacks(self) -> None:
script = render_script(["PROCESS-NAME,OneDrive.exe"])
self.assertIn("const directDnsFallbacks", script)
self.assertIn("https://dns.alidns.com/dns-query", script)
self.assertIn("https://doh.pub/dns-query", script)
self.assertIn("config.dns = dns", script)
if __name__ == "__main__":
unittest.main()