diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 421fa39..bc3d2ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,5 +54,37 @@ jobs: echo "db214c7a2517e63c150d123178d16d102e03a241ccdae4e5e07ffbe9cf56c6f9 $RUNNER_TEMP/mihomo.gz" | sha256sum --check gzip --decompress "$RUNNER_TEMP/mihomo.gz" chmod 755 "$RUNNER_TEMP/mihomo" - MIHOMO_BIN="$RUNNER_TEMP/mihomo" python3 -m unittest discover -s tests -p 'test_mihomo_config.py' -v - MIHOMO_BIN="$RUNNER_TEMP/mihomo" python3 -m unittest discover -s tests -p 'test_aggregate.py' -v + mkdir -p "$RUNNER_TEMP/mihomo-data" + for pair in geoip.dat:geoip.dat geosite.dat:geosite.dat GeoLite2-ASN.mmdb:ASN.mmdb country.mmdb:Country.mmdb; do + source_name="${pair%%:*}" + target_name="${pair#*:}" + curl --fail --location --retry 3 --max-time 120 \ + "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/2df2f6281aa2d14d54b8a099b9326f63ba389fd4/$source_name" \ + --output "$RUNNER_TEMP/mihomo-data/$target_name" + done + export MIHOMO_BIN="$RUNNER_TEMP/mihomo" + export MIHOMO_DATA_DIR="$RUNNER_TEMP/mihomo-data" + python3 -m unittest discover -s tests -p 'test_mihomo_config.py' -v + python3 -m unittest discover -s tests -p 'test_aggregate*.py' -v + + subscription: + name: Validate private subscription Worker + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: cloud/subscription/package-lock.json + - name: Install locked dependencies + working-directory: cloud/subscription + run: npm ci --ignore-scripts + - name: Test real workerd and local KV + working-directory: cloud/subscription + run: npm test diff --git a/.gitignore b/.gitignore index fa41ffc..de2889d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,12 @@ __pycache__/ # Local Graphify analysis cache (may contain absolute paths and derived metadata) config/mcporter.json graphify-out/ + +# Worker tools and local runtime state +node_modules/ +.wrangler/ +.dev.vars* + +# Account-specific subscription deployment settings +/cloud/subscription/wrangler.jsonc +/cloud/subscription/endpoint.json diff --git a/README.md b/README.md index 084e482..db10ea7 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火 | 配置 | 默认值 | 说明 | |---|---|---| | `CLIENT_CONFIG_ENABLE` | `true` | 设为 `false` 跳过此 profile 的客户端输出,保留状态与凭据;不操作服务器 | -| `CLIENT_TARGET` | `stash` | 生成 Stash 或 `mihomo` 对应字段 | +| `CLIENT_IDENTITY` | 自动选择已有 `mac`,否则第一个身份 | 两个客户端共享此身份;默认同时输出 Stash / Mihomo | | `AI_STRICT_MODE` | `false` | 默认日常分流:AI 优先保护、国内默认直连;`true` 才将全部公网统一到 AI 线路 | | `REALITY_PORT` | `443` | Reality 监听端口 | | `REALITY_TARGET` / `REALITY_SNI` | `1.1.1.1:443` / 空 | Reality 目标与客户端 SNI | @@ -100,7 +100,7 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火 | `HY2_OBFS_ENABLE` | `false` | 可选 Salamander 混淆;开启后不再表现为标准 HTTP/3 | | `HY2_ACME_ENABLE` | `false` | 可选 Cloudflare DNS-01 真实证书 | | `ANYTLS_PORT` | 随机 | AnyTLS TCP 端口 | -| `DEVICES` | `mac iphone` | 每设备独立 Reality/HY2 凭据及 YAML;AnyTLS 为 profile 共享密码,自动管理 | +| `DEVICES` | `mac iphone` | 服务器既有 Reality/HY2 账号清单,不再按设备输出 YAML;AnyTLS 为 profile 共享密码 | | `PRIVACY_MODE` | `false` | 仅日常分流生效:国内组默认直连;`true` 则默认代理 | | `CDN_ENABLE` | `false` | 可选 Cloudflare Tunnel 出口 | | `CDN_ONLY` | `false` | 仅使用 Cloudflare WS,并关闭直连代理端口 | @@ -129,14 +129,12 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火 ## 导入客户端 -需要把多台服务器汇总为一份配置、敏感服务固定主备而普通海外自动选线时,使用 [多服务器汇总与日常分流](docs/aggregate-routing.md)。每个业务组保留默认策略和手动排查入口,完整节点列表只维护一处。 +多服务器日常分流使用 [双客户端汇总与白名单分流](docs/aggregate-routing.md):默认同时生成 Stash / Mihomo 两份文件,AI 等敏感服务走指定主备,国内域名白名单直连,明确列出的普通海外服务走自动测速,其余 TCP/UDP 由敏感主备兜底。共享身份计划不区分 Mac/iPhone 输出,Stash 可显式发布到 iCloud;Mihomo 可发布到 [Cloudflare 私有 HTTPS 订阅](cloud/subscription/README.md),通过钥匙串令牌复制链接或一键导入。 部署成功后,每个平台默认得到两份名称明确的 YAML: -- `clash-configs/gcloud-mac.yaml` -- `clash-configs/gcloud-iphone.yaml` -- `clash-configs/-mac.yaml` -- `clash-configs/-iphone.yaml` +- `clash-configs/stash/.yaml`(例如 `gcloud.yaml`) +- `clash-configs/mihomo/.yaml`(Clash Verge) 生成器通过精确文件归属清单管理输出,不按前缀通配符删除。`cstone` 与 `cstone-next` 可以安全共存; 不同 profile 指定相同输出文件会报错。旧版本未登记的文件、移除设备后被手工修改的文件会保留。 @@ -147,8 +145,11 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火 - Clash Verge:Settings → Profiles → Import - 其他客户端:使用支持 Reality、Hysteria2 和 AnyTLS 的 Mihomo/Clash.Meta 兼容客户端 -`CLIENT_TARGET=stash` 为默认目标,使用 Stash 的 HY2 `auth`、`up-speed/down-speed`、DNS `follow-rule` 和 STUN 协议规则; -`CLIENT_TARGET=mihomo` 生成 Mihomo 字段和 TUN 设置。两种目标共用源规则,但不能把同一份 YAML 视为两端字段完全等价。 +默认 `node.py render --profile ` 同时生成两种客户端;`--client stash|mihomo` 可单独更新一份,另一份保持原样。 +Stash 使用 HY2 `auth`、`up-speed/down-speed`、DNS `follow-rule` 和 STUN 协议规则;Mihomo 使用对应字段和 TUN 设置。 +`CLIENT_IDENTITY` 选定共享的既有凭据身份;留空时优先 `mac`,否则用 `DEVICES` 第一项。Mac/iPhone 共用同一份 Stash 文件;该身份名仅是历史账号键,不再是设备绑定。 +历史 `CLIENT_TARGET` 不再控制生成份数,定向输出改用 `--client`。同时输出时 HY2 跳端口间隔须用整数,范围仅支持 `--client mihomo`。 +旧设备文件保留供迁移,不再刷新;生成不修改 `DEVICES`、服务器账号或节点密码。两种目标共用源规则,但字段并不完全等价。 Stash 的节点域名独立解析功能需要 iOS/tvOS 3.6 或 macOS 4.3 及以上版本。 **默认使用日常分流:普通国内网站直连,AI 核心服务及已知依赖优先走 AI 线路。** @@ -181,7 +182,7 @@ AI 入口全部不可用时,公网业务失败;国内业务 DNS 分支也被 `HY2_PORT_RANGE`、`HY2_OBFS_ENABLE`、`HY2_ACME_ENABLE` 均为可选增强:开启后需要重新部署服务端并重新生成客户端 YAML;默认关闭时不改变已有协议行为。 -修改 `DEVICES` 后重跑同一个 profile 的入口,即可增加或撤销设备。 +修改 `DEVICES` 后重跑同一个 profile 的部署入口,会增加或撤销相应的服务器身份;这不是客户端输出开关。共享配置后无法仅靠该身份区分或撤销单台设备。 自签名 HY2 证书在下次成功部署后自动回传 SHA256 指纹并写入 YAML;已有 profile 尚无指纹时保留原兼容行为。 ACME 证书使用可持久写入的 `/var/lib/hysteria/acme`。AnyTLS 的临时自签名证书与 HY2 ACME 相互独立, @@ -274,7 +275,7 @@ Both entry points run the same shared pipeline: 2. Generate per-device credentials locally. 3. Provision or secure a reachable host. 4. Install BBR, Xray/Reality, Hysteria2, AnyTLS, systemd units, and security updates. -5. Recover the Reality public key and generate one Stash-first, Mihomo-compatible YAML per device. +5. Recover the Reality public key and generate separate Stash and Mihomo YAML files using a shared existing client identity. The provider adapters only own host lifecycle, connectivity, and firewall behaviour. Key generation, server configuration, routing rules, optional Cloudflare setup, and client generation remain in `core/`. diff --git a/aggregate.py b/aggregate.py index aa0b282..2276add 100644 --- a/aggregate.py +++ b/aggregate.py @@ -3,18 +3,40 @@ import argparse from pathlib import Path import sys +import subprocess ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(ROOT / 'core')) -from client_aggregate import generate +from client_aggregate import generate, publish_stash +from client_snapshot import import_snapshot def main(): parser = argparse.ArgumentParser(description='汇总本地节点;不部署服务器、不显示凭据') - parser.add_argument('action', choices=('render', 'check')) + parser.add_argument('action', choices=('render', 'check', 'publish', 'import')) parser.add_argument('--profile', required=True, help='保存 aggregate.json 的汇总 profile') - parser.add_argument('--client', choices=('stash', 'mihomo')) + parser.add_argument('--client', choices=('stash', 'mihomo', 'both'), + help='默认同时输出两种客户端,互不覆盖') + parser.add_argument('--icloud-dir', help='Stash 发布目录;默认使用 Stash iCloud Documents') + parser.add_argument('--source', help='首次迁移的本地完整客户端 YAML;不上传') args = parser.parse_args() + if args.action == 'import': + if not args.source or args.client or args.icloud_dir: + parser.error('import 需要 --source,且不接受 --client/--icloud-dir') + return import_snapshot(ROOT, args.profile, args.source) + if args.source: + parser.error('--source 仅用于 import') + if args.action == 'publish': + if args.client == 'mihomo': + if args.icloud_dir: + parser.error('Mihomo 发布到私有 HTTPS,不接受 --icloud-dir') + return subprocess.call([sys.executable, str(ROOT / 'subscription.py'), + 'publish', '--profile', args.profile]) + if args.client == 'both': + parser.error('请明确分别发布 stash 或 mihomo,以便核对不同分发渠道') + return publish_stash(ROOT, args.profile, args.icloud_dir) + if args.icloud_dir: + parser.error('--icloud-dir 仅用于 publish') return generate(ROOT, args.profile, target=args.client, check=args.action == 'check') diff --git a/cloud/subscription/.gitignore b/cloud/subscription/.gitignore new file mode 100644 index 0000000..8fbcf8f --- /dev/null +++ b/cloud/subscription/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.wrangler/ +.dev.vars* diff --git a/cloud/subscription/README.md b/cloud/subscription/README.md new file mode 100644 index 0000000..57d87cb --- /dev/null +++ b/cloud/subscription/README.md @@ -0,0 +1,55 @@ +# 私有 Mihomo 订阅 + +使用 Workers Free 与私有 KV,通过 `..workers.dev` 提供订阅,无需个人域名。源配置仍在本地,云端仅存验证后的 Mihomo YAML。账户/KV 标识与端点保存在被 Git 忽略的 `wrangler.jsonc` 和 `endpoint.json`,不要把个人部署信息提交到公共仓库。 + +## 日常使用 + +在仓库根目录运行: + +```bash +python3 aggregate.py render --profile routing +python3 aggregate.py publish --profile routing --client mihomo +python3 subscription.py verify --profile routing +python3 subscription.py copy-link --profile routing +python3 subscription.py import --profile routing +``` + +`import` 从钥匙串读取令牌,通过标准输入调用 macOS URL handler,使用 `clash-verge://install-config?url=...`。通用 `clash://` 可能被其他客户端注册,因此这里明确指定 Verge。copy-link 每次复制一个链接;日常仅需主配置。系统接受 URL 请求不等于客户端下载成功,必要时使用 copy-link 手动粘贴。已有同一订阅时无需重复导入。调用后客户端可能直接添加订阅;若客户端尚无当前配置,还可能选中它。工具不会主动开启 TUN 或系统代理。未执行导入前不能宣称客户端已经导入或运行通过。 + +单 VPS 文件从主配置同源生成,例如 `routing-cstone`、`routing-lax`、`routing-gcloud`。使用 `python3 subscription.py publish --profile routing --server gcloud` 发布,copy-link/import/verify 同样接受 --server。共享计划开启 standalone 后,render/check 同时处理所有文件。详见 [单服务器输出](../../docs/aggregate-routing.md#按主配置节点生成单服务器文件)。 + +`set-token` 首次生成 256-bit 随机令牌并保存在 macOS 钥匙串(服务名 `Network-Node subscriptions`);已有令牌会复用,不会无故失效旧链接。云端 secret 仅存 SHA256 校验值。完整订阅 URL 属于密码,不写普通文件、命令参数、日志或回复;剪贴板和客户端订阅数据库需要保密。若需撤销泄露令牌,应明确轮换钥匙串条目与云端校验值,并重新导入所有客户端;重新运行 set-token 本身不是轮换。 + +## 初始部署与维护 + +已有部署复用当前本地设置,不重复创建 namespace。新部署先确认自己的账户与免费套餐,在本目录执行: + +```bash +npm ci +cp wrangler.example.jsonc wrangler.jsonc +cp endpoint.example.json endpoint.json +# 填写 account_id,删除模板中的 kv_namespaces 占位项;填好 endpoint.json +npm test +npx wrangler kv namespace create CONFIGS --binding CONFIGS --update-config +npm run check +npm run deploy +# 回到仓库根目录 +python3 subscription.py set-token +python3 aggregate.py publish --profile routing --client mihomo +``` + +这里的配置文件使用 JSON 语法(Wrangler 接受 JSONC,但发布器按 JSON 读取),不要添加注释或尾随逗号。namespace 创建成功后必须保留返回的绑定 ID;不因后续步骤失败重复创建。 + +代码部署和 secret put 都是云端写操作。没有设置 TOKEN_SHA256 时所有请求均拒绝,初次部署到写入 secret 之间也不公开配置。Worker 仅支持 GET/HEAD、固定文件路径、单个格式有效的令牌;鉴权在 KV 读取前完成。无公开目录、上传接口或公共存储桶。应用不记录 URL/正文,关闭 Worker observability、日志与 traces,保留平台聚合指标;不承诺云平台完全没有底层访问元数据。 + +发布器检查本地成品与源一致,再上传固定字节快照;KV API 读回、HTTPS 内容对比、无令牌/错误令牌反向检查都通过才报告成功。验证下载使用 Verge User-Agent;本次默认 Python User-Agent 被 CF 边缘以 1010 拒绝,Verge 标识正常。HTTP 重定向一律拒绝,避免携带令牌跳到其他站点。响应为 private/no-store,建议 24 小时更新。 + +KV 是最终一致存储,更新后其他地区可能延迟 60 秒或更久。若 HTTPS 仍是旧版本,等待后运行 verify;不要反复上传。验证失败不会自动回滚已写入对象。当前网络下载成功不证明大陆无代理首连可达;必要时先导入本地 YAML。单次发布限制为 1 MiB;免费额度耗尽会失败,本工具不会升级套餐。 + +## 验证 + +- `npm test`:真实 workerd + 本地 KV,检查未授权、错误/重复令牌、令牌撤销、无 secret、未知对象、HEAD、禁止写入与内容一致性。 +- 仓库 Python 测试含真实 Mihomo 隔离路由验证;`subscription.py verify` 验证实际云端成品。 +- 本次验证主配置及单 VPS 成品的 Mihomo schema;未改变运行客户端,未实测 Claude 登录和长连接。 + +参考:[Verge URI 与响应头](https://www.clashverge.dev/guide/url_schemes.html)、[Verge 协议解析实现](https://github.com/clash-verge-rev/clash-verge-rev/blob/main/src-tauri/src/utils/resolve/scheme.rs)、[Workers 价格](https://developers.cloudflare.com/workers/platform/pricing/)、[KV 免费额度](https://developers.cloudflare.com/kv/platform/pricing/)、[KV 一致性](https://developers.cloudflare.com/kv/concepts/how-kv-works/)。 diff --git a/cloud/subscription/endpoint.example.json b/cloud/subscription/endpoint.example.json new file mode 100644 index 0000000..b369cc9 --- /dev/null +++ b/cloud/subscription/endpoint.example.json @@ -0,0 +1,3 @@ +{ + "base_url": "https://network-node-subscriptions.your-account.workers.dev" +} diff --git a/cloud/subscription/package-lock.json b/cloud/subscription/package-lock.json new file mode 100644 index 0000000..bb06313 --- /dev/null +++ b/cloud/subscription/package-lock.json @@ -0,0 +1,1591 @@ +{ + "name": "network-node-subscription", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "network-node-subscription", + "devDependencies": { + "miniflare": "5.20260923.0-alpha", + "wrangler": "4.139.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260923.1.tgz", + "integrity": "sha512-sFMyHjABspfVxAf9bmcRdYWm3rO5Vrmhb3fLboMB5Lt+Eo+X1Fj4n/3hgMYmXb6P+zW+cjWjZdH1ilE+Ad5kDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260923.1.tgz", + "integrity": "sha512-t/O1jcuag+JO5VnFblf20B960FU/WSmn/n2Lcy021cBUhWnhApO9yx1VjJ7I4KhS09yXLgC2shHTzB9EnbSQpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260923.1.tgz", + "integrity": "sha512-6X9fp5vFBJG0BXrjOcB4yqyqZRtZ/Jx4KRAmEp5FPxuNUbf3n6ogzZvD2s0YS+DC2zNKb0gd1lIiOye7AaGnvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260923.1.tgz", + "integrity": "sha512-esOXfxhDp2YPjZmMrO3OLa/9qAfa/uXQGwFGDyUWNF3aXkwE8fc6oauZS01UDk8YwuYlxsyjLQIKjJOrzJ2N+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260923.1.tgz", + "integrity": "sha512-Hlda2apkBcRtPQD4NnEZiEqKLIhR7au+8skF+tBHBa1jsX1/GfuYEdIR0HJ6He1qkSNkxD/Y0VXo+TNgUc1EUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20260923.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260923.0-alpha.tgz", + "integrity": "sha512-tmICPUd2P+/4WNa2A9kE22SPZexlu+Dw8nZIvaXCzRj2OVu62hc751Xq4S1P4HEscT5viDle3zxyHyxT2XY3Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.4", + "undici": "7.29.0", + "workerd": "1.20260923.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260923.1.tgz", + "integrity": "sha512-1xynegnJJqkgk/zuhNVcOFzAJXzsJ4UxazfThUe0K80eZXKpsRIXI2sHcT4trswLfeHCc+3uWTBvtSWLFRozbQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260923.1", + "@cloudflare/workerd-darwin-arm64": "1.20260923.1", + "@cloudflare/workerd-linux-64": "1.20260923.1", + "@cloudflare/workerd-linux-arm64": "1.20260923.1", + "@cloudflare/workerd-windows-64": "1.20260923.1" + } + }, + "node_modules/wrangler": { + "version": "4.139.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.139.0.tgz", + "integrity": "sha512-9RtaWkIkxqntg58bGwNVRfGVSgf+cAq/45YzC87LN5YniCt4DDNrFM2gXK5SHMj3iG5YnDHhRVvh63FWnmJnaA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260923.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260923.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260923.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/cloud/subscription/package.json b/cloud/subscription/package.json new file mode 100644 index 0000000..b903a31 --- /dev/null +++ b/cloud/subscription/package.json @@ -0,0 +1,14 @@ +{ + "name": "network-node-subscription", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test.mjs", + "deploy": "wrangler deploy", + "check": "wrangler deploy --dry-run" + }, + "devDependencies": { + "wrangler": "4.139.0", + "miniflare": "5.20260923.0-alpha" + } +} diff --git a/cloud/subscription/test.mjs b/cloud/subscription/test.mjs new file mode 100644 index 0000000..c31ea39 --- /dev/null +++ b/cloud/subscription/test.mjs @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createHash, randomBytes } from 'node:crypto'; +import { Miniflare } from 'miniflare'; + +// Given a private YAML and revocable token, only authorized GET/HEAD may read it. +// Tests execute the real Worker and real local KV in workerd, not a mocked handler. +test('private subscription contract in workerd', async () => { + const token = randomBytes(32).toString('base64url'); + const hash = createHash('sha256').update(token).digest('hex'); + const script = await readFile(new URL('./worker.mjs', import.meta.url), 'utf8'); + const options = verifier => ({workers: [{config: {name: 'subscription', + compatibilityDate: '2026-09-24', + manifest: {mainModule: 'worker.mjs', modules: {'worker.mjs': {type: 'esm', contents: script}}}, + env: {CONFIGS: {type: 'kv', id: 'test-configs'}, + ...(verifier ? {TOKEN_SHA256: {type: 'text', value: verifier}} : {})}}}]}); + const mf = new Miniflare(options(hash)); + try { + const kv = await mf.getKVNamespace('CONFIGS'); + const yaml = 'mode: rule\nrules:\n - MATCH,REJECT\n'; + await kv.put('mihomo/routing.yaml', yaml); + const base = 'https://subscription.test/mihomo/routing.yaml'; + for (const path of [base, base+'?token=wrong', base+'?token='+randomBytes(32).toString('base64url'), + 'https://subscription.test/', 'https://subscription.test/mihomo/missing.yaml?token='+token, + base+'?token='+token+'&token='+token]) { + const response = await mf.dispatchFetch(path); + assert.equal(response.status, 404); + assert.equal(await response.text(), 'Not found'); + } + const response = await mf.dispatchFetch(base+'?token='+token); + assert.equal(response.status, 200); + assert.equal(await response.text(), yaml); + assert.equal(response.headers.get('cache-control'), 'private, no-store'); + assert.equal(response.headers.get('etag'), '"'+createHash('sha256').update(yaml).digest('hex')+'"'); + const head = await mf.dispatchFetch(base+'?token='+token, { method: 'HEAD' }); + assert.equal(head.status, 200); assert.equal(await head.text(), ''); + const write = await mf.dispatchFetch(base+'?token='+token, { method: 'PUT', body:'replace' }); + assert.equal(write.status, 404); assert.equal(await kv.get('mihomo/routing.yaml'), yaml); + // Missing verifier fails closed; rotation invalidates the old token. + await mf.setOptions(options()); + assert.equal((await mf.dispatchFetch(base+'?token='+token)).status, 404); + await mf.setOptions(options('0'.repeat(64))); + assert.equal((await mf.dispatchFetch(base+'?token='+token)).status, 404); + } finally { await mf.dispose(); } +}); diff --git a/cloud/subscription/worker.mjs b/cloud/subscription/worker.mjs new file mode 100644 index 0000000..f3e5ebe --- /dev/null +++ b/cloud/subscription/worker.mjs @@ -0,0 +1,38 @@ +// A private, read-only YAML endpoint. Never log request URLs or config contents. +const encoder = new TextEncoder(); +const headers = { + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', +}; +function failure(status = 404) { + return new Response(status === 404 ? 'Not found' : 'Unavailable', { status, headers }); +} +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (!['GET', 'HEAD'].includes(request.method)) return failure(); + const match = /^\/mihomo\/([A-Za-z0-9][A-Za-z0-9_-]{0,63})\.yaml$/.exec(url.pathname); + const token = url.searchParams.get('token'); + if (!match || !token || !/^[A-Za-z0-9_-]{43}$/.test(token) || + url.searchParams.getAll('token').length !== 1 || !/^[a-f0-9]{64}$/.test(env.TOKEN_SHA256 || '')) return failure(); + const provided = await crypto.subtle.digest('SHA-256', encoder.encode(token)); + const expected = Uint8Array.from(env.TOKEN_SHA256.match(/../g), b => parseInt(b, 16)); + if (!crypto.subtle.timingSafeEqual(provided, expected)) return failure(); + try { + // Bindings keep objects private; there is no public bucket or list route. + const config = await env.CONFIGS.get(`mihomo/${match[1]}.yaml`, 'arrayBuffer'); + if (config === null) return failure(); + const digest = await crypto.subtle.digest('SHA-256', config); + const etag = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join(''); + return new Response(request.method === 'HEAD' ? null : config, { + headers: { ...headers, 'Content-Type': 'application/yaml; charset=utf-8', + 'Content-Disposition': `attachment; filename="${match[1]}.yaml"`, + 'ETag': `"${etag}"`, 'profile-update-interval': '24' }, + }); + } catch { + // No exception text: storage errors must not disclose state or credentials. + return failure(503); + } + }, +}; diff --git a/cloud/subscription/wrangler.example.jsonc b/cloud/subscription/wrangler.example.jsonc new file mode 100644 index 0000000..135edc8 --- /dev/null +++ b/cloud/subscription/wrangler.example.jsonc @@ -0,0 +1,23 @@ +{ + "name": "network-node-subscriptions", + "account_id": "REPLACE_WITH_ACCOUNT_ID", + "main": "worker.mjs", + "compatibility_date": "2026-09-24", + "workers_dev": true, + "preview_urls": false, + "observability": { + "enabled": false, + "logs": { + "enabled": false + }, + "traces": { + "enabled": false + } + }, + "kv_namespaces": [ + { + "binding": "CONFIGS", + "id": "REPLACE_WITH_KV_NAMESPACE_ID" + } + ] +} diff --git a/config/aggregate.json.example b/config/aggregate.json.example index c0100a3..5856db4 100644 --- a/config/aggregate.json.example +++ b/config/aggregate.json.example @@ -1,10 +1,22 @@ { "sources": [ - {"profile": "cstonecloud-cuii-a", "name": "cstone"}, - {"profile": "lax-cmin2-01", "name": "lax"}, - {"profile": "gcloud", "name": "gcloud"} + { + "profile": "cstonecloud-cuii-a", + "name": "cstone" + }, + { + "profile": "lax-cmin2-01", + "name": "lax" + }, + { + "profile": "gcloud", + "name": "gcloud" + } ], - "sensitive": ["cstone/Reality", "gcloud/CDN"], - "devices": ["mac", "iphone"], - "client": "stash" + "sensitive": [ + "cstone/Reality", + "gcloud/CDN" + ], + "credential_identity": "mac", + "standalone": true } diff --git a/config/deploy.conf.example b/config/deploy.conf.example index 6549420..309df7e 100644 --- a/config/deploy.conf.example +++ b/config/deploy.conf.example @@ -4,8 +4,9 @@ # 停用机器可设为 false:跳过客户端输出,保留 profile 和凭据,不操作服务器。 CLIENT_CONFIG_ENABLE=true -# 客户端目标:stash(默认)/ mihomo。两者 DNS、HY2、TUN 字段分别生成。 -CLIENT_TARGET=stash +# 默认同时输出 Stash / Mihomo;单独输出使用 node.py render --client stash|mihomo。 +# 共享既有节点身份;留空优先 mac,无 mac 时用 DEVICES 的第一个身份。 +CLIENT_IDENTITY= # 默认日常分流:已知 AI 及其依赖优先走 AI 线路,普通国内流量默认直连。 # true 为可选严格模式:全部公网固定走同一 VPS 的 Reality/CDN,国内也代理。 # 严格模式不自动切到 HY2/AnyTLS/WARP;不需要时保持 false。 @@ -45,7 +46,7 @@ ANYTLS_PORT= # Hysteria2 可选增强:端口跳跃 / QUIC 混淆。默认关闭,开启后需要重新生成并导入客户端 YAML。 # HY2_PORT_RANGE=30000-30010 -# HY2_HOP_INTERVAL=30(整数秒,Stash/Mihomo 均支持;范围仅支持 Mihomo) +# HY2_HOP_INTERVAL=30(整数秒,双客户端部署必须用整数;范围仅用于 --client mihomo 单独渲染) HY2_PORT_RANGE= HY2_HOP_INTERVAL= HY2_OBFS_ENABLE=false @@ -65,7 +66,7 @@ HY2_ACME_DNS_PROVIDER=cloudflare HY2_UP= HY2_DOWN= -# 设备 ID:字母、数字、下划线,1-64 位,空格分隔。 +# 服务器账号清单(兼容历史设备身份,不再决定输出份数):字母、数字、下划线。 # 移除设备后部署会自动轮换本 profile 的 AnyTLS 密码,剩余设备需重新导入 YAML。 DEVICES="mac iphone" diff --git a/core/aggregate-rules.yaml.tmpl b/core/aggregate-rules.yaml.tmpl new file mode 100644 index 0000000..b941a11 --- /dev/null +++ b/core/aggregate-rules.yaml.tmpl @@ -0,0 +1,90 @@ + # 1. 本机与局域网 + - DOMAIN,localhost,DIRECT + - DOMAIN-SUFFIX,local,DIRECT + - DOMAIN-SUFFIX,lan,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,169.254.0.0/16,DIRECT,no-resolve + - IP-CIDR,100.64.0.0/10,DIRECT,no-resolve + - IP-CIDR6,::1/128,DIRECT,no-resolve + - IP-CIDR6,fc00::/7,DIRECT,no-resolve + - IP-CIDR6,fe80::/10,DIRECT,no-resolve + + - RULE-SET,private,DIRECT + + - RULE-SET,private-ip,DIRECT,no-resolve + + - GEOIP,LAN,DIRECT,no-resolve + + # 2. DNS 基础设施与规则下载 + - IP-CIDR,1.1.1.1/32,{AI},no-resolve + - IP-CIDR,1.0.0.1/32,{AI},no-resolve + - IP-CIDR,9.9.9.9/32,{SENSITIVE},no-resolve + - IP-CIDR,149.112.112.112/32,{SENSITIVE},no-resolve + - IP-CIDR,223.5.5.5/32,{CN},no-resolve + - IP-CIDR,120.53.53.53/32,{CN},no-resolve + - DOMAIN-SUFFIX,raw.githubusercontent.com,{SENSITIVE} + + # 3. 敏感服务:应用、AI、Meta 商业与广告 +{APP_RULES} +{SENSITIVE_RULES} + - DOMAIN-KEYWORD,stun,{AI} + - IP-CIDR,160.79.104.0/21,{AI},no-resolve + - IP-CIDR6,2607:6bc0::/32,{AI},no-resolve + - IP-ASN,399358,{AI},no-resolve + - GEOSITE,category-ntp,{AI} + - DST-PORT,123,{AI} +{META_RULES} +{BUSINESS_RULES} + - RULE-SET,facebook,{AI} + - RULE-SET,instagram,{AI} + - RULE-SET,whatsapp,{AI} + - RULE-SET,ai,{AI} + - RULE-SET,tiktok,{SENSITIVE} + + # 4. Apple 海外能力与业务依赖 + - RULE-SET,siri,{SENSITIVE} + - DOMAIN,guzzoni.smoot.apple.com,{SENSITIVE} + - DOMAIN,probe.siri.apple.com,{SENSITIVE} + - DOMAIN,seed.siri.apple.com,{SENSITIVE} + - DOMAIN,seed-sequoia.siri.apple.com,{SENSITIVE} + - DOMAIN,seed-swallow.siri.apple.com,{SENSITIVE} + - DOMAIN,sequoia.apple.com,{SENSITIVE} + - DOMAIN,swallow.apple.com,{SENSITIVE} + - RULE-SET,icloud-private-relay,{SENSITIVE} + - DOMAIN-SUFFIX,applovin.com,{SENSITIVE} + - DOMAIN-SUFFIX,applvn.com,{SENSITIVE} + - DOMAIN-SUFFIX,applovinedge.com,{SENSITIVE} + - DOMAIN-SUFFIX,appsflyer.com,{SENSITIVE} + - DOMAIN-SUFFIX,adjust.com,{SENSITIVE} + - DOMAIN-SUFFIX,adj.st,{SENSITIVE} + - DOMAIN-SUFFIX,kochava.com,{SENSITIVE} + - DOMAIN-SUFFIX,branch.io,{SENSITIVE} + - DOMAIN-SUFFIX,singular.net,{SENSITIVE} + - DOMAIN-SUFFIX,ads.google.com,{SENSITIVE} + - DOMAIN-SUFFIX,adwords.google.com,{SENSITIVE} + - DOMAIN-SUFFIX,analytics.google.com,{SENSITIVE} + - DOMAIN-SUFFIX,googletagmanager.com,{SENSITIVE} + - DOMAIN-SUFFIX,googleadservices.com,{SENSITIVE} + - DOMAIN-SUFFIX,googlesyndication.com,{SENSITIVE} + - DOMAIN-SUFFIX,googletagservices.com,{SENSITIVE} + - DOMAIN-SUFFIX,ads.tiktok.com,{SENSITIVE} + - DOMAIN-SUFFIX,business.tiktok.com,{SENSITIVE} + + # 5. 广告拦截 + - RULE-SET,ads-lite,{ADS} + + # 6. 国内白名单优先直连;Apple 其余请求保护 + - RULE-SET,apple-cn,{CN} + - RULE-SET,icloud,{APPLE} + - RULE-SET,cn,{CN} + + # 7. 普通海外:本地例外 + 固定版本服务规则集 +{ORDINARY_EXCLUSIONS} +{ORDINARY_LOCAL_RULES} +{ORDINARY_RULESET_RULES} + + # 8. 未知流量:敏感主备,绝不进入测速或直连 + - MATCH,{SENSITIVE} diff --git a/core/client_aggregate.py b/core/client_aggregate.py index 521a026..7ce0b85 100644 --- a/core/client_aggregate.py +++ b/core/client_aggregate.py @@ -13,9 +13,11 @@ import sys import tempfile -from client_output import write_outputs +from client_output import atomic_write, write_outputs from client_policy import adapt_config +from client_snapshot import snapshot_nodes from sensitive_policy import domain_rules, app_rules +from ordinary_policy import ordinary_rules, ordinary_providers from profile_lock import profile_lock from settings import DEVICE, NAME, load_settings, validate @@ -24,16 +26,18 @@ SENSITIVE = '🛡 敏感服务' AUTO = '⚡ 自动测速' MANUAL = '🔧 手动选择' -OVERSEAS = '🌐 海外流量' +OVERSEAS = '🌐 普通海外' CN = '🇨🇳 国内流量' APPLE = '🍎 Apple 基础服务' ADS = '🛑 屏蔽流量' META_DOMAINS = ('facebook.com', 'facebook.net', 'fb.com', 'fb.me', 'fbcdn.net', 'fbsbx.com', 'instagram.com', 'cdninstagram.com', 'whatsapp.com', 'whatsapp.net', 'wa.me', 'messenger.com', 'meta.com', 'meta.ai') +BUSINESS_DOMAINS = ('tiktok.com', 'tiktokshop.com', 'tiktokglobalshop.com', 'tiktokglobalshopv.com', + 'line.biz', 'linemyshop.com', 'lineshoppingseller.com', 'brightline.tv') DNS = { AI: ('1.1.1.1', '1.0.0.1'), - OVERSEAS: ('9.9.9.9', '149.112.112.112'), + SENSITIVE: ('9.9.9.9', '149.112.112.112'), CN: ('223.5.5.5', '120.53.53.53'), } @@ -50,10 +54,35 @@ def load_plan(root, profile): plan = json.loads(path.read_text()) except (OSError, ValueError): raise ValueError('无法读取汇总 profile 的 aggregate.json(未显示内容)') from None - if not isinstance(plan, dict) or set(plan) != {'sources', 'sensitive', 'devices', 'client'}: - raise ValueError('aggregate.json 需要 sources、sensitive、devices、client 四个字段') - if plan['client'] not in ('stash', 'mihomo'): + if not isinstance(plan, dict): + raise ValueError('汇总计划必须是对象') + standalone = plan.pop('standalone', False) + if not isinstance(standalone, bool): + raise ValueError('standalone 必须为布尔值') + shared = {'sources', 'sensitive', 'credential_identity'} + legacy = {'sources', 'sensitive', 'devices', 'client'} + snapshot = {'snapshot', 'sensitive'} + if not isinstance(plan, dict) or set(plan) not in (shared, legacy, snapshot): + raise ValueError('aggregate.json 需要 sources、sensitive、credential_identity;也接受旧版 devices/client 格式') + if set(plan) == snapshot: + if plan['snapshot'] != 'nodes.json': + raise ValueError('快照必须引用 profile 内的 nodes.json') + sensitive = plan['sensitive'] + if (not isinstance(sensitive, list) or not sensitive + or any(not isinstance(name, str) or not name for name in sensitive) + or len(set(sensitive)) != len(sensitive)): + raise ValueError('快照敏感主备列表无效') + return plan | {'sources': [], 'devices': ['shared'], 'credential_identity': 'shared', 'standalone': standalone} + if set(plan) == shared: + identity = plan['credential_identity'] + if not isinstance(identity, str) or not DEVICE.fullmatch(identity): + raise ValueError('credential_identity 必须是已有的有效凭据身份') + plan['devices'] = [identity] + elif plan['client'] not in ('stash', 'mihomo'): raise ValueError('client 必须为 stash/mihomo') + if standalone and 'credential_identity' not in plan: + raise ValueError('单服务器输出需要共享身份计划') + plan['standalone'] = standalone devices = plan['devices'] if (not isinstance(devices, list) or not devices or any(not isinstance(d, str) or not DEVICE.fullmatch(d) for d in devices) @@ -105,15 +134,9 @@ def node_blocks(text, alias): def collect_nodes(root, plan, target): nodes = {device: {} for device in plan['devices']} - with ExitStack() as stack, tempfile.TemporaryDirectory(prefix='network-node-aggregate-') as tmp: + with tempfile.TemporaryDirectory(prefix='network-node-aggregate-') as tmp: sources = [] - # Match the GCP deployment/address-operation lock; fail instead of reading - # a partially changed profile. Hold all locks until every source is read. - for source in sorted(plan['sources'], key=lambda s: s['profile']): - state = root / 'profiles' / source['profile'] - if not (state / 'deploy.conf').is_file(): - raise ValueError(f"源 profile {source['profile']} 不存在") - stack.enter_context(profile_lock(state)) + # generate() holds all source locks across both client renders. for source in plan['sources']: state = root / 'profiles' / source['profile'] settings = load_settings(state) @@ -122,22 +145,23 @@ def collect_nodes(root, plan, target): if settings.get('CLIENT_CONFIG_ENABLE', 'true') == 'false': continue fingerprint = tuple((state / name).read_bytes() for name in ('deploy.conf', '.secrets.env')) - directory = Path(tmp) / source['name'] - env = os.environ | { - 'NETWORK_NODE_ROOT': str(root), 'NETWORK_NODE_STATE_DIR': str(state), - 'NETWORK_NODE_PROFILE': source['profile'], 'NETWORK_NODE_CLIENTS_DIR': str(directory), - } - try: - run = subprocess.run([sys.executable, str(CORE / 'gen-clash.py'), '--client', target], - env=env, capture_output=True, text=True, timeout=60) - except subprocess.TimeoutExpired: - raise ValueError(f"源 profile {source['profile']} 生成超时;已保留旧汇总文件") from None - if run.returncode: - # Never relay unexpected generator tracebacks containing local state. - raise ValueError(f"源 profile {source['profile']} 生成失败;请用 node.py validate 检查") prefix = settings.get('CLIENT_FILE_PREFIX', '').strip() or source['profile'] for device in set(settings.get('DEVICES', 'mac iphone').split()) & nodes.keys(): - text = (directory / f'{prefix}-{device}.yaml').read_text() + directory = Path(tmp) / source['name'] / device + env = os.environ | { + 'NETWORK_NODE_ROOT': str(root), 'NETWORK_NODE_STATE_DIR': str(state), + 'NETWORK_NODE_PROFILE': source['profile'], 'NETWORK_NODE_CLIENTS_DIR': str(directory), + } + try: + run = subprocess.run([sys.executable, str(CORE / 'gen-clash.py'), + '--client', target, '--identity', device], + env=env, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + raise ValueError(f"源 profile {source['profile']} 生成超时;已保留旧汇总文件") from None + if run.returncode: + # Never relay unexpected generator tracebacks containing local state. + raise ValueError(f"源 profile {source['profile']} 生成失败;请用 node.py validate 检查") + text = (directory / target / f'{prefix}.yaml').read_text() nodes[device].update(node_blocks(text, source['name'])) sources.append((state, fingerprint)) for state, fingerprint in sources: @@ -164,10 +188,10 @@ def group(name, kind, proxies, target): def groups(nodes, sensitive, target): auto = [name for name in nodes if not name.endswith('/Reality-WARP')] - selections = [(AI, [SENSITIVE, MANUAL]), - (OVERSEAS, [AUTO, MANUAL]), (CN, ['DIRECT', AUTO, MANUAL]), - (APPLE, ['DIRECT', AUTO, MANUAL]), - (ADS, ['REJECT', AUTO, MANUAL, 'DIRECT'])] + selections = [(AI, [SENSITIVE]), + (OVERSEAS, [AUTO, SENSITIVE, MANUAL]), (CN, ['DIRECT', SENSITIVE, MANUAL]), + (APPLE, [SENSITIVE, MANUAL]), + (ADS, ['REJECT', SENSITIVE, MANUAL, 'DIRECT'])] return ('proxy-groups:\n' + ''.join(group(n, 'select', options, target) for n, options in selections) + group(SENSITIVE, 'fallback', sensitive, target) + group(AUTO, 'url-test', auto, target) @@ -175,100 +199,206 @@ def groups(nodes, sensitive, target): def dns_policy(rules): - """Use separate resolver endpoints so manual selections also apply to DNS.""" + """Route resolver endpoints through the same sensitive or domestic policy.""" policies = {} for line in rules.splitlines(): if not line.startswith(' - '): continue fields = line[4:].split(',') - if len(fields) < 3 or fields[2] != AI: + if len(fields) < 3 or fields[2] not in (AI, SENSITIVE): continue if fields[0] == 'DOMAIN': - policies[fields[1]] = AI + policies[fields[1]] = fields[2] elif fields[0] == 'DOMAIN-SUFFIX': - policies['+.' + fields[1]] = AI + policies['+.' + fields[1]] = fields[2] # Sensitive sets precede CN when a domain belongs to more than one set. policies['geosite:category-ai-!cn'] = AI for name in ('facebook', 'instagram', 'whatsapp'): policies['geosite:' + name] = AI - policies['+.cn'] = CN + policies['geosite:apple-cn'] = CN policies['geosite:cn'] = CN return ' nameserver-policy:\n' + ''.join( - f' {quote(domain)}:\n' + ''.join(f' - https://{ip}/dns-query\n' for ip in DNS[policy]) + f' {quote(domain)}: ' + quote([f'https://{ip}/dns-query' for ip in DNS[policy]]) + '\n' for domain, policy in policies.items()) def compose(profile, device, target, nodes, sensitive): template = (CORE / 'client.yaml.tmpl').read_text() + rule_template = (CORE / 'aggregate-rules.yaml.tmpl').read_text() + revision_files = ('sensitive_policy.py', 'sensitive-services.json', 'ordinary_policy.py', + 'ordinary-services.json', 'ordinary-rulesets.json', 'client_policy.py') + revision = hashlib.sha256(template.encode() + rule_template.encode() + Path(__file__).read_bytes() + + b''.join((CORE / f).read_bytes() for f in revision_files)).hexdigest()[:12] values = {name: '' for name in re.findall(r'\{([A-Z0-9_]+)\}', template)} - revision = hashlib.sha256(template.encode() + Path(__file__).read_bytes() - + (CORE / 'sensitive_policy.py').read_bytes() - + (CORE / 'sensitive-services.json').read_bytes() - + (CORE / 'client_policy.py').read_bytes()).hexdigest()[:12] values.update(DEVICE=device, PROFILE_OWNER=profile, TARGET_LABEL=target, - STRICT_LABEL='false', TEMPLATE_REVISION=revision, - SENSITIVE_RULES=domain_rules(AI), APP_RULES=app_rules(target, device, AI), + STRICT_LABEL='true', TEMPLATE_REVISION=revision, SERVER_LABEL='Aggregate; per-server credentials remain in source profiles', - DNS_FOLLOW_RULE=' follow-rule: true' if target == 'stash' else ' respect-rules: true', - STUN_PROTOCOL_RULE=f' - PROTOCOL,STUN,{AI}' if target == 'stash' else '') - # Adapt the shared header/rules once, then insert already-adapted node blocks. + DNS_FOLLOW_RULE=' follow-rule: true' if target == 'stash' else ' respect-rules: true') + # Share client transport/header and provider definitions with single-server + # output; aggregate routing has its own ordered, readable policy template. base = adapt_config(template.format(**values), target, False, []) header = base.split('\nproxies:\n', 1)[0] providers = base.split('\nrule-providers:\n', 1)[1].split('\nrules:\n', 1)[0] - rules = base.split('\nrules:\n', 1)[1] - mapping = {'🤖 AI 隐私出口': AI, '🌐 代理流量': OVERSEAS, '↪️ 直连流量': APPLE, - '🎯 兜底策略': OVERSEAS} - for old, new in mapping.items(): - rules = rules.replace(old, new) - rules = '\n'.join(line for line in rules.splitlines() if line.startswith(' - ')) + '\n' - rules = rules.replace(f'DOMAIN-KEYWORD,spotify,{APPLE}', 'DOMAIN-KEYWORD,spotify,DIRECT') - rules = rules.replace(f'DOMAIN-SUFFIX,scdn.co,{APPLE}', 'DOMAIN-SUFFIX,scdn.co,DIRECT') - rules = rules.replace(f'DOMAIN-SUFFIX,raw.githubusercontent.com,{AI}', - f'DOMAIN-SUFFIX,raw.githubusercontent.com,{OVERSEAS}') - # Resolver routes must remain ahead of CN IP rules. Replace inherited endpoints. - old_resolvers = ('1.1.1.1', '8.8.8.8', '223.5.5.5', '120.53.53.53') - rules = '\n'.join(line for line in rules.splitlines() - if not any(line.startswith(f' - IP-CIDR,{ip}/32,') for ip in old_resolvers)) + '\n' - dns_rules = ''.join(f' - IP-CIDR,{ip}/32,{policy},no-resolve\n' - for policy, addresses in DNS.items() for ip in addresses) - # Static LAN rules remain first. Resolver IPs are public and cannot overlap LAN. - anchor = ' - DOMAIN-SUFFIX,raw.githubusercontent.com,' - index = rules.index(anchor) - rules = rules[:index] + dns_rules + rules[index:] - meta_rules = ''.join(f' - DOMAIN-SUFFIX,{domain},{AI}\n' for domain in META_DOMAINS) for name in ('facebook', 'instagram', 'whatsapp'): providers += (f'\n {name}:\n type: http\n behavior: domain\n format: mrs\n' f' url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/{name}.mrs"\n' f' path: ./ruleset/meta_{name}.mrs\n interval: 86400\n') - meta_rules += f' - RULE-SET,{name},{AI}\n' - # Meta shares the sensitive group, ahead of ads and domestic routing. - rules = rules.replace(f' - RULE-SET,ai,{AI}\n', meta_rules + f' - RULE-SET,ai,{AI}\n') + ordinary_provider_text, ordinary_refs = ordinary_providers(OVERSEAS, target=target, download_proxy=SENSITIVE) + providers += '\n' + ordinary_provider_text + labels = dict(AI=AI, SENSITIVE=SENSITIVE, OVERSEAS=OVERSEAS, CN=CN, APPLE=APPLE, ADS=ADS) + values = labels | dict( + APP_RULES=app_rules(target, device, AI), SENSITIVE_RULES=domain_rules(AI), + META_RULES='\n'.join(f' - DOMAIN-SUFFIX,{d},{AI}' for d in META_DOMAINS), + BUSINESS_RULES='\n'.join(f' - DOMAIN-SUFFIX,{d},{SENSITIVE}' for d in BUSINESS_DOMAINS), + # Preserve the previous exclusion of whole branded TLD namespaces. + ORDINARY_EXCLUSIONS='\n'.join(f' - DOMAIN-SUFFIX,{d},{SENSITIVE}' + for d in ('bbc', 'bloomberg', 'hbo', 'playstation', 'xbox')), + ORDINARY_LOCAL_RULES='', ORDINARY_RULESET_RULES=ordinary_refs.rstrip()) + rules = rule_template.format(**values) + protected = [(fields[0], fields[1]) for line in rules.splitlines() if line.startswith(' - ') + and len(fields := line[4:].split(',')) >= 3 + and fields[0] in ('DOMAIN', 'DOMAIN-SUFFIX') and fields[2] in (AI, SENSITIVE)] + values['ORDINARY_LOCAL_RULES'] = ordinary_rules(OVERSEAS, protected).rstrip() + rules = rule_template.format(**values) + used = set(re.findall(r'RULE-SET,([^,]+),', rules)) + providers = ''.join(f' {name}:\n{body}' for name, body in re.findall( + r'(?ms)^ ([a-z][a-z0-9-]*):\n(.*?)(?=^ [a-z][a-z0-9-]*:|\Z)', providers) if name in used) header = header.split(' nameserver:\n', 1)[0] + header = header.replace(' - captive.apple.com\n', '') # Bootstrap remains independent of sensitive resolver endpoints. header = header.replace(' - https://1.1.1.1/dns-query', ' - https://1.12.12.12/dns-query') - header += ' nameserver:\n' + ''.join(f' - https://{ip}/dns-query\n' for ip in DNS[OVERSEAS]) + header += ' nameserver:\n' + ''.join(f' - https://{ip}/dns-query\n' for ip in DNS[SENSITIVE]) header += dns_policy(rules) header = '\n'.join(line for line in header.splitlines() if not line.lstrip().startswith('#') or line.startswith('#')) + if target == 'mihomo': + # Route exclusions replace Stash's skip-proxy for private address ranges. + header = header.replace(' dns-hijack:\n', + ' route-exclude-address:\n' + ' - 127.0.0.0/8\n - 10.0.0.0/8\n - 172.16.0.0/12\n' + ' - 192.168.0.0/16\n - 169.254.0.0/16\n - 100.64.0.0/10\n' + ' - ::1/128\n - fc00::/7\n - fe80::/10\n' + ' dns-hijack:\n') return (header + '\n\nproxies:\n' + '\n'.join(nodes.values()) + '\n' + groups(nodes, sensitive, target) + 'rule-providers:\n' + providers + '\nrules:\n' + rules) -def generate(root, profile, *, target=None, check=False): +def standalone_selections(available, sensitive): + """Partition the exact main-config nodes; never consult stale deploy profiles.""" + partitions = {} + for name, block in available.items(): + alias, separator, _ = name.partition('/') + if not separator or not NAME.fullmatch(alias): + raise ValueError('单服务器输出需要合法的 server/protocol 节点名称') + partitions.setdefault(alias, {})[name] = block + result = {} + for alias, nodes in partitions.items(): + preferred = [name for name in sensitive if name in nodes] + if not preferred: + preferred = [alias + '/' + protocol for protocol in ('Reality', 'CDN') + if alias + '/' + protocol in nodes] + if not preferred: + raise ValueError('单服务器缺少可用的 Reality/CDN 敏感出口;停止生成') + result[alias] = (nodes, preferred) + return result + + +def generate(root, profile, *, target=None, check=False, publish_to=None): root = Path(root) - plan = load_plan(root, profile) - target = target or plan['client'] - if target not in ('stash', 'mihomo'): + if not isinstance(profile, str) or not NAME.fullmatch(profile): + raise ValueError('汇总 profile 名称无效') + targets = ('stash', 'mihomo') if target in (None, 'both') else (target,) + if any(t not in ('stash', 'mihomo') for t in targets): raise ValueError('客户端目标无效') - nodes = collect_nodes(root, plan, target) - directory = root / 'clash-configs' - rendered = {directory / f'{profile}-{device}.yaml': compose(profile, device, target, available, plan['sensitive']) - for device, available in nodes.items()} - current = write_outputs(directory, profile, rendered, check=check) + if publish_to is not None and (not check or targets != ('stash',)): + raise ValueError('发布必须先校验单个 Stash 目标') + if (root / 'clash-configs').is_symlink(): + raise ValueError('客户端输出目录不能是符号链接') + state = root / 'profiles' / profile + if state.is_symlink() or not (state / 'aggregate.json').is_file(): + raise ValueError('汇总 profile 不存在或不是本地目录;先创建计划或 import') + with ExitStack() as stack: + stack.enter_context(profile_lock(state)) + plan = load_plan(root, profile) + snapshot_path = root / 'profiles' / profile / 'nodes.json' + if 'snapshot' in plan and snapshot_path.is_symlink(): + raise ValueError('节点快照不能是符号链接') + snapshot_before = snapshot_path.read_bytes() if 'snapshot' in plan else None + for source in sorted(plan['sources'], key=lambda s: s['profile']): + state = root / 'profiles' / source['profile'] + if not (state / 'deploy.conf').is_file(): + raise ValueError('源 profile 不存在') + stack.enter_context(profile_lock(state)) + bundles = [] + for client in targets: + nodes = (snapshot_nodes(root, profile, client, plan['sensitive']) if 'snapshot' in plan + else collect_nodes(root, plan, client)) + directory = root / 'clash-configs' / client + rendered = {} + for device, available in nodes.items(): + shared = 'credential_identity' in plan + filename = f'{profile}.yaml' if shared else f'{profile}-{device}.yaml' + text = compose(profile, 'mac' if shared else device, client, available, plan['sensitive']) + if shared: + text = text.replace('# Network-Node config — device: mac', '# Network-Node shared client config', 1) + rendered[directory / filename] = text + if plan.get('standalone'): + for alias, (selected, preferred) in standalone_selections(available, plan['sensitive']).items(): + # Snapshot labels alone are not proof of transport/UDP support. + if 'snapshot' in plan: + snapshot_nodes(root, profile, client, preferred) + child_name = f'{profile}-{alias}' + if not NAME.fullmatch(child_name): + raise ValueError('单服务器文件名超过长度限制') + child = compose(profile, 'mac', client, selected, preferred) + child = child.replace('# Network-Node config — device: mac', '# Network-Node shared client config', 1) + child += f'\n# Standalone server: {alias}; source: {profile}\n' + rendered[directory / (child_name + '.yaml')] = child + bundles.append((directory, rendered)) + if snapshot_before is not None and snapshot_before != snapshot_path.read_bytes(): + raise ValueError('节点快照在生成期间发生变化,已保留原输出') + # Render every requested target before publishing any output. Separate + # ownership manifests prevent single-target renders removing the other. + current = True + for directory, rendered in bundles: + current = write_outputs(directory, profile, rendered, check=check) and current + if publish_to is not None: + if not current: + raise ValueError('Stash 配置缺失或过期;先 render,再发布') + # Publish these verified bytes under the same source locks. Never + # re-read a potentially changed output after releasing the locks. + _publish_stash_files(profile, bundles[0][1], Path(publish_to)) if check: print('汇总配置与所有当前源一致' if current else '汇总配置缺失或已过期,请重新 render') return 0 if current else 1 - print(f'已生成 {len(rendered)} 份 {target} 汇总配置;每设备节点数:' - + '、'.join(f'{device}={len(available)}' for device, available in nodes.items())) + print(f'已生成 {sum(len(files) for _, files in bundles)} 份汇总配置:' + '、'.join(targets)) print('敏感服务使用显式主备顺序;手动选择集中在一个组。未部署或切换客户端。') return 0 + + +def publish_stash(root, profile, destination=None): + """Explicitly distribute current Stash outputs; never publish node state.""" + destination = Path(destination) if destination is not None else ( + Path.home() / 'Library/Mobile Documents/iCloud~ws~stash~icloud/Documents') + return generate(root, profile, target='stash', check=True, publish_to=destination) + + +def _publish_stash_files(profile, rendered, destination): + if destination.is_symlink(): + raise ValueError('iCloud 分发目录不能是符号链接') + if not destination.is_dir(): + raise ValueError('Stash iCloud 目录不存在;确认 iCloud 已启用,或指定 --icloud-dir') + copies = [] + for source, text in rendered.items(): + target = destination / source.name + if target.is_symlink(): + raise ValueError('分发文件不能是符号链接') + if target.exists(): + header = target.read_text().splitlines()[:5] + if f'# Profile: {profile}' not in header or not any(line.startswith('# Client: stash;') for line in header): + raise ValueError('目标文件归属不明;已保留,请选择独立分发目录') + copies.append((target, text)) + for target, text in copies: + atomic_write(target, text) + print(f'已发布 {len(copies)} 份 Stash 配置到本地 iCloud 目录;远端同步和客户端加载需另行验证。') + return 0 diff --git a/core/client_snapshot.py b/core/client_snapshot.py new file mode 100644 index 0000000..e682284 --- /dev/null +++ b/core/client_snapshot.py @@ -0,0 +1,126 @@ +"""Migrate an existing client file into private, client-neutral node state.""" +import json +from pathlib import Path +import re + +from client_output import atomic_write +from settings import NAME + + +def load_yaml(path): + try: + import yaml + except ImportError: + raise ValueError('首次导入需要 PyYAML;使用 uv run --with PyYAML==6.0.2 python aggregate.py import ...') from None + try: + value = yaml.safe_load(Path(path).read_text()) + except Exception: + raise ValueError('无法解析源 YAML(未显示文件内容)') from None + if not isinstance(value, dict): + raise ValueError('源 YAML 必须是配置对象') + return value + + +def validate_nodes(nodes): + if not isinstance(nodes, list) or not nodes: + raise ValueError('节点快照必须为非空列表') + names = set() + for node in nodes: + if not isinstance(node, dict) or node.get('type') not in ('vless', 'hysteria2', 'anytls'): + raise ValueError('快照仅接受已有 VLESS、HY2、AnyTLS 节点') + name = node.get('name') + if not isinstance(name, str) or not name or name in names: + raise ValueError('节点名称缺失或重复') + if not isinstance(node.get('server'), str) or not node['server']: + raise ValueError('节点缺少服务器地址') + port = node.get('port') + if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535: + raise ValueError('节点端口无效') + credential = 'uuid' if node['type'] == 'vless' else 'password' + if not isinstance(node.get(credential), str) or not node[credential]: + raise ValueError('节点缺少认证字段') + if node.get('dialer-proxy'): + raise ValueError('暂不支持带外部 dialer-proxy 引用的快照') + names.add(name) + return names + + +def import_snapshot(root, profile, source): + if not NAME.fullmatch(profile): + raise ValueError('汇总 profile 名称无效') + data = load_yaml(source) + nodes = data.get('proxies') + if not isinstance(nodes, list) or data.get('proxy-providers'): + raise ValueError('只导入完整内嵌节点;不接受外部节点 provider') + # Store canonical Mihomo credential names. JSON is also YAML-compatible. + try: + nodes = json.loads(json.dumps(nodes)) + for node in nodes: + node.pop('benchmark-url', None) + node.pop('benchmark-timeout', None) + if node.get('type') == 'hysteria2': + for old, new in (('auth', 'password'), ('server-cert-fingerprint', 'fingerprint'), + ('up-speed', 'up'), ('down-speed', 'down')): + if old in node: + if new in node and node[new] != node[old]: + raise ValueError('conflicting fields') + node[new] = node.pop(old) + names = validate_nodes(nodes) + groups = data.get('proxy-groups', []) + sensitive = next(g['proxies'] for g in groups if g['name'] == '🛡 敏感服务' and g['type'] == 'fallback') + if not sensitive or len(set(sensitive)) != len(sensitive) or any(n not in names for n in sensitive): + raise ValueError('missing sensitive node') + if any(next(n for n in nodes if n['name'] == name).get('udp') is not True + or next(n for n in nodes if n['name'] == name)['type'] != 'vless' for name in sensitive): + raise ValueError('sensitive nodes must support VLESS UDP') + except Exception: + raise ValueError('源节点或敏感主备不符合导入契约(未显示内容)') from None + state = Path(root) / 'profiles' / profile + if state.is_symlink() or state.exists(): + raise ValueError('目标 profile 已存在;导入不会覆盖现有私有状态') + state.mkdir(parents=True, mode=0o700) + try: + atomic_write(state / 'nodes.json', json.dumps(nodes, ensure_ascii=False, indent=2) + '\n') + atomic_write(state / 'aggregate.json', json.dumps( + {'snapshot': 'nodes.json', 'sensitive': sensitive}, ensure_ascii=False, indent=2) + '\n') + except BaseException: + # Only remove the fresh files owned by this failed import. + for name in ('nodes.json', 'aggregate.json'): + (state / name).unlink(missing_ok=True) + state.rmdir() + raise + print(f'已导入 {len(nodes)} 个节点到私有共享快照;未输出凭据、未改服务器。') + return 0 + + +def snapshot_nodes(root, profile, target, sensitive): + path = Path(root) / 'profiles' / profile / 'nodes.json' + if path.is_symlink(): + raise ValueError('节点快照不能是符号链接') + try: + nodes = json.loads(path.read_text()) + names = validate_nodes(nodes) + by_name = {node['name']: node for node in nodes} + if any(name not in names or by_name[name].get('udp') is not True + or by_name[name]['type'] != 'vless' for name in sensitive): + raise ValueError('missing sensitive node') + except Exception: + raise ValueError('节点快照无效或缺少敏感主备(未显示内容)') from None + rendered = {} + for node in nodes: + if target == 'stash': + node['benchmark-url'] = 'http://www.gstatic.com/generate_204' + node['benchmark-timeout'] = 5 + if node['type'] == 'hysteria2': + node['auth'] = node.pop('password') + if 'fingerprint' in node: + node['server-cert-fingerprint'] = node.pop('fingerprint') + for key in ('up', 'down'): + if key in node: + match = re.fullmatch(r'(\d+(?:\.\d+)?)\s*([kmg]?bps)?', str(node.pop(key)), re.I) + if not match: + raise ValueError('HY2 带宽无法转换为 Stash Mbps 数值') + unit = (match[2] or 'mbps').lower() + node[key + '-speed'] = float(match[1]) * {'bps': .000001, 'kbps': .001, 'mbps': 1, 'gbps': 1000}[unit] + rendered[node['name']] = ' - ' + json.dumps(node, ensure_ascii=False) + '\n' + return {'shared': rendered} diff --git a/core/deploy.sh b/core/deploy.sh index a0bd182..296ca3b 100755 --- a/core/deploy.sh +++ b/core/deploy.sh @@ -121,7 +121,7 @@ run_deploy() { || die "CDN 端到端验收失败;服务器可能已更新,本次未发布客户端 YAML。请检查后重试" fi - say "生成目标客户端配置(${CLIENT_TARGET:-stash})" + say "生成共享身份客户端配置(Stash + Mihomo)" NETWORK_NODE_ROOT="$PROJECT_DIR" \ NETWORK_NODE_STATE_DIR="$STATE_DIR" \ NETWORK_NODE_CLIENTS_DIR="$CLIENTS_DIR" \ @@ -143,6 +143,7 @@ run_deploy() { if [ "${CDN_ENABLE:-false}" = "true" ]; then echo " CDN : $CDN_HOSTNAME" fi - echo " 配置文件 : $CLIENTS_DIR/${CLIENT_FILE_PREFIX:-$PROFILE_NAME}-*.yaml" + echo " Stash 配置: $CLIENTS_DIR/stash/${CLIENT_FILE_PREFIX:-$PROFILE_NAME}.yaml" + echo " Mihomo 配置: $CLIENTS_DIR/mihomo/${CLIENT_FILE_PREFIX:-$PROFILE_NAME}.yaml" echo " 凭据位置 : ${SECRETS_FILE}(工具自动管理,无需记忆;不要复制到聊天)" } diff --git a/core/gen-clash.py b/core/gen-clash.py index 82c3fad..3d4f524 100755 --- a/core/gen-clash.py +++ b/core/gen-clash.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate Stash-first, Mihomo-compatible YAML configs, one per device. +"""Generate Stash-first, Mihomo-compatible YAML configs, one shared identity per client. Reads deploy.conf (DEVICES, REALITY_PORT, REALITY_SNI, PROJECT_ID, REGION) and .secrets.env (STATIC_IP, REALITY_PUBLIC, REALITY_SHORTID, HY2_PORT, @@ -15,14 +15,15 @@ import sys import argparse import hashlib -from settings import load_settings, validate +from settings import FILE_PREFIX_PATTERN, load_settings, validate from client_output import write_outputs from client_policy import adapt_config from sensitive_policy import domain_rules, app_rules parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true", help="只检查输出是否与当前配置一致,不写文件") -parser.add_argument("--client", choices=("stash", "mihomo"), help="客户端目标,默认读取 CLIENT_TARGET/stash") +parser.add_argument("--client", choices=("stash", "mihomo", "both"), default="both", help="客户端目标,默认同时生成两份") +parser.add_argument("--identity", help="复用的已有凭据身份;不创建或轮换服务器账号") args = parser.parse_args() ROOT = pathlib.Path(os.environ.get("NETWORK_NODE_ROOT", pathlib.Path(__file__).resolve().parent.parent)) @@ -38,20 +39,21 @@ try: env = load_settings(STATE_DIR) - if args.client: - env["CLIENT_TARGET"] = args.client - validate(env) + if args.identity: + env["CLIENT_IDENTITY"] = args.identity + targets = ("stash", "mihomo") if args.client == "both" else (args.client,) + for target in targets: + validate(env | {"CLIENT_TARGET": target}) except (ValueError, OSError) as exc: sys.exit(f"ERROR: {exc}") if env.get("CLIENT_CONFIG_ENABLE", "true") == "false": print("此 profile 已停用客户端配置输出,跳过生成和一致性检查") sys.exit(0) -CLIENT_TARGET = env.get("CLIENT_TARGET", "stash") AI_STRICT_MODE = env.get("AI_STRICT_MODE", "false") == "true" FILE_PREFIX = env.get("CLIENT_FILE_PREFIX", "").strip() or PROFILE devices = env.get("DEVICES", "mac iphone").split() -safe_name = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +safe_name = FILE_PREFIX_PATTERN if not FILE_PREFIX or not safe_name.fullmatch(FILE_PREFIX): sys.exit( "ERROR: CLIENT_FILE_PREFIX(或 profile 名)只能包含 1-64 位字母、数字、点、下划线和连字符" @@ -62,6 +64,11 @@ if unsafe_devices: sys.exit(f"ERROR: DEVICES 包含不安全设备名 {unsafe_devices}") +# Keep server account inventory intact; client files share one existing identity. +identity = args.identity or env.get("CLIENT_IDENTITY", "").strip() or ("mac" if "mac" in devices else devices[0]) +if identity not in devices: + sys.exit("ERROR: CLIENT_IDENTITY/--identity 必须是 DEVICES 中的已有身份") + # ── CDN 套娃出口(可选)── # 启用条件:CDN_ENABLE=true 且 CF/WS 参数齐全。启用时把 US-CDN 作为一个普通节点 # 加入现有节点池(节点策略 / 自动测速 / 手动选择),分流规则完全不变; @@ -94,7 +101,7 @@ missing = [k for k in required if not env.get(k)] if missing: sys.exit(f"ERROR: 缺少必要变量 {missing}(应由部署入口自动生成,请检查 profile 状态)") -for device in devices: +for device in (identity,): if not CDN_ONLY and ( not env.get(f"REALITY_UUID_{device}") or not env.get(f"HY2_PASS_{device}") ): @@ -120,10 +127,6 @@ HY2_CERT_SHA256 = env.get("HY2_CERT_SHA256", "").replace(":", "").lower() if HY2_CERT_SHA256 and not re.fullmatch(r"[0-9a-f]{64}", HY2_CERT_SHA256): sys.exit("ERROR: HY2_CERT_SHA256 必须是 64 位 SHA256 指纹") -HY2_PIN = "" -if not HY2_ACME_ENABLE and HY2_CERT_SHA256: - pin_field = "server-cert-fingerprint" if CLIENT_TARGET == "stash" else "fingerprint" - HY2_PIN = f' {pin_field}: "{HY2_CERT_SHA256}"\n' HY2_OBFS_ENABLE = env.get("HY2_OBFS_ENABLE", "false") == "true" HY2_OBFS_PASSWORD = env.get("HY2_OBFS_PASSWORD", "").strip() if HY2_OBFS_ENABLE and not HY2_OBFS_PASSWORD: @@ -136,12 +139,6 @@ # 自伤丢包反而更慢。留空 = 保持 Hysteria2 默认动态 CC(向后兼容,与历史行为一致)。 HY2_UP = env.get("HY2_UP", "").strip() HY2_DOWN = env.get("HY2_DOWN", "").strip() -HY2_BW = f' up: "{HY2_UP}"\n down: "{HY2_DOWN}"\n' if HY2_UP and HY2_DOWN else "" -if CLIENT_TARGET == "stash" and HY2_UP and HY2_DOWN: - def mbps(value): - match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*([kmg]?bps)?", value, re.I) - return float(match[1]) * {None: 1, "bps": 0.000001, "kbps": 0.001, "mbps": 1, "gbps": 1000}[match[2].lower() if match[2] else None] - HY2_BW = f" up-speed: {mbps(HY2_UP):g}\n down-speed: {mbps(HY2_DOWN):g}\n" def cdn_proxy_block(dev_cdn_uuid): @@ -261,7 +258,18 @@ def direct_proxy_blocks(dev_uuid, hy2_password): + pathlib.Path(__file__).with_name("sensitive_policy.py").read_bytes() + pathlib.Path(__file__).with_name("sensitive-services.json").read_bytes() ).hexdigest()[:12] -for dev in devices: +for CLIENT_TARGET in targets: + dev = identity + HY2_PIN = "" + if not HY2_ACME_ENABLE and HY2_CERT_SHA256: + pin_field = "server-cert-fingerprint" if CLIENT_TARGET == "stash" else "fingerprint" + HY2_PIN = f' {pin_field}: "{HY2_CERT_SHA256}"\n' + HY2_BW = f' up: "{HY2_UP}"\n down: "{HY2_DOWN}"\n' if HY2_UP and HY2_DOWN else "" + if CLIENT_TARGET == "stash" and HY2_UP and HY2_DOWN: + def mbps(value): + match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*([kmg]?bps)?", value, re.I) + return float(match[1]) * {None: 1, "bps": 0.000001, "kbps": 0.001, "mbps": 1, "gbps": 1000}[match[2].lower() if match[2] else None] + HY2_BW = f" up-speed: {mbps(HY2_UP):g}\n down-speed: {mbps(HY2_DOWN):g}\n" uuid = env.get(f"REALITY_UUID_{dev}") hy2pw = env.get(f"HY2_PASS_{dev}") dev_cdn_uuid = env.get(f"CDN_UUID_{dev}", "") @@ -291,13 +299,13 @@ def direct_proxy_blocks(dev_uuid, hy2_password): sys.exit("ERROR: 没有可用的代理节点") yaml = TEMPLATE.format( - DEVICE=dev, + DEVICE="shared", PROFILE_OWNER=PROFILE or FILE_PREFIX, TARGET_LABEL=CLIENT_TARGET, STRICT_LABEL=str(AI_STRICT_MODE).lower(), TEMPLATE_REVISION=template_revision, SENSITIVE_RULES=domain_rules("🤖 AI 隐私出口"), - APP_RULES=app_rules(CLIENT_TARGET, dev, "🤖 AI 隐私出口"), + APP_RULES=app_rules(CLIENT_TARGET, "mac", "🤖 AI 隐私出口"), DNS_FOLLOW_RULE=" follow-rule: true" if CLIENT_TARGET == "stash" else " respect-rules: true", STUN_PROTOCOL_RULE=" - PROTOCOL,STUN,🤖 AI 隐私出口" if CLIENT_TARGET == "stash" else "", SERVER_LABEL=( @@ -321,14 +329,19 @@ def direct_proxy_blocks(dev_uuid, hy2_password): **env, ) yaml = adapt_config(yaml, CLIENT_TARGET, AI_STRICT_MODE, ai_nodes) - filename = f"{FILE_PREFIX}-{dev}.yaml" if FILE_PREFIX else f"{dev}.yaml" - rendered[OUT_DIR / filename] = yaml + rendered[OUT_DIR / CLIENT_TARGET / f"{FILE_PREFIX}.yaml"] = yaml try: - current = write_outputs(OUT_DIR, PROFILE or FILE_PREFIX, rendered, check=args.check) + current = True + for target in targets: + directory = OUT_DIR / target + result = write_outputs(directory, PROFILE or FILE_PREFIX, + {path: text for path, text in rendered.items() if path.parent == directory}, + check=args.check) + current = result and current except (ValueError, OSError) as exc: sys.exit(f"ERROR: {exc}") if args.check: print("配置与当前源一致" if current else "配置缺失或已过期;请运行 render") sys.exit(0 if current else 1) -print(f"全部 {len(devices)} 份 {CLIENT_TARGET} 配置已写入 {OUT_DIR};AI 严格模式={AI_STRICT_MODE}") +print(f"全部 {len(targets)} 份共享身份客户端 配置已写入 {OUT_DIR};AI 严格模式={AI_STRICT_MODE}") diff --git a/core/ordinary-rulesets.json b/core/ordinary-rulesets.json new file mode 100644 index 0000000..dfd1e32 --- /dev/null +++ b/core/ordinary-rulesets.json @@ -0,0 +1,70 @@ +{ + "revision": "0f3410e013082b242f381750899a533078914f34", + "categories": { + "media": [ + "disney", + "hbo", + "primevideo", + "vimeo", + "dailymotion", + "deezer", + "soundcloud", + "tidal", + "lastfm" + ], + "games": [ + "steam", + "epicgames", + "gog", + "playstation", + "xbox", + "nintendo", + "ubisoft", + "ea", + "blizzard", + "riot", + "rockstar" + ], + "social": [ + "discord", + "signal", + "line", + "threads" + ], + "news": [ + "bbc", + "cnn", + "nytimes", + "bloomberg", + "reuters", + "wsj", + "economist", + "ft", + "theguardian" + ], + "learning": [ + "coursera", + "edx", + "khanacademy", + "stackexchange" + ], + "development": [ + "npmjs", + "python", + "docker", + "rust", + "golang", + "debian", + "ubuntu", + "archlinux", + "sourceforge", + "kubernetes", + "homebrew", + "fedora", + "readthedocs", + "ruby", + "flutter", + "dart" + ] + } +} diff --git a/core/ordinary-services.json b/core/ordinary-services.json new file mode 100644 index 0000000..6589ea2 --- /dev/null +++ b/core/ordinary-services.json @@ -0,0 +1,137 @@ +{ + "youtube": { + "suffixes": [ + "youtube.com", + "youtu.be", + "youtube-nocookie.com", + "googlevideo.com", + "ytimg.com", + "youtubei.googleapis.com", + "youtube.googleapis.com", + "youtubeembeddedplayer.googleapis.com" + ], + "domains": [ + "yt3.googleusercontent.com" + ], + "ip_cidrs": [] + }, + "netflix": { + "suffixes": [ + "netflix.com", + "netflix.net", + "nflxext.com", + "nflximg.com", + "nflximg.net", + "nflxsearch.net", + "nflxso.net", + "nflxvideo.net" + ], + "domains": [ + "netflix.com.edgesuite.net" + ], + "ip_cidrs": [] + }, + "telegram": { + "suffixes": [ + "telegram.org", + "telegram.me", + "telegram.dog", + "t.me", + "tdesktop.com", + "telegra.ph", + "telesco.pe", + "telegram-cdn.org", + "cdn-telegram.org" + ], + "domains": [], + "ip_cidrs": [ + "91.108.56.0/22", + "91.108.4.0/22", + "91.108.8.0/22", + "91.108.16.0/22", + "91.108.12.0/22", + "149.154.160.0/20", + "91.105.192.0/23", + "91.108.20.0/22", + "185.76.151.0/24", + "2001:b28:f23d::/48", + "2001:b28:f23f::/48", + "2001:67c:4e8::/48", + "2001:b28:f23c::/48", + "2a0a:f280::/32" + ] + }, + "ibkr": { + "suffixes": [ + "interactivebrokers.com", + "ibkr.com", + "interactivebrokers.co.uk", + "interactivebrokers.com.au", + "interactivebrokers.co.in", + "interactivebrokers.co.jp", + "interactivebrokers.com.hk", + "ibkr.com.hk", + "ibkr.eu" + ], + "domains": [], + "ip_cidrs": [] + }, + "tradingview": { + "suffixes": [ + "tradingview.com", + "tradingview-widget.com" + ], + "domains": [], + "ip_cidrs": [] + }, + "spotify": { + "suffixes": [ + "spotify.com", + "scdn.co", + "spotifycdn.com", + "spotifycdn.net", + "pscdn.co", + "spoti.fi", + "spotify.link" + ], + "domains": [ + "audio-ak-spotify-com.akamaized.net", + "audio4-ak-spotify-com.akamaized.net", + "spotify.map.fastly.net" + ], + "ip_cidrs": [] + }, + "twitch": { + "suffixes": [ + "twitch.tv", + "twitchcdn.net", + "ttvnw.net", + "jtvnw.net", + "live-video.net" + ], + "domains": [], + "ip_cidrs": [] + }, + "reddit": { + "suffixes": [ + "reddit.com", + "redd.it", + "redditmedia.com", + "redditstatic.com" + ], + "domains": [ + "reddit.map.fastly.net" + ], + "ip_cidrs": [] + }, + "wikipedia": { + "suffixes": [ + "wikipedia.org", + "wikimedia.org", + "wikidata.org", + "w.wiki" + ], + "domains": [], + "ip_cidrs": [] + } +} diff --git a/core/ordinary_policy.py b/core/ordinary_policy.py new file mode 100644 index 0000000..876d479 --- /dev/null +++ b/core/ordinary_policy.py @@ -0,0 +1,93 @@ +"""Explicit ordinary-service allowlist; unknown destinations remain sensitive.""" +import ipaddress +import json +from pathlib import Path +import re + +DATA = Path(__file__).with_name('ordinary-services.json') +RULESETS = Path(__file__).with_name('ordinary-rulesets.json') +SHARED_ROOTS = {'google.com', 'googleapis.com', 'gstatic.com', 'googleusercontent.com', + 'ggpht.com', 'cloudfront.net', 'amazonaws.com', 'cloudflare.com', + 'akamaihd.net', 'akamaized.net', 'fastly.net', 'azureedge.net', + 'windows.net', 'blob.core.windows.net', 'storage.googleapis.com', + 'cloudflarestorage.com', 'pages.dev', 'workers.dev', 'r2.dev', + 'vercel.app', 'netlify.app', 'herokuapp.com', 'appspot.com', + 'github.io', 'gitlab.io'} + + +def domain_overlap(kind, value, protected_kind, protected_value): + if value == protected_value: + return True + return ((kind == 'DOMAIN-SUFFIX' and protected_value.endswith('.' + value)) + or (protected_kind == 'DOMAIN-SUFFIX' and value.endswith('.' + protected_value))) + + +def ordinary_rules(group, protected): + """Compile a reviewed catalog, rejecting broad clouds and sensitive overlap.""" + services = json.loads(DATA.read_text()) + if not isinstance(services, dict) or not services: + raise ValueError('普通海外服务清单必须为非空对象') + lines, seen = [], set() + for service in services.values(): + if not isinstance(service, dict) or set(service) != {'domains', 'suffixes', 'ip_cidrs'}: + raise ValueError('普通海外服务需要 domains、suffixes、ip_cidrs') + for field, kind in (('domains', 'DOMAIN'), ('suffixes', 'DOMAIN-SUFFIX'), ('ip_cidrs', 'IP-CIDR')): + if not isinstance(service[field], list): + raise ValueError('普通海外清单字段必须为列表') + for value in service[field]: + if not isinstance(value, str): + raise ValueError('普通海外规则必须为字符串') + if kind == 'IP-CIDR': + try: + network = ipaddress.ip_network(value) + except ValueError: + raise ValueError('普通海外 CIDR 无效') from None + if not network.is_global or network.prefixlen < (20 if network.version == 4 else 32): + raise ValueError('普通海外 CIDR 必须为明确的服务公网网段') + rule_kind = 'IP-CIDR6' if network.version == 6 else kind + line = f' - {rule_kind},{value},{group},no-resolve' + else: + if not re.fullmatch(r'[a-z0-9]+(?:[a-z0-9.-]*[a-z0-9])?', value) or '.' not in value: + raise ValueError('普通海外域名格式无效') + if value in SHARED_ROOTS: + raise ValueError('不能把共享云或 CDN 整体加入普通海外清单') + if any(domain_overlap(kind, value, k, v) for k, v in protected): + raise ValueError('普通海外清单与敏感域名重叠,已停止生成') + line = f' - {kind},{value},{group}' + if (kind, value) in seen: + raise ValueError('普通海外规则重复') + seen.add((kind, value)) + lines.append(line) + return '\n'.join(lines) + '\n' + + +def ordinary_providers(group, *, target, download_proxy): + """Reference reviewed, immutable upstream sets; rendering stays offline.""" + manifest = json.loads(RULESETS.read_text()) + if (not isinstance(manifest, dict) or set(manifest) != {'revision', 'categories'} + or not isinstance(manifest['revision'], str) + or not re.fullmatch(r'[a-f0-9]{40}', manifest['revision']) + or not isinstance(manifest['categories'], dict) or not manifest['categories']): + raise ValueError('普通规则集需要固定提交和分类清单') + providers, rules, seen = [], [], set() + for category, services in manifest['categories'].items(): + if (not isinstance(category, str) or not re.fullmatch('[a-z]+', category) + or not isinstance(services, list) or not services): + raise ValueError('普通规则集分类无效') + rules.append(f' # 普通海外 / {category}') + for service in services: + if (not isinstance(service, str) or not re.fullmatch('[a-z][a-z0-9-]*', service) + or service in seen or service.startswith(('category-', 'geolocation-')) + or service in {'cn', 'proxy', 'google', 'microsoft', 'facebook', 'instagram', + 'whatsapp', 'tiktok', 'openai', 'anthropic', 'github', 'gitlab'}): + raise ValueError('普通规则集必须是唯一且经过审阅的服务子清单') + seen.add(service) + name = 'ordinary-' + service + url = (f'https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/' + f'{manifest["revision"]}/geo/geosite/{service}.mrs') + providers.append(f' {name}:\n' + ' type: http\n behavior: domain\n format: mrs\n' + f' url: {url}\n interval: 86400\n' + + (f' proxy: {download_proxy}\n' if target == 'mihomo' else '')) + rules.append(f' - RULE-SET,{name},{group}') + return '\n'.join(providers), '\n'.join(rules) + '\n' diff --git a/core/sensitive-services.json b/core/sensitive-services.json index 83b9bc7..6191803 100644 --- a/core/sensitive-services.json +++ b/core/sensitive-services.json @@ -142,5 +142,20 @@ "us-west3-aiplatform.googleapis.com", "us-west4-aiplatform.googleapis.com" ] + }, + "claude_dependencies": { + "suffixes": [ + "claude.app" + ], + "domains": [ + "http-intake.logs.us5.datadoghq.com", + "cdnjs.cloudflare.com", + "cdn.jsdelivr.net", + "cdn.tailwindcss.com", + "code.jquery.com", + "unpkg.com", + "fonts.googleapis.com", + "fonts.gstatic.com" + ] } } diff --git a/core/settings.py b/core/settings.py index 8650add..17c6b5b 100644 --- a/core/settings.py +++ b/core/settings.py @@ -7,6 +7,7 @@ import sys NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z") +FILE_PREFIX_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z") DEVICE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_]{0,63}\Z") GENERATED_OPTIONS = {"HY2_PORT", "ANYTLS_PORT", "WARP_REALITY_PORT", "CDN_WS_PATH"} CONNECTION_KEYS = ( @@ -54,6 +55,8 @@ def validate(settings, *, deployment=False): raise ValueError("设备 ID 仅支持 1-64 位字母、数字、下划线;不支持点或连字符") if any(not DEVICE.fullmatch(device) for device in settings.get("ANYTLS_DEVICES", "").split()): raise ValueError("已保存的 AnyTLS 设备清单无效") + if settings.get("CLIENT_IDENTITY") and settings["CLIENT_IDENTITY"] not in devices: + raise ValueError("CLIENT_IDENTITY 必须是 DEVICES 中的已有身份") for key in ("CLIENT_CONFIG_ENABLE", "CDN_ENABLE", "CDN_ONLY", "WARP_ENABLE", "PRIVACY_MODE", "AI_STRICT_MODE", "HY2_OBFS_ENABLE", "HY2_ACME_ENABLE"): if key in settings and settings[key] not in ("true", "false"): @@ -85,8 +88,9 @@ def validate(settings, *, deployment=False): match = re.fullmatch(r"([1-9]\d*)(?:-([1-9]\d*))?", hop) if not match or (match[2] and int(match[1]) > int(match[2])): raise ValueError("HY2_HOP_INTERVAL 必须是正整数或递增范围") - if target == "stash" and match[2]: - raise ValueError("Stash 的 HY2_HOP_INTERVAL 需要整数秒;范围仅用于 CLIENT_TARGET=mihomo") + requires_stash = target == "stash" or (deployment and settings.get("CLIENT_CONFIG_ENABLE", "true") != "false") + if requires_stash and match[2]: + raise ValueError("Stash/双客户端部署的 HY2_HOP_INTERVAL 需要整数秒;范围仅用于显式 --client mihomo 渲染") for key in ("REALITY_SNI", "HY2_SNI", "HY2_ACME_DOMAIN", "CDN_HOSTNAME"): if settings.get(key) and not re.fullmatch(r"[A-Za-z0-9.-]+", settings[key]): raise ValueError(f"{key} 必须是域名或 IPv4 地址") diff --git a/docs/aggregate-routing.md b/docs/aggregate-routing.md index 13c5bdc..8a21014 100644 --- a/docs/aggregate-routing.md +++ b/docs/aggregate-routing.md @@ -1,65 +1,159 @@ -# 多服务器汇总与日常分流 +# 双客户端汇总与白名单分流 -`aggregate.py` 将显式列出的本地 profile 汇总成每设备一份客户端配置。默认以 Stash 为目标,支持显式生成 Mihomo。单服务器部署与配置生成入口保持独立;汇总不访问云端、不修改服务端或自动切换客户端。 +`aggregate.py` 从明确列出的本地源 profile 生成客户端配置,默认同时输出 Stash 和 Mihomo(Clash Verge Rev)。共用节点来源与分流意图,各自输出原生字段。生成不部署服务器、不切换客户端;发布是显式操作。 -## 默认行为与手动排查 +## 分流约定 -| 分流入口 | 默认策略 | 可手动调整 | +| 顺序 | 流量 | 默认出口 | |---|---|---| -| AI / Meta 服务(包括 Antigravity 应用、Facebook、Instagram、WhatsApp 等) | 敏感服务主备 | 手动选择 | -| 海外流量,包括未匹配请求 | 自动测速 | 手动选择 | -| 国内流量 | DIRECT | 自动测速、手动选择 | -| Apple 基础服务 | DIRECT;Siri 等既有海外规则仍进入海外流量 | 自动测速、手动选择 | -| 屏蔽流量 | REJECT | 自动测速、手动选择、DIRECT | +| 1 | 本机、局域网、私有地址 | DIRECT | +| 2 | AI、Meta 商业相关及共享域名、TikTok Shop/广告、认证与上传依赖、Mac 上的 Antigravity 应用 | 敏感服务主备 | +| 3 | 明确的广告拦截规则 | REJECT | +| 4 | 国内域名白名单 `cn`、`apple-cn` | DIRECT | +| 5 | 普通海外服务清单 | 自动测速 | +| 6 | 所有未匹配的 TCP / UDP | 敏感服务主备 | -敏感服务主备按 `sensitive` 数组的顺序执行,示例为 **cstone/Reality → gcloud/CDN**。只引用这些指定节点,不自动退到 GCP 直连、其他服务器、WARP 或 DIRECT。CDN 保护本地到 GCP 的入口;出口仍是 GCP,不能改善目的站对 GCP IP 的信誉判断。切换备用时出口 IP 会改变。主备全部不通时敏感服务失败,用户可以主动切换到手动选择排查。 +准确优先级以生成的 rules 为准:已有业务保护规则保留在广告拦截前。白名单来自 MetaCubeX 的国内域名集合;这是可更新的域名清单,不是“任何 .cn 域名”或“任何大陆 IP”。不再用 `.cn` 后缀、`cn-ip`、`GEOIP,CN` 自动放行。Spotify 和全球 Apple/iCloud 服务没有额外直连豁免,Apple 国内白名单仍可直连。 -所有业务组的手动入口指向同一个「手动选择」组,完整节点列表只出现一次。操作顺序:先在「手动选择」中选节点,再把需要排查的业务组切到「手动选择」。这会影响所有已经选择手动的业务组;其他业务组的默认策略不变。排查结束后恢复该业务组的第一个选项。若以后需要多个业务同时固定到不同节点,应增加各自的独立选择组。 +敏感服务按照 `sensitive` 数组顺序选择,例如 cstone/Reality → gcloud/CDN,不自动扩展到其他节点或 DIRECT。CDN 只改变到 GCP 的入口,不能改变其最终出口信誉。主备切换会改变出口 IP。未匹配流量直接 `MATCH,🛡 敏感服务`,不跟随普通业务组的手动选择。AI 策略组只允许指定敏感主备,不能切到全节点手动组。 -普通海外和未匹配流量共用一个入口,不再经过「代理流量 → 代理策略 → 兜底」多层选择。Spotify 保留原有直连行为,使用显式 DIRECT 规则,不显示独立分组,也不会跟随海外流量组选线。局域网保持直连,避免改动业务分流后影响本地设备访问。 +普通海外使用 53 个上游服务规则集与 9 项本地补充(76 条),覆盖影音、游戏、新闻、学习、开发下载、通讯和用户指定的金融服务,包括 YouTube、Netflix、Telegram、IBKR、TradingView、Spotify、Twitch、Reddit、Wikipedia、Threads 等。完整来源及取舍见 [普通服务清单来源与边界](ordinary-services-sources.md)。`🌐 普通海外` 默认选择 `⚡ 自动测速`,也可手动改为敏感主备或指定节点。测速组每 300 秒检查所有非 Reality-WARP 节点,不包含 DIRECT;Mihomo 设置 50 ms 切换容差。测速衡量探测延迟,不代表带宽、流媒体解锁或业务可用性。IBKR 按用户选择加入,出口变化可能影响登录会话;未识别的 TWS 地址仍走敏感兜底。 -「自动测速」每 300 秒测试一次,参与范围是启用来源中当前生成的普通节点;WARP 如已生成,只在手动列表中出现。「敏感服务」每 60 秒检查一次,采用固定优先顺序。它们都跳过不健康节点。HTTP 延迟探测不能等同于下载带宽、IP 信誉或某个网站的业务可用性;连得上测速 URL 但网站异常时需要手动切换。Stash 的测试地址/超时沿用生成器中的节点级 `benchmark-url` / `benchmark-timeout`,不假定 Mihomo 的组级 `url` / `tolerance` 在 Stash 生效。 +普通规则集在 `core/ordinary-rulesets.json` 按类别维护,少量补充在 `core/ordinary-services.json`;八层路由顺序由 `core/aggregate-rules.yaml.tmpl` 明确声明,不引入全 Google、共享 CDN 或“非大陆即测速”的规则。已知敏感规则优先,国内白名单与广告规则也先于普通清单。新增静态域名若与 AI、Meta 或业务保护域名重叠,或试图放行共享云根域,生成会失败并保留旧成品;动态敏感规则集发生重叠时由顺序保证敏感优先。未知依赖留在敏感兜底,因此清单并不承诺完整覆盖每款应用。域名规则不能区分同域名中的业务用途,例如 Telegram AI 机器人也会跟随 Telegram 出口。 -## 配置与生成 +普通服务直接引用 MetaCubeX 固定提交的 MRS 子清单,业务重叠由更早的敏感规则覆盖,不自动跟随远端增加普通服务。Threads 已按用户选择纳入普通组,不因属于 Meta 而统一归入敏感;无法按路径拆分的 Facebook/Instagram 商业与普通请求继续使用敏感出口。Telegram 网段来源为 https://core.telegram.org/resources/cidr.txt (核对日期 2026-09-25);TradingView widget 域名参考 https://en.tradingview.com/widget-docs/tutorials/web-components/configuring/ 。域名参考数据:https://github.com/MetaCubeX/meta-rules-dat/tree/meta/geo/geosite 。 -在汇总 profile 下保存 `aggregate.json`,格式参考 [配置示例](../config/aggregate.json.example): +Stash 的 `PROTOCOL,STUN` 无法直接迁移到 Mihomo。本方案不靠 STUN 协议或常见端口补丁实现隐私兜底:两个客户端都将未命中白名单的未知 TCP/UDP 送往敏感主备。明确命中国内域名白名单的 UDP 仍直连;命中普通服务清单的 UDP 使用普通海外组,这是恢复测速后明确允许的例外。Mihomo 的 TUN 排除本机、RFC1918、链路本地和 CGNAT 地址,保护局域网与 Tailscale 等私网路由。DNS 劫持仅处理进入 TUN 的 DNS,不代表应用自带加密 DNS 也会被接管。 + +IPv6 沿用当前配置的关闭状态;不能据此宣称操作系统所有 IPv6 出口均已封堵。完整隐私验收还需实际客户端接管、IPv6、WebRTC 与应用自带 DNS 测试。 + +Agent 的模型 API 请求与访问资料网站的请求分别匹配规则。普通网站出口变化不会自动改变模型 API 出口;但网站会看到它收到的连接出口,Agent 也可能从定位、验证码或工具返回内容中得知变化。X 当前不在普通清单中,继续走敏感兜底。已保留 Antigravity 的进程优先规则;无法据此保证独立浏览器、子工具或远程抓取服务也走同一出口。 + +## 一份方案、两份客户端文件 + +新汇总计划参考 `config/aggregate.json.example`: + +```json +{ + "sources": [ + {"profile": "primary", "name": "cstone"}, + {"profile": "backup", "name": "gcloud"} + ], + "sensitive": ["cstone/Reality", "gcloud/CDN"], + "credential_identity": "mac" +} +``` + +`credential_identity` 只指定复用源 profile 中哪套已存在的凭据。它不是新的设备输出维度,不创建用户、不改服务器、不删除其他身份。选择 `mac` 后,同一 Stash 文件可供 Mac/iPhone 使用,两者共享该身份,不能再单独撤销。文件与头部不标记设备。Mac 进程规则保留在共享文件中;iOS 不执行进程规则,依靠域名分流。 ```bash -mkdir -p profiles/routing -chmod 700 profiles/routing -cp config/aggregate.json.example profiles/routing/aggregate.json -chmod 600 profiles/routing/aggregate.json python3 aggregate.py render --profile routing python3 aggregate.py check --profile routing +# 仅重建某一端,另一端不受影响 +python3 aggregate.py render --profile routing --client mihomo ``` -修改 `sources` 中的 profile 名称以匹配实际服务器。`name` 是节点显示前缀,必须唯一;最终名称如 `cstone/Reality`、`gcloud/CDN`。`devices` 显式列出要生成的设备,敏感主备的每个来源都必须具备对应设备凭据。普通来源缺少某设备时,仅从该设备的节点池中省略;明确指定的敏感主备缺失时,整个生成失败并保留已有输出。 +输出(权限 600,目录 700): -输出为 `clash-configs/routing-mac.yaml`、`clash-configs/routing-iphone.yaml`,权限为 `600`,目录为 `700`。各设备只应导入自己的文件。需要 Mihomo 时使用 `--client mihomo`;同名文件会被替换,不能把该目标的文件当成 Stash 配置。 +```text +clash-configs/stash/routing.yaml +clash-configs/mihomo/routing.yaml +``` -汇总从源 profile 重新生成节点,不依赖已有客户端 YAML 或 iCloud 副本。临时生成目录为私有目录,用后清理;凭据继续归各源 profile 管理,不写进公开规则。生成期间使用 profile 操作锁;遇到部署、换 IP 或源文件变化时停止,避免混合两版状态。源节点生成/校验失败发生在写入前,保留旧输出;发布阶段沿用逐文件原子替换,并非跨文件事务,磁盘写入失败后应修复问题并重新 render/check。 +旧版 `devices/client` 计划仍能读取,默认也会生成两种客户端,但每个客户端目录继续保留原设备维度。切换到上述 `credential_identity` 计划才合并身份。旧目录下未审计的 `routing-mac.yaml` / `routing-iphone.yaml` 不自动删除。单服务器 `node.py render --profile ` 也默认输出 `stash/.yaml` 和 `mihomo/.yaml`,两者复用 `CLIENT_IDENTITY`(未指定时优先 mac)。服务器账号清单保留,不再按设备输出。 -源 profile 的 `CLIENT_CONFIG_ENABLE=false` 会将普通来源移出汇总;若它被指定为敏感主备,则报错,要求明确调整主备计划。不会生成空策略组,因为 Stash 会把空组视为 DIRECT。 +若只有已有的客户端文件、缺少原始汇总/服务器 profile,可一次性导入其内嵌节点: -增加节点、修改端口/密码、GCP 换 IP、停用来源之后,都需要重新执行 `aggregate.py render` 并更新设备分发副本。`check` 会从当前源重新计算并判断漂移;不会修改现有 YAML,也不测试网络在线状态。源 profile 的 `AI_STRICT_MODE`、国内组偏好只影响其单服务器配置,汇总始终使用本文定义的日常分流策略。 +```bash +uv run --with PyYAML==6.0.2 python aggregate.py import --profile routing --source /path/to/routing-mac.yaml +python3 aggregate.py render --profile routing +``` -## DNS 与规则边界 +导入仅支持已知的 VLESS/HY2/AnyTLS 节点以及明确的「敏感服务」fallback 主备;不接受外部节点 provider,也不复制旧文件的分流规则。导入后,节点以客户端中性的 JSON 存入 `profiles/routing/nodes.json`(600),汇总计划引用此快照。主备必须支持 UDP,以免不支持 UDP 的出口导致规则继续向下匹配。导入拒绝覆盖已有 profile;服务器凭据变更后须维护私有快照或切回完整源 profile 生成方式,不能假设快照会自动感知服务器变化。只有首次 YAML 导入需要 PyYAML,日常快照生成仍只用 Python 标准库。 -AI 和 Meta 的明确域名/域名集共用敏感服务的加密解析器地址,解析器连接路由到「AI / Meta 服务」组,因此切换该组到手动节点时,其 DNS 也跟随选择。普通海外 DNS 使用独立解析器,经海外流量组,避免依赖敏感主备。国内 DNS 沿用国内组;节点域名由 `proxy-server-nameserver` 独立启动解析。规则集下载经普通海外组,避免敏感线路同时故障时影响规则更新。 +两个客户端完整生成后才开始写出;每个客户端分别维护归属清单。持有所有源 profile 的操作锁,避免混入部署/换 IP 中间状态。生成失败保留旧输出;磁盘故障时逐文件原子替换不构成跨文件事务,应修复后重新 render/check。 -DNS 分组覆盖静态精确/后缀规则及对应 AI、Meta geosite 集合。域名规则无法推断未知第三方依赖或请求归属;原有 DOMAIN-KEYWORD、IP、协议规则与 DNS 策略也不是逐条等价。新增网站依赖时仍需补精确规则;通配 DNS 规则可能优先于 geosite,见 [Stash DNS 文档](https://stash.wiki/features/dns-server)。 +## 按主配置节点生成单服务器文件 -Meta 的静态域名和远程域名集优先于广告拦截、国内分流和普通兜底;AI 认证、上传、监控等已知依赖由共享敏感服务清单生成。汇总仅增补策略,不复制维护另一份完整业务规则。 +共享计划设置 `"standalone": true` 后,默认 render 同时生成主配置与每个服务器别名的独立文件,每份都有 Stash / Mihomo 两种格式。例如 `routing-cstone.yaml`、`routing-lax.yaml`、`routing-gcloud.yaml`;文件名的 routing 表示来自主配置,后缀是服务器名,不是设备名。 -## 验证 +单服务器文件直接复用主配置当次生成的节点、凭据、DNS、规则与规则集,不再从旧部署目录另取一份节点。每个节点必须采用合法的 `server/protocol` 名称;会检查每台服务器可用的 Reality/CDN 敏感出口,无可用出口则停止生成。AI 优先复用主配置在该服务器上指定的线路;该服务器不在主配置敏感主备中时,单机文件使用其 Reality/CDN。这只是单机使用方式,不代表该出口具有住宅属性或已通过 AI 账号风控。单机文件无法提供跨服务器主备;主配置仍是日常推荐。 + +```bash +python3 aggregate.py render --profile routing +python3 aggregate.py check --profile routing +# 将主配置与三台服务器的 Stash 文件一起发布到 iCloud +python3 aggregate.py publish --profile routing --client stash +# 每个 HTTPS 发布显式指定需要的服务器 +python3 subscription.py publish --profile routing --server cstone +python3 subscription.py publish --profile routing --server lax +python3 subscription.py publish --profile routing --server gcloud +python3 subscription.py copy-link --profile routing --server gcloud +``` + +历史部署目录不是当前汇总节点的清单。以主配置中的服务器别名为准,使用带主配置前缀的单机文件,避免误选历史部署生成的同名文件。退役节点或旧分设备输出不自动删除,清理前核对使用者及归属。 + +主配置快照仍是源,节点参数变化后应先更新源再 render/publish。 + +## 客户端适配 + +- HY2:Stash `auth` / `server-cert-fingerprint` / `up-speed,down-speed`;Mihomo `password` / `fingerprint` / `up,down`。 +- DNS:Stash `follow-rule`,Mihomo `respect-rules`;节点 bootstrap 单独解析。 +- 测速:Stash 使用节点 benchmark 参数;Mihomo 使用策略组测速 URL。主备只做健康检查,不改变指定顺序。 +- 应用规则:Stash 的包路径前缀与 Mihomo 的 `PROCESS-PATH-REGEX` 分别生成。 +- TUN:Mihomo 显式配置 TUN、DNS 劫持、私网排除;Stash 由应用管理增强模式。 +- DNS:敏感域名使用敏感组解析器;国内白名单使用国内解析器;其余解析器默认也通过敏感主备。已知解析器端点和节点 bootstrap 是必要的解析基础设施,不能与业务白名单完全等同。 -运行时只依赖 Python 标准库。测试使用 PyYAML 独立解析生成的 YAML,验证引用、循环、DNS 路由和客户端字段: +Clash Verge 的应用设置可能覆盖订阅里的 TUN/DNS/端口设置。成功导入不代表已经启用 TUN,也不证明最终规则未被全局/订阅扩展改写。检查客户端最终运行配置和连接日志,不能只检查源 YAML。 + +## 发布与导入 + +### Stash:iCloud + +```bash +python3 aggregate.py publish --profile routing --client stash +# 可显式指定已存在的分发目录 +python3 aggregate.py publish --profile routing --client stash --icloud-dir /path/to/Stash/Documents +``` + +默认发布到 macOS Stash iCloud 容器的 `Documents`,共享计划下文件名为 `routing.yaml`。发布前检查源配置是否过期;拒绝覆盖归属不明的同名文件和符号链接。其他订阅、旧分端文件保持原样。命令不主动调用 Stash 切换或重载接口,但客户端可能自行监测、同步或重载文件,因此 iCloud publish 不能视为完全没有运行影响。本地写入成功与 iPhone 已收到同步是不同验证阶段。 + +### Clash Verge:私有 HTTPS + +采用 Cloudflare Workers Free + 私有 KV + `workers.dev`,无需购买或续费个人域名。账户应维持 Workers Free;部署不要求付费套餐或 R2。端点只读取本地验证后的 Mihomo 成品,不上传 profiles 或服务器管理文件。部署与维护见 [订阅服务](../cloud/subscription/README.md)。 + +```bash +python3 aggregate.py render --profile routing +python3 aggregate.py publish --profile routing --client mihomo +python3 subscription.py verify --profile routing +python3 subscription.py copy-link --profile routing +# 或调用 Verge URL scheme,并在客户端检查结果 +python3 subscription.py import --profile routing +``` + +随机订阅令牌保存在 macOS 钥匙串;Worker 仅保存 SHA256 校验值。无令牌、错误令牌与未知路径返回 404,不提供公开目录。响应禁止缓存,关闭 Worker 请求日志和跟踪,避免完整 URL 被应用日志记录。完整 URL 等同订阅密码;复制后只粘贴到可信客户端。客户端本身需要保存 URL,因此客户端数据仍应视为私密。 + +发布先校验源与成品一致,写 KV 后读回,再从 HTTPS 端点逐字节核对并测试未授权访问。KV 有跨区域传播延迟,若端点仍是旧版本,稍后运行 verify,不重复写入。写入后的验证失败不意味着自动回滚。订阅默认建议每 24 小时更新,最终周期由客户端决定。 + +`workers.dev` 不依赖个人域名续费,但中国大陆首次下载的可达性取决于网络;当前代理下成功下载不代表关代理后也可用。可随时从本地 `clash-configs/mihomo/routing.yaml` 首次导入,再设置订阅。导入和订阅下载不代表 TUN 已启用或客户端扩展未改写规则。 + +官方依据: +- https://www.clashverge.dev/guide/url_schemes.html +- https://developers.cloudflare.com/workers/platform/pricing/ +- https://developers.cloudflare.com/kv/platform/pricing/ +- https://developers.cloudflare.com/kv/concepts/how-kv-works/ + +## 验证 ```bash python3 -m pip install -r requirements-test.txt python3 -m unittest discover -s tests -p 'test_*.py' ``` -真实连通性、Stash 导入后的组选择和设备网络差异,需要在各设备上验证。规则策略类型依据 [Stash 策略组文档](https://stash.wiki/proxy-protocols/proxy-groups)。 +真实内核校验与隔离路由测试:设置 `MIHOMO_BIN` 为 Mihomo 可执行文件、`MIHOMO_DATA_DIR` 为已有公开地理数据库所在目录,再执行: + +```bash +python3 -m unittest discover -s tests -p 'test_aggregate*.py' +``` -Antigravity 应用规则、AI 清单来源、Stash 版本要求及已知覆盖限制见 [AI 分流覆盖说明](ai-routing-coverage.md)。 +`test_aggregate_runtime.py` 使用真实 Mihomo、回环 HTTP 出口和确定性规则集夹具,验证敏感域名优先于国内与普通清单、国内/局域网直连、清单内服务使用测速出口、未知域名与非白名单 `.cn` 使用敏感兜底。夹具让测速组选中 BACKUP、敏感主备仍选 PRIMARY,以实际响应区分两种出口;同时覆盖 Google 共享域及伪装成 YouTube 的域名。域名直连使用受控 NXDOMAIN 和内核日志验证路由选择,不外连真实站点。测试还将兜底故意改为 DIRECT,确认能识别错误。设置 `ORDINARY_RULESET_DIR` 指向固定提交的完整 MRS 文件目录(以服务名命名)时,测试用真实上游数据并从控制接口核对全部规则集已加载;未设置时使用最小离线规则集夹具。另以真实 HTTP 404 验证普通规则无缓存时回到敏感兜底。它不启用 TUN,不改系统代理,不证明真实节点连通性、UDP 转发或 Stash/iPhone 的实际运行状态。 diff --git a/docs/ai-routing-coverage.md b/docs/ai-routing-coverage.md index 6083ac2..ff71b01 100644 --- a/docs/ai-routing-coverage.md +++ b/docs/ai-routing-coverage.md @@ -1,19 +1,19 @@ # AI 与 Antigravity 的覆盖边界 -核对日期:2026-09-07。目标是让已知海外 AI 服务及其必要登录依赖使用敏感线路,同时为 Antigravity 增加应用级分流;不把所有 Google Cloud、AWS 或 Azure 流量都当成 AI。 +核对日期:2026-09-25(本轮重点复核 Claude;其他服务沿用既有证据)。目标是让已知海外 AI 服务及其必要登录依赖使用敏感线路,同时为 Antigravity 增加应用级分流;不把所有 Google Cloud、AWS 或 Azure 流量都当成 AI。 ## Antigravity:应用优先,域名补充 -汇总 Mac 配置在静态局域网规则之后、普通业务和广告规则之前,将以下应用包内的可执行文件送到「AI / Meta 服务」: +共享汇总配置在 macOS 上,于静态局域网规则之后、普通业务和广告规则之前,将以下应用包内的可执行文件送到「AI / Meta 服务」: - `/Applications/Antigravity.app/`:本机核对版本 2.12.2,含主程序、Electron 辅助程序与 `Contents/Resources/bin/language_server`。 - `/Applications/Antigravity IDE.app/`:当前未安装;这是 2.12.2 Hub 安装代码内明确的独立 IDE 安装目标。 -Stash 使用 `PROCESS-NAME` 的包路径前缀模式,末尾 `/` 必须保留;Mihomo 使用单独生成的 `PROCESS-PATH-REGEX`,不能混用两种语义。默认经过敏感服务主备,排查时把「AI / Meta 服务」切到「手动选择」即可。没有增加外显分组。单服务器配置复用同一批域名与应用规则,但出口仍是其既有「AI 隐私出口」组;本文的手动切换步骤指汇总配置。 +Stash 使用 `PROCESS-NAME` 的包路径前缀模式,末尾 `/` 必须保留;Mihomo 使用单独生成的 `PROCESS-PATH-REGEX`,不能混用两种语义。默认经过敏感服务主备;汇总的 AI 策略组只允许指定敏感线路,不能切到普通测速或全节点手动组。没有增加外显分组。旧部署生成器的单服务器配置使用「AI 隐私出口」组;从汇总计划派生的单服务器文件复用汇总规则和分组,只保留对应 VPS。 -[Stash Mac 4.2.0 更新记录](https://stash.wiki/en/release-notes/macos)明确新增了末尾 `/` 的进程路径前缀匹配。本机 Stash 为 4.2.0,满足这项规则的版本要求。应用规则依赖客户端能识别连接的本机进程;浏览器登录、应用启动的外部 `curl` / Python / Node、远端 SSH 执行和不在这些包内的工具不继承应用身份。应用移到其他目录后应修改路径。iOS 不支持进程规则,因此只在设备名 `mac` 的配置中输出,其他设备仍依靠域名规则。参见 [Stash 规则类型](https://stash.wiki/rules/rule-types)。 +[Stash Mac 4.2.0 更新记录](https://stash.wiki/en/release-notes/macos)明确新增了末尾 `/` 的进程路径前缀匹配。本机 Stash 为 4.2.0,满足这项规则的版本要求。应用规则依赖客户端能识别连接的本机进程;浏览器登录、应用启动的外部 `curl` / Python / Node、远端 SSH 执行和不在这些包内的工具不继承应用身份。应用移到其他目录后应修改路径。iOS 忽略进程规则。2026-09-25 起的共享汇总文件保留 Mac 应用规则以便两端共用;iOS 依靠域名规则和敏感主备兜底。旧版分设备计划仅对 `mac` 输出应用规则;新的共享文件保留应用规则。参见 [Stash 规则类型](https://stash.wiki/rules/rule-types)。 -应用规则覆盖包内程序发出的公网业务、更新、插件与遥测请求;局域网优先直连。不能由此声称所有 DNS 查询也带有应用身份:应用未知域名仍可能使用普通海外解析器,所以核心 API 和登录域名需要明确 DNS 策略。 +应用规则覆盖包内程序发出的公网业务、更新、插件与遥测请求;局域网优先直连。不能由此声称所有 DNS 查询也带有应用身份:共享汇总文件的未知域名解析默认使用敏感线路,核心 API 和登录域名仍保留明确 DNS 策略。 ## 同源的域名与 DNS @@ -32,7 +32,15 @@ Antigravity 包内明确包含 `daily-cloudcode-pa.googleapis.com`、`aicode.goo HTTPS 规则只能按主机分流,不能区分加密 URL 路径。因此 `www.googleapis.com` 的非 AI API、Google 账号登录,以及既有 `challenges.cloudflare.com`、Stripe/Intercom 等共享依赖,也会进入敏感线路。这是保障登录链路的明确取舍;没有将整个 `googleapis.com`、`googleusercontent.com`、`storage.googleapis.com` 或 `www.gstatic.com` 静态归入敏感服务。 -移除 `datadog`、`sentry`、`sift` 的全局关键词和 `sentry.io` 整站静态规则,改用已确认的具体租户/主机。既有 STUN 与 NTP 隐私规则保留,它们是协议/端口策略,不代表对应域名全部属于 AI。Meta 仍合并在同一个服务入口;Spotify 继续显式 DIRECT,国内 AI 未统一强制出海。 +移除 `datadog`、`sentry`、`sift` 的全局关键词和 `sentry.io` 整站静态规则,改用已确认的具体租户/主机。汇总配置以敏感兜底覆盖未知 TCP/UDP;明确的国内和普通服务白名单可以先匹配。Threads、Spotify 属于普通海外组,Meta 商业及共享域名仍使用敏感线路。旧部署生成器的单服务器策略与汇总不同;从汇总派生的单机文件使用同一套规则。 + +## Claude 本轮复核与独立清单 + +AI 动态集合继续复用 MetaCubeX `category-ai-!cn`;少量必须优先保障的域名由独立源文件 `core/sensitive-services.json` 维护并编译进两种客户端 YAML。因此维护上是独立清单,交付上仍是一份完整配置,无需额外下载私人规则文件;再拆成远程补充文件只会增加首次下载与版本同步依赖。 + +依据 [Claude Code 网络配置](https://code.claude.com/docs/en/network-config)及 [Desktop 网络要求](https://code.claude.com/docs/en/desktop#network-access-requirements),原有规则已覆盖 `anthropic.com`、`claude.ai`、`claude.com`、`claudeusercontent.com`、`claudemcpcontent.com` 等根域下的 API、登录、MCP、下载、bridge 和 frame。此次补入 `claude.app`(含 livepreview)、`http-intake.logs.us5.datadoghq.com`,以及 Artifacts 的七个精确主机:`cdnjs.cloudflare.com`、`cdn.jsdelivr.net`、`cdn.tailwindcss.com`、`code.jquery.com`、`unpkg.com`、`fonts.googleapis.com`、`fonts.gstatic.com`。这些共享主机的其他业务也会使用敏感线路,以保证渲染依赖一致;没有扩大为整个 Cloudflare/Google 根域。 + +上述官方一方域名和补充依赖同时进入敏感流量规则与 DNS 策略,并由真实 Mihomo 隔离路由测试核对。官方表中的 GitHub/npm/Homebrew 等通用安装下载、用户连接的第三方 MCP、工具访问网站仍按各自规则分流;网络允许列表并不意味着它们都必须与模型 API 共用 IP。没有声称穷举未来域名,也未以路由测试代替 Claude 实际登录、长连接、账号风控或出口住宅属性验收。 ## 仍然存在的限制 diff --git a/docs/ordinary-services-sources.md b/docs/ordinary-services-sources.md new file mode 100644 index 0000000..3a0981c --- /dev/null +++ b/docs/ordinary-services-sources.md @@ -0,0 +1,50 @@ +# 普通规则集的来源与维护 + +核对日期:2026-09-25。普通服务由 **53 个远程规则集 + 9 项本地补充** 组成。普通域名明细不再复制到仓库和主配置中。 + +## 单一维护入口 + +| 文件 | 维护内容 | +|---|---| +| `core/aggregate-rules.yaml.tmpl` | 有顺序的八层路由;敏感与业务保护先于国内/普通 | +| `core/ordinary-rulesets.json` | 上游固定提交、按用途分类的服务名单 | +| `core/ordinary-services.json` | 76 条本地精确域名/后缀/Telegram 网段;保留九项收窄清单 | +| `core/sensitive-services.json` | 两个客户端共用的 AI 核心域名与依赖补充 | + +`ordinary_policy.py` 校验本地域名与敏感域名不重叠,并拒绝共享云根域、重复服务、活动分支和泛海外类别。生成器把以上意图转换为 Stash/Mihomo 原生配置,日常 render 不联网。 + +## 复用上游 + +直接使用 [MetaCubeX/meta-rules-dat](https://github.com/MetaCubeX/meta-rules-dat) 的 MRS 服务子清单,固定提交 `0f3410e013082b242f381750899a533078914f34`。仓库标注 GPL-3.0;相关上游包括 MIT 的 [v2fly/domain-list-community](https://github.com/v2fly/domain-list-community) 及 GPL-2.0 的 [blackmatrix7/ios_rule_script](https://github.com/blackmatrix7/ios_rule_script)。不使用 Global/Proxy、非 CN、整个开发或社交类别作为测速兜底。 + +配置引用形式:`https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/<固定提交>/geo/geosite/<服务>.mrs`。两个客户端都支持 MRS domain/ipcidr;无需部署额外规则托管站,也没有给 iPhone 留下必须复制的本地 sidecar 文件。 + +| 分类 | 规则集 | +|---|---| +| media | disney, hbo, primevideo, vimeo, dailymotion, deezer, soundcloud, tidal, lastfm | +| games | steam, epicgames, gog, playstation, xbox, nintendo, ubisoft, ea, blizzard, riot, rockstar | +| social | discord, signal, line, threads | +| news | bbc, cnn, nytimes, bloomberg, reuters, wsj, economist, ft, theguardian | +| learning | coursera, edx, khanacademy, stackexchange | +| development | npmjs, python, docker, rust, golang, debian, ubuntu, archlinux, sourceforge, kubernetes, homebrew, fedora, readthedocs, ruby, flutter, dart | + +本地保留 YouTube、Netflix、Telegram、IBKR、TradingView、Spotify、Twitch、Reddit、Wikipedia 的收窄清单。其中 IBKR/TradingView 属于用户指定补充,其他清单避免共享域、关联业务或无关服务被整个上游包带入测速。Telegram 14 条 CIDR 来自 [官方网段](https://core.telegram.org/resources/cidr.txt)。Hulu 已包含在 Disney 规则集中。 + +## 敏感边界与例外 + +- AI、Meta 商业及共享域名、TikTok Shop、广告/归因后台和已知依赖先匹配敏感主备。AI 策略组只允许指定敏感组,不再通向全节点手动组;兜底直接引用敏感组。 +- Threads 的 threads.net/threads.com 按用户要求走普通测速;共享 Meta 资源仍可能被前面的敏感规则接管。 +- 上游 LINE 包中的 line.biz、linemyshop.com、lineshoppingseller.com,以及 HBO 包中的 brightline.tv,在前面显式送往敏感组。依据 [LINE 官方商业页面](https://www.lycbiz.com/jp/) 与 [BrightLine](https://brightline.tv/)。 +- 上游包含的五个品牌顶级域名空间 bbc/bloomberg/hbo/playstation/xbox 仍以敏感规则覆盖,保留上一版的保守边界。 +- Google、Microsoft、GitHub、GitLab、Notion、Figma、Zoom 等混合平台未整体加入普通测速;X 仍保留敏感兜底。同域名内的普通内容与 AI/广告用途无法按页面路径分开。 +- 固定版本避免普通清单在无人审阅时扩展。动态 AI 与业务保护优先于普通规则,未知目的地址继续走敏感主备。 + +## 更新与故障行为 + +更新只需修改 manifest 的提交或服务名单,审阅完整上游子清单与敏感/共享域重叠,再运行结构测试、真实内核和分流验收。不要手工维护一份逐域名镜像。Mihomo 的普通规则下载显式使用敏感组;Stash 依靠 raw.githubusercontent.com 的前置敏感路由。 + +首次使用需要下载规则;客户端会缓存。某个普通规则下载失败、且没有可用缓存时,其未匹配流量继续走敏感兜底。已有国内或敏感规则命中的流量仍按更早规则处理。HTTP 404 无缓存场景已用真实 Mihomo 验证。敏感上游下载失败时,已知敏感域名仍有本地补充;无法保证未知、重叠或分类错误的域名被语义识别。 + +配置中引用规则集不代表其在真实设备上已下载成功。启用后需核对 provider 状态;本地文件和发布验证不能替代客户端实际加载检查。 + +官方格式依据:[Stash 规则集合](https://stash.wiki/rules/rule-set)、[Mihomo Rule Providers](https://wiki.metacubex.one/en/config/rule-providers/)。 diff --git a/node.py b/node.py index 83b494d..7f0ee3e 100644 --- a/node.py +++ b/node.py @@ -16,7 +16,7 @@ def main(): parser.add_argument("action", choices=("status", "validate", "render", "check", "cdn-check", "ip-check", "ip-sync", "ip-rotate", "ip-rollback", "ip-finalize")) parser.add_argument("--profile", required=True) - parser.add_argument("--client", choices=("stash", "mihomo")) + parser.add_argument("--client", choices=("stash", "mihomo", "both")) parser.add_argument("--apply", action="store_true", help="执行换 IP、回滚或释放保留地址;省略时仅显示计划") args = parser.parse_args() if not NAME.fullmatch(args.profile): @@ -31,7 +31,7 @@ def main(): if args.profile != 'gcloud': parser.error('当前 GCP 入口恢复仅支持 gcloud profile') if args.client: - parser.error('IP 操作沿用 profile 的 CLIENT_TARGET,不接受 --client') + parser.error('IP 操作默认同步两种客户端,不接受 --client') from profile_lock import profile_lock sys.path.insert(0, str(ROOT / 'providers')) from gcp_ip import Rotation, check, render @@ -56,7 +56,7 @@ def main(): print('CDN TLS / WebSocket / VLESS / HTTPS 请求全部通过;未通过直连节点回退') return if args.client: - settings["CLIENT_TARGET"] = args.client + settings["CLIENT_TARGET"] = "stash" if args.client == "both" else args.client if args.action == "status": print(f"Profile: {args.profile}") print("客户端输出: " + ("已停用" if settings.get("CLIENT_CONFIG_ENABLE", "true") == "false" else "已启用")) @@ -77,7 +77,7 @@ def main(): command += ["--client", args.client] env = os.environ | {"NETWORK_NODE_ROOT": str(ROOT), "NETWORK_NODE_STATE_DIR": str(state), "NETWORK_NODE_PROFILE": args.profile} - if args.action == 'render' and args.profile == 'gcloud': + if args.action in ('render', 'check'): from profile_lock import profile_lock with profile_lock(state): return subprocess.call(command, env=env) diff --git a/subscription.py b/subscription.py new file mode 100644 index 0000000..1319dae --- /dev/null +++ b/subscription.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Publish verified Mihomo output; subscription tokens remain in macOS Keychain.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import secrets +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +from core.settings import FILE_PREFIX_PATTERN, NAME, load_settings + +ROOT = Path(__file__).resolve().parent +CLOUD = ROOT / 'cloud/subscription' +WRANGLER = CLOUD / 'node_modules/wrangler/bin/wrangler.js' +SERVICE = 'Network-Node subscriptions' + + +class PublishError(ValueError): + """Messages in this exception are explicitly safe to show.""" + + +def run(command, **kwargs): + result = subprocess.run(command, capture_output=True, **kwargs) + if result.returncode: + raise PublishError('外部命令失败;已隐藏可能包含凭据的输出') + return result.stdout + + +def settings(): + if not (CLOUD / 'wrangler.jsonc').is_file() or not (CLOUD / 'endpoint.json').is_file(): + raise PublishError('请先复制 cloud/subscription 中的 example 配置并填写自己的账户、KV 和发布地址') + data = json.loads((CLOUD / 'wrangler.jsonc').read_text()) + state = json.loads((CLOUD / 'endpoint.json').read_text()) + base = state['base_url'] + if not re.fullmatch(r'https://[a-z0-9-]+\.[a-z0-9-]+\.workers\.dev', base): + raise PublishError('订阅地址必须是已确认的 HTTPS workers.dev 地址') + return data, base + + +def keychain_token(account, *, create=False): + result = subprocess.run(['security', 'find-generic-password', '-s', SERVICE, + '-a', account, '-w'], capture_output=True, text=True) + if result.returncode == 0: + token = result.stdout.strip() + if not re.fullmatch(r'[A-Za-z0-9_-]{43}', token): + raise PublishError('钥匙串中的订阅令牌格式不正确') + return token + if not create or result.returncode != 44: + raise PublishError('无法读取订阅令牌;请检查钥匙串访问权限或先运行 set-token') + token = secrets.token_urlsafe(32) + # Interactive input avoids placing the token in process arguments or files. + instruction = f'add-generic-password -s "{SERVICE}" -a "{account}" -w "{token}"\n' + run(['security', '-i'], input=instruction, text=True) + if keychain_token(account) != token: + raise PublishError('钥匙串令牌写入后校验失败') + return token + + +def auth_token(): + raw = run(['node', str(WRANGLER), 'auth', 'token', '--json'], text=True, + cwd=CLOUD, env=os.environ | {'WRANGLER_SEND_METRICS': 'false'}) + data = json.loads(raw) + token = data.get('token') + if not isinstance(token, str) or not token: + raise PublishError('Wrangler 登录状态无效') + return token + + +class Cloudflare: + def __init__(self, token): + self.token = token + + def request(self, path, *, method='GET', content=None, raw=False): + request = urllib.request.Request('https://api.cloudflare.com/client/v4/' + path, + method=method, data=content, + headers={'Authorization': 'Bearer ' + self.token, 'Content-Type': 'application/octet-stream'}) + try: + with urllib.request.urlopen(request, timeout=40) as response: + body = response.read() + except urllib.error.HTTPError as exc: + code = exc.code + exc.close() + raise PublishError(f'Cloudflare 操作失败(HTTP {code});响应内容已隐藏') from None + if raw: + return body + result = json.loads(body) + if not result.get('success'): + raise PublishError('Cloudflare 未确认操作成功;响应内容已隐藏') + return result.get('result') + + +def verified_file(profile, server=None): + if not NAME.fullmatch(profile): + raise PublishError('profile 名称无效') + state = ROOT / 'profiles' / profile + aggregate = (state / 'aggregate.json').is_file() + cli = 'aggregate.py' if aggregate else 'node.py' + prefix = output_name(profile, server) + if not aggregate: + config = load_settings(state) + if config.get('CLIENT_CONFIG_ENABLE', 'true') == 'false': + raise PublishError('已停用的 profile 不能发布旧配置') + if server is not None: + raise PublishError('--server 仅适用于汇总 profile') + prefix = config.get('CLIENT_FILE_PREFIX', '').strip() or profile + if not FILE_PREFIX_PATTERN.fullmatch(prefix): + raise PublishError('CLIENT_FILE_PREFIX 无效') + # Compare the exact bytes before and after source validation to detect a + # concurrent render. Publish this snapshot, never re-read after validation. + source = ROOT / 'clash-configs/mihomo' / (prefix + '.yaml') + if source.is_symlink() or not source.is_file(): + raise PublishError('共享 Mihomo 成品不存在或是符号链接') + manifest = source.parent / '.network-node-outputs.json' + owners = json.loads(manifest.read_text()) + if source.name not in owners.get(profile, {}): + raise PublishError('成品归属与当前 profile 不一致;请先 render') + before = source.read_bytes() + run([sys.executable, str(ROOT / cli), 'check', '--profile', profile, '--client', 'mihomo']) + if source.read_bytes() != before: + raise PublishError('校验期间成品改变;重新运行 publish') + if len(before) > 1024 * 1024 or b'\nproxies:\n' not in before or b'\nrules:\n' not in before: + raise PublishError('成品不符合发布限制') + return before + + +def output_name(profile, server=None): + if not NAME.fullmatch(profile) or (server is not None and not NAME.fullmatch(server)): + raise PublishError('profile/server 名称无效') + name = profile if server is None else profile + '-' + server + if not NAME.fullmatch(name): + raise PublishError('订阅名称超过长度限制') + return name + + +def subscription_url(base, profile, token): + if not NAME.fullmatch(profile): + raise PublishError('profile 名称无效') + return f'{base}/mihomo/{profile}.yaml?token={token}' + + +def fetch_subscription(url): + # Do not follow redirects with a bearer URL. A redirect is a deployment error. + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + try: + request = urllib.request.Request(url, headers={'User-Agent': 'clash-verge/2.5.5'}) + with urllib.request.build_opener(NoRedirect).open(request, timeout=35) as response: + return response.status, response.read(), dict(response.headers) + except urllib.error.HTTPError as exc: + code = exc.code + exc.close() + return code, b'', {} + except urllib.error.URLError: + raise PublishError('订阅地址连接失败;URL 与令牌未输出') from None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=('set-token', 'publish', 'verify', 'copy-link', 'import')) + parser.add_argument('--profile', default='routing') + parser.add_argument('--server', help='从主配置派生的单服务器别名') + args = parser.parse_args() + config, base = settings() + account = config['account_id'] + if not re.fullmatch(r'[a-f0-9]{32}', account): + raise PublishError('Cloudflare account_id 无效') + token = keychain_token(account, create=args.action == 'set-token') + if args.action == 'set-token': + digest = hashlib.sha256(token.encode()).hexdigest() + run(['node', str(WRANGLER), 'secret', 'put', 'TOKEN_SHA256'], + input=digest + '\n', text=True, cwd=CLOUD, + env=os.environ | {'WRANGLER_SEND_METRICS': 'false'}) + print('订阅令牌已保存到钥匙串,云端只保存 SHA256 校验值;未输出令牌。') + return + name = output_name(args.profile, args.server) + url = subscription_url(base, name, token) + if args.action == 'copy-link': + run(['pbcopy'], input=url.encode()) + print('私有订阅链接已复制到剪贴板;请在 Verge 中导入 URL。') + return + if args.action == 'import': + link = 'clash-verge://install-config?' + urllib.parse.urlencode({'url': url}) + # Send the private URI via stdin, never a process argument or file. + script = 'ObjC.import("AppKit"); if (!$.NSWorkspace.sharedWorkspace.openURL($.NSURL.URLWithString(' + json.dumps(link) + '))) throw new Error("Verge URL handler unavailable");' + run(['osascript', '-l', 'JavaScript', '-'], input=script, text=True) + print('已向 Clash Verge 发送导入请求;请在客户端检查下载结果。') + return + content = verified_file(args.profile, args.server) + if args.action == 'publish': + namespace = next(item['id'] for item in config['kv_namespaces'] if item['binding'] == 'CONFIGS') + if not re.fullmatch(r'[a-f0-9]{32}', namespace): + raise PublishError('KV namespace id 无效') + key = urllib.parse.quote('mihomo/' + name + '.yaml', safe='') + path = f'accounts/{account}/storage/kv/namespaces/{namespace}/values/{key}' + cf = Cloudflare(auth_token()) + cf.request(path, method='PUT', content=content) + if cf.request(path, raw=True) != content: + raise PublishError('KV 发布读回不一致;未确认端点生效') + print('KV 发布并读回一致;正在核对 HTTPS 端点。') + status, remote, headers = fetch_subscription(url) + if status not in (200, 404): + raise PublishError(f'订阅 HTTP {status};尚未验证线上可用,请排查网络或 Cloudflare 边缘策略') + if status != 200 or remote != content: + raise PublishError('端点尚未返回当前版本(KV 跨区域同步可能延迟);稍后运行 verify,不要重复写入') + for candidate in (base + '/mihomo/' + name + '.yaml', subscription_url(base, name, 'A' * 43)): + if fetch_subscription(candidate)[0] != 404: + raise PublishError('端点未通过未授权访问检查') + print(f'{name}: HTTPS 读回一致,缺少/错误令牌均拒绝;{len(content)} bytes。') + + +if __name__ == '__main__': + try: + main() + except PublishError as exc: + print(str(exc), file=sys.stderr) + sys.exit(1) + except (ValueError, OSError, KeyError, StopIteration, subprocess.SubprocessError): + # Never dump exceptions/tracebacks containing remote URLs, bodies or tokens. + print('操作未完成;请检查登录、钥匙串、源配置是否过期,或等待 KV 同步后运行 verify。', file=sys.stderr) + sys.exit(1) diff --git a/tests/test_aggregate.py b/tests/test_aggregate.py index 6c54750..9a243bd 100644 --- a/tests/test_aggregate.py +++ b/tests/test_aggregate.py @@ -4,6 +4,7 @@ import os import ipaddress import re +import shutil from pathlib import Path import sys import subprocess @@ -13,6 +14,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'core')) import client_aggregate as aggregate +import ordinary_policy +from client_snapshot import import_snapshot try: import yaml @@ -56,10 +59,288 @@ def save_plan(self): self.plan_path.write_text(json.dumps(self.plan)) def generate(self, **kwargs): + self.current_target = kwargs.get('target') or 'stash' return aggregate.generate(self.root, 'routing', **kwargs) - def output(self, device='mac'): - return self.root / 'clash-configs' / f'routing-{device}.yaml' + def output(self, device='mac', target=None): + name = 'routing.yaml' if 'credential_identity' in self.plan else f'routing-{device}.yaml' + return self.root / 'clash-configs' / (target or getattr(self, 'current_target', 'stash')) / name + + def shared_plan(self): + self.plan.pop('devices') + self.plan.pop('client') + self.plan['credential_identity'] = 'mac' + self.save_plan() + + def test_shared_plan_defaults_to_two_clients_without_device_labels(self): + # Given one existing identity, both clients share nodes but not their schema/files. + self.shared_plan() + legacy = self.root / 'clash-configs/routing-mac.yaml' + legacy.parent.mkdir() + legacy.write_text('existing distribution copy') + self.assertEqual(self.generate(), 0) + for target in ('stash', 'mihomo'): + path = self.output(target=target) + self.assertEqual([p.name for p in path.parent.glob('*.yaml')], ['routing.yaml']) + text = path.read_text() + self.assertIn('mac-only', text) + self.assertNotIn('phone-only', text) + self.assertNotIn('device: mac', text) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual(legacy.read_text(), 'existing distribution copy') + self.assertEqual(self.generate(check=True), 0) + self.output(target='mihomo').write_text('drift') + self.assertEqual(self.generate(check=True), 1) + + @unittest.skipUnless(yaml, 'PyYAML required') + def test_standalone_files_cover_main_nodes_and_share_routing(self): + # Given a shared main plan, every server must have an independently usable + # client file with exactly its main-config credentials and all transports. + self.shared_plan() + self.plan['standalone'] = True + self.save_plan() + self.assertEqual(self.generate(), 0) + for target in ('stash', 'mihomo'): + directory = self.root / 'clash-configs' / target + main = yaml.safe_load((directory / 'routing.yaml').read_text()) + self.assertEqual({p.name for p in directory.glob('*.yaml')}, + {'routing.yaml', 'routing-cstone.yaml', 'routing-lax.yaml', 'routing-gcloud.yaml'}) + for alias in ('cstone', 'lax', 'gcloud'): + child = yaml.safe_load((directory / f'routing-{alias}.yaml').read_text()) + self.assertEqual(child['proxies'], [n for n in main['proxies'] if n['name'].startswith(alias + '/')]) + self.assertEqual(child['rules'], main['rules']) + self.assertEqual(child['dns'], main['dns']) + groups = {g['name']: g for g in child['proxy-groups']} + self.assertEqual(groups[aggregate.SENSITIVE]['proxies'], + [alias + ('/CDN' if alias == 'gcloud' else '/Reality')]) + allowed = {n['name'] for n in child['proxies']} | set(groups) | {'DIRECT', 'REJECT'} + for group in groups.values(): + self.assertTrue(group['proxies']) + self.assertTrue(set(group['proxies']) <= allowed) + self.assertEqual(self.generate(check=True), 0) + child = self.root / 'clash-configs/mihomo/routing-lax.yaml' + child.write_text(child.read_text() + '# drift') + self.assertEqual(self.generate(check=True), 1) + + def test_standalone_requires_shared_identity(self): + self.plan['standalone'] = True + self.save_plan() + with self.assertRaisesRegex(ValueError, '共享'): + self.generate() + + def test_one_target_does_not_replace_other_client(self): + self.shared_plan() + self.generate() + other = self.output(target='stash') + other.write_text(other.read_text() + '# local edit\n') + original = other.read_bytes() + self.generate(target='mihomo') + self.assertEqual(other.read_bytes(), original) + + def test_second_client_failure_preserves_both_outputs(self): + self.shared_plan() + self.generate() + originals = {t: self.output(target=t).read_bytes() for t in ('stash', 'mihomo')} + original_collect = aggregate.collect_nodes + def fail_mihomo(root, plan, target): + if target == 'mihomo': + raise ValueError('second client failed') + return original_collect(root, plan, target) + with mock.patch.object(aggregate, 'collect_nodes', side_effect=fail_mihomo): + with self.assertRaisesRegex(ValueError, 'second client failed'): + self.generate() + for target, text in originals.items(): + self.assertEqual(self.output(target=target).read_bytes(), text) + + def test_shared_identity_must_exist_on_sensitive_sources(self): + self.shared_plan() + self.plan['credential_identity'] = 'missing' + self.save_plan() + with self.assertRaisesRegex(ValueError, 'missing 缺少指定'): + self.generate() + self.assertFalse(self.output().exists()) + + def test_publish_stash_only_and_refuse_stale_or_unowned_destination(self): + self.shared_plan() + self.generate() + destination = self.root / 'icloud' + destination.mkdir() + unrelated = destination / 'personal.yaml' + unrelated.write_text('keep me') + self.assertEqual(aggregate.publish_stash(self.root, 'routing', destination), 0) + published = destination / 'routing.yaml' + self.assertEqual(published.read_bytes(), self.output(target='stash').read_bytes()) + self.assertEqual(published.stat().st_mode & 0o777, 0o600) + self.assertEqual(unrelated.read_text(), 'keep me') + self.assertEqual(len(list(destination.glob('routing*.yaml'))), 1) + self.output(target='stash').write_text('stale') + original = published.read_bytes() + with self.assertRaisesRegex(ValueError, '过期'): + aggregate.publish_stash(self.root, 'routing', destination) + self.assertEqual(published.read_bytes(), original) + self.generate() + published.write_text('someone else') + with self.assertRaisesRegex(ValueError, '归属'): + aggregate.publish_stash(self.root, 'routing', destination) + self.assertEqual(published.read_text(), 'someone else') + + def test_publish_refuses_symlink_destination(self): + self.shared_plan() + self.generate() + destination = self.root / 'icloud' + destination.mkdir() + victim = self.root / 'victim' + victim.write_text('do not replace') + (destination / 'routing.yaml').symlink_to(victim) + with self.assertRaisesRegex(ValueError, '符号链接'): + aggregate.publish_stash(self.root, 'routing', destination) + self.assertEqual(victim.read_text(), 'do not replace') + + def test_publication_uses_verified_bytes_not_a_second_read(self): + self.shared_plan() + self.generate() + source = self.output(target='stash') + verified = source.read_bytes() + destination = self.root / 'icloud' + destination.mkdir() + original_write = aggregate.write_outputs + def concurrent_edit(*args, **kwargs): + result = original_write(*args, **kwargs) + source.write_text('changed after validation') + return result + with mock.patch.object(aggregate, 'write_outputs', side_effect=concurrent_edit): + aggregate.publish_stash(self.root, 'routing', destination) + self.assertEqual((destination / 'routing.yaml').read_bytes(), verified) + + @unittest.skipUnless(yaml, 'PyYAML required') + def test_imported_snapshot_preserves_node_identity_and_uses_new_rules(self): + self.generate(target='stash') + source = self.output() + original = yaml.safe_load(source.read_text()) + self.assertEqual(import_snapshot(self.root, 'imported', source), 0) + self.assertEqual(aggregate.generate(self.root, 'imported'), 0) + for target in ('stash', 'mihomo'): + data = yaml.safe_load((self.root / 'clash-configs' / target / 'imported.yaml').read_text()) + self.assertEqual([n['name'] for n in data['proxies']], [n['name'] for n in original['proxies']]) + for before, after in zip(original['proxies'], data['proxies']): + for key in ('server', 'port', 'uuid', 'reality-opts', 'ws-opts'): + self.assertEqual(after.get(key), before.get(key)) + if before['type'] == 'hysteria2': + self.assertEqual(after['auth' if target == 'stash' else 'password'], before['auth']) + elif before['type'] == 'anytls': + self.assertEqual(after['password'], before['password']) + self.assertEqual(data['rules'][-1], f'MATCH,{aggregate.SENSITIVE}') + self.assertEqual((self.root / 'profiles/imported/nodes.json').stat().st_mode & 0o777, 0o600) + with self.assertRaisesRegex(ValueError, '已存在'): + import_snapshot(self.root, 'imported', source) + + @unittest.skipUnless(yaml, 'PyYAML required') + def test_snapshot_rejects_udp_unsupported_sensitive_exit_without_exposing_credentials(self): + self.generate(target='stash') + data = yaml.safe_load(self.output().read_text()) + data['proxies'][0]['udp'] = False + source = self.root / 'unsupported.yaml' + source.write_text(yaml.safe_dump(data)) + with self.assertRaisesRegex(ValueError, '不符合导入契约') as error: + import_snapshot(self.root, 'imported', source) + self.assertNotIn('mac-only', str(error.exception)) + self.assertFalse((self.root / 'profiles/imported').exists()) + + @unittest.skipUnless(yaml, 'PyYAML required') + def test_only_explicit_ordinary_services_can_use_auto(self): + # Given overlapping service categories, sensitive/CN rules win and + # unknown services must never inherit a broad overseas AUTO default. + self.shared_plan() + self.generate() + for target in ('stash', 'mihomo'): + data = yaml.safe_load(self.output(target=target).read_text()) + rules = data['rules'] + auto_rules = [r for r in rules if len(r.split(',')) >= 3 + and r.split(',')[2] == aggregate.OVERSEAS] + self.assertTrue(auto_rules) + self.assertTrue(all(r.startswith(('DOMAIN,', 'DOMAIN-SUFFIX,', 'IP-CIDR,', 'IP-CIDR6,', 'RULE-SET,ordinary-')) + for r in auto_rules)) + for host in ('youtube.com', 'googlevideo.com', 'netflix.com', 'telegram.org', + 'interactivebrokers.com', 'ibkr.com', 'tradingview.com', 'spotify.com', + 'twitch.tv', 'reddit.com', 'wikipedia.org'): + self.assertIn(f'DOMAIN-SUFFIX,{host},{aggregate.OVERSEAS}', rules) + first_auto = min(rules.index(r) for r in auto_rules) + for protected in (f'DOMAIN-SUFFIX,openai.com,{aggregate.AI}', + f'DOMAIN,accounts.google.com,{aggregate.AI}', + f'RULE-SET,ai,{aggregate.AI}', f'RULE-SET,cn,{aggregate.CN}', + f'RULE-SET,ads-lite,{aggregate.ADS}'): + self.assertLess(rules.index(protected), first_auto) + for host in ('google.com', 'googleapis.com', 'cloudfront.net', 'amazonaws.com', + 'cloudflare.com', 'googleusercontent.com'): + self.assertNotIn(f'DOMAIN-SUFFIX,{host},{aggregate.OVERSEAS}', rules) + self.assertEqual(rules[-1], f'MATCH,{aggregate.SENSITIVE}') + self.assertNotIn('google', data['rule-providers']) + # Default DNS stays sensitive even for an unrecognized AI dependency. + self.assertIn(f'IP-CIDR,9.9.9.9/32,{aggregate.SENSITIVE},no-resolve', rules) + + def test_unsafe_ordinary_catalog_fails_before_replacing_outputs(self): + self.shared_plan() + self.generate() + originals = {t: self.output(target=t).read_bytes() for t in ('stash', 'mihomo')} + catalog = self.root / 'ordinary.json' + for suffix, message in (('openai.com', '敏感域名重叠'), ('googleapis.com', '共享云'), + ('tiktokshop.com', '敏感域名重叠'), ('applovin.com', '敏感域名重叠')): + catalog.write_text(json.dumps({'test': {'domains': [], 'suffixes': [suffix], 'ip_cidrs': []}})) + with mock.patch.object(ordinary_policy, 'DATA', catalog): + with self.assertRaisesRegex(ValueError, message): + self.generate() + for target, content in originals.items(): + self.assertEqual(self.output(target=target).read_bytes(), content) + + @unittest.skipUnless(yaml, 'PyYAML required') + def test_expanded_catalog_preserves_ai_meta_and_commerce_boundary(self): + # Given reviewed public service lists, media/games/news/learning/downloads + # may use AUTO, including the user's Threads exception. AI, Meta business + # and advertising/commerce must beat CN and AUTO. + self.shared_plan() + self.generate() + for target in ('stash', 'mihomo'): + rules = yaml.safe_load(self.output(target=target).read_text())['rules'] + data = yaml.safe_load(self.output(target=target).read_text()) + for service in ('disney', 'steam', 'discord', 'reuters', 'coursera', 'python', + 'docker', 'rust', 'threads'): + name = 'ordinary-' + service + self.assertIn(f'RULE-SET,{name},{aggregate.OVERSEAS}', rules) + provider = data['rule-providers'][name] + self.assertEqual(provider['behavior'], 'domain') + self.assertRegex(provider['url'], rf'/[a-f0-9]{{40}}/geo/geosite/{service}[.]mrs$') + self.assertEqual(provider.get('proxy'), aggregate.SENSITIVE if target == 'mihomo' else None) + # Main routing remains a short, ordered policy, not a domain dump. + self.assertLess(len(rules), 450) + first_auto = next(i for i, r in enumerate(rules) if aggregate.OVERSEAS in r) + cn_index = rules.index(f'RULE-SET,cn,{aggregate.CN}') + for domain in ('facebook.com', 'tiktokshop.com', 'tiktokglobalshop.com', + 'tiktok.com', 'applovin.com', 'ads.google.com', 'line.biz', 'brightline.tv'): + matches = [i for i, r in enumerate(rules) + if r in (f'DOMAIN-SUFFIX,{domain},{aggregate.AI}', + f'DOMAIN-SUFFIX,{domain},{aggregate.SENSITIVE}')] + self.assertTrue(matches, domain) + self.assertLess(min(matches), min(first_auto, cn_index)) + for domain in ('x.com', 'twitter.com', 'github.com', 'notion.so', 'google.com', + 'microsoft.com', 'openai.com', 'facebook.com', 'tiktok.com'): + self.assertNotIn(f'DOMAIN-SUFFIX,{domain},{aggregate.OVERSEAS}', rules) + self.assertEqual(rules[-1], f'MATCH,{aggregate.SENSITIVE}') + + def test_mutable_or_broad_ordinary_rulesets_cannot_replace_outputs(self): + # An unreviewed moving branch or all-overseas category must not silently + # expand AUTO's scope; existing generated files survive validation failure. + self.shared_plan() + self.generate() + before = {t: self.output(target=t).read_bytes() for t in ('stash', 'mihomo')} + path = self.root / 'rulesets.json' + for revision, services in (('meta', ['steam']), ('a' * 40, ['category-dev']), + ('a' * 40, ['google']), ('a' * 40, ['steam', 'steam'])): + path.write_text(json.dumps({'revision': revision, 'categories': {'test': services}})) + with mock.patch.object(ordinary_policy, 'RULESETS', path): + with self.assertRaises(ValueError): + self.generate() + for target, text in before.items(): + self.assertEqual(self.output(target=target).read_bytes(), text) @unittest.skipUnless(yaml, 'PyYAML required') def test_ai_core_domains_have_matching_sensitive_dns(self): @@ -84,7 +365,13 @@ def sensitive(host): 'accounts.google.com', 'oauth2.googleapis.com', 'www.googleapis.com', 'antigravity-unleash.goog', 'us-central1-aiplatform.googleapis.com', 'global-aiplatform.googleapis.com', 'api.dev.runwayml.com', - 'api.replicate.com', 'fal.run', 'v0.app', 'api.githubcopilot.com') + 'api.replicate.com', 'fal.run', 'v0.app', 'api.githubcopilot.com', + 'platform.claude.com', 'mcp-proxy.anthropic.com', 'downloads.claude.ai', + 'bridge.claudeusercontent.com', 'a.frame.claudeusercontent.com', + 'assets-proxy.anthropic.com', 'a.livepreview.claude.app', + 'http-intake.logs.us5.datadoghq.com', 'cdnjs.cloudflare.com', + 'cdn.jsdelivr.net', 'cdn.tailwindcss.com', 'code.jquery.com', + 'unpkg.com', 'fonts.googleapis.com', 'fonts.gstatic.com') dns = data['dns']['nameserver-policy'] for host in required: with self.subTest(host=host): @@ -209,6 +496,11 @@ def test_mihomo_engine_accepts_bundle(self): if not binary or not Path(binary).is_file(): self.skipTest('MIHOMO_BIN is not available') self.generate(target='mihomo') + if os.environ.get('MIHOMO_DATA_DIR'): + for name in ('geoip.dat', 'geosite.dat', 'ASN.mmdb', 'Country.mmdb'): + source = Path(os.environ['MIHOMO_DATA_DIR']) / name + if source.is_file(): + shutil.copyfile(source, self.root / name) result = subprocess.run([binary, '-t', '-d', str(self.root), '-f', str(self.output())], capture_output=True, text=True, timeout=180) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) @@ -228,8 +520,13 @@ def test_routing_graph_dns_defaults_and_client_schema(self): self.assertFalse(groups[aggregate.SENSITIVE]['lazy']) self.assertEqual(groups[aggregate.AUTO]['type'], 'url-test') self.assertEqual(groups[aggregate.AUTO]['interval'], 300) - self.assertEqual(groups[aggregate.AI]['proxies'], [aggregate.SENSITIVE, aggregate.MANUAL]) - self.assertEqual(groups[aggregate.OVERSEAS]['proxies'], [aggregate.AUTO, aggregate.MANUAL]) + self.assertEqual(set(groups[aggregate.AUTO]['proxies']), set(proxies)) + self.assertNotIn('DIRECT', groups[aggregate.AUTO]['proxies']) + if target == 'mihomo': + self.assertEqual(groups[aggregate.AUTO]['tolerance'], 50) + self.assertEqual(groups[aggregate.AI]['proxies'], [aggregate.SENSITIVE]) + self.assertEqual(groups[aggregate.OVERSEAS]['proxies'], [aggregate.AUTO, aggregate.SENSITIVE, aggregate.MANUAL]) + self.assertEqual(groups[aggregate.APPLE]['proxies'][0], aggregate.SENSITIVE) self.assertEqual(groups[aggregate.CN]['proxies'][0], 'DIRECT') self.assertEqual(groups[aggregate.ADS]['proxies'][0], 'REJECT') self.assertEqual(set(groups[aggregate.MANUAL]['proxies']), set(proxies)) @@ -249,9 +546,16 @@ def visit(name, stack): self.assertIn(fields[1 if fields[0] == 'MATCH' else 2], allowed) if fields[0] == 'RULE-SET': self.assertIn(fields[1], data['rule-providers']) - self.assertEqual(rules[-1], f'MATCH,{aggregate.OVERSEAS}') - self.assertIn('DOMAIN-KEYWORD,spotify,DIRECT', rules) - self.assertIn('DOMAIN-SUFFIX,scdn.co,DIRECT', rules) + self.assertEqual(rules[-1], f'MATCH,{aggregate.SENSITIVE}') + self.assertNotIn('DOMAIN-KEYWORD,spotify,DIRECT', rules) + self.assertNotIn('DOMAIN-SUFFIX,scdn.co,DIRECT', rules) + self.assertNotIn(f'DOMAIN-SUFFIX,cn,{aggregate.CN}', rules) + self.assertNotIn(f'GEOIP,CN,{aggregate.CN},no-resolve', rules) + self.assertNotIn(f'RULE-SET,cn-ip,{aggregate.CN},no-resolve', rules) + self.assertIn(f'RULE-SET,apple-cn,{aggregate.CN}', rules) + self.assertLess(rules.index(f'RULE-SET,apple-cn,{aggregate.CN}'), + rules.index(f'RULE-SET,icloud,{aggregate.APPLE}')) + self.assertFalse(any(r.startswith(('NETWORK,', 'PROTOCOL,')) for r in rules)) self.assertFalse(any('Spotify' in name for name in groups)) meta_rule = f'DOMAIN-SUFFIX,facebook.com,{aggregate.AI}' self.assertLess(rules.index(meta_rule), rules.index(f'RULE-SET,ads-lite,{aggregate.ADS}')) @@ -274,6 +578,13 @@ def visit(name, stack): self.assertEqual('password' in proxy, target == 'mihomo') self.assertEqual('follow-rule' in dns, target == 'stash') self.assertEqual('tun' in data, target == 'mihomo') + if target == 'mihomo': + exclusions = data['tun']['route-exclude-address'] + for ip in ('127.0.0.1', '10.0.0.1', '192.168.1.1', '100.64.0.1'): + self.assertTrue(any(ipaddress.ip_address(ip) in ipaddress.ip_network(net) + for net in exclusions if ':' not in net)) + self.assertFalse(any(ipaddress.ip_address('8.8.8.8') in ipaddress.ip_network(net) + for net in exclusions if ':' not in net)) if target == 'stash': self.assertNotIn('url', groups[aggregate.SENSITIVE]) diff --git a/tests/test_aggregate_runtime.py b/tests/test_aggregate_runtime.py new file mode 100644 index 0000000..21799ff --- /dev/null +++ b/tests/test_aggregate_runtime.py @@ -0,0 +1,253 @@ +"""Real Mihomo routing against loopback origins; no TUN/system proxy changes. + +Acceptance: sensitive domains beat ordinary/CN membership, CN and LAN go +DIRECT; only explicit ordinary services use AUTO; unknown domains stay sensitive. +External nodes and downloaded rule sets are replaced by deterministic fixtures. +""" +import http.client +import http.server +import json +import os +from pathlib import Path +import selectors +import shutil +import socket +import socketserver +import subprocess +import threading +import time +import unittest + +import test_aggregate as fixtures + + +class AggregateRuntimeTest(unittest.TestCase): + maxDiff = None + def test_real_engine_routes_requests_and_detects_wrong_fallback(self): + binary = os.environ.get('MIHOMO_BIN') + assets = os.environ.get('MIHOMO_DATA_DIR') + if not binary or not assets or not fixtures.yaml: + self.skipTest('Requires MIHOMO_BIN, MIHOMO_DATA_DIR and PyYAML') + fixture = fixtures.AggregateTest() + fixture.setUp() + self.addCleanup(fixture.doCleanups) + fixture.shared_plan() + fixture.generate(target='mihomo') + root = fixture.root + data = fixtures.yaml.safe_load(fixture.output().read_text()) + for name in ('geoip.dat', 'geosite.dat', 'ASN.mmdb', 'Country.mmdb'): + shutil.copyfile(Path(assets) / name, root / name) + + def server(marker): + class Handler(http.server.BaseHTTPRequestHandler): + def handle(self): + try: + super().handle() + except (BrokenPipeError, ConnectionResetError): + # Mihomo cancels concurrent health probes on selection + # and shutdown. Foreground responses remain asserted. + pass + + def do_CONNECT(self): + self.send_response(200) + self.end_headers() + self.wfile.flush() + self.raw_requestline = self.rfile.readline(65537) + if self.raw_requestline and self.parse_request(): + self.do_GET() + + def do_GET(self): + body = marker.encode() + status = 503 if self.path == '/auto-health' and marker == 'PRIMARY' else 200 + self.send_response(404 if self.path == '/missing-rules' else status) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + instance = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + threading.Thread(target=instance.serve_forever, daemon=True).start() + self.addCleanup(instance.server_close) + self.addCleanup(instance.shutdown) + return instance.server_port + + direct_port, primary_port, backup_port = [server(s) for s in ('DIRECT', 'PRIMARY', 'BACKUP')] + for group in data['proxy-groups']: + if group['type'] == 'fallback': + group['url'] = f'http://127.0.0.1:{direct_port}/health' + elif group['type'] == 'url-test': + group['url'] = f'http://127.0.0.1:{direct_port}/auto-health' + group['expected-status'] = '200' + for node in data['proxies']: + name = node['name'] + node.clear() + node.update(name=name, type='http', server='127.0.0.1', + port=primary_port if name.startswith('cstone/') else backup_port) + for name, provider in data['rule-providers'].items(): + if name.startswith('ordinary-'): + service = name.removeprefix('ordinary-') + cache = os.environ.get('ORDINARY_RULESET_DIR') + if cache: + # Final acceptance uses the complete pinned upstream data, + # rather than mocking which domains a service contains. + source = Path(cache) / (service + '.mrs') + rule_format = 'mrs' + if not source.exists(): + source = Path(cache) / (service + '.list') + rule_format = 'text' + self.assertTrue(source.is_file(), str(source)) + destination = root / source.name + shutil.copyfile(source, destination) + provider.clear() + provider.update(type='file', behavior='domain', format=rule_format, path=str(destination)) + continue + # Offline fixture for the routing boundary; upstream content is + # independently verified with ORDINARY_RULESET_DIR in acceptance. + domains = {'steam': ['steampowered.com'], 'disney': ['disneyplus.com'], + 'reuters': ['reuters.com'], 'python': ['pypi.org'], + 'coursera': ['coursera.org'], 'threads': ['threads.net', 'threads.com'], + 'discord': ['discord.com']}.get(service, ['unused.invalid']) + path = root / f'{name}.yaml' + path.write_text(fixtures.yaml.safe_dump({'payload': ['+.' + d for d in domains]})) + provider.clear() + provider.update(type='file', behavior='domain', format='yaml', path=str(path)) + continue + domains = {'cn': ['baidu.com', 'openai.com', 'reddit.com', 'tiktokshop.com'], + 'ai': ['openai.com', 'netflix.com'], + 'apple-cn': ['apple.cn'], 'ads-lite': ['ads.test']}.get(name, ['unused.invalid']) + path = root / f'{name}.yaml' + path.write_text(fixtures.yaml.safe_dump({'payload': ['DOMAIN-SUFFIX,' + d for d in domains]})) + provider.clear() + provider.update(type='file', behavior='classical', format='yaml', path=str(path)) + # DNS/origin connectivity is outside this boundary. NXDOMAIN makes + # DIRECT attempts observable without contacting any public origin. + class DNS(socketserver.BaseRequestHandler): + def handle(self): + packet, sock = self.request + response = packet[:2] + b'\x81\x83' + packet[4:6] + b'\x00\x00' * 3 + packet[12:] + sock.sendto(response, self.client_address) + dns = socketserver.ThreadingUDPServer(('127.0.0.1', 0), DNS) + threading.Thread(target=dns.serve_forever, daemon=True).start() + self.addCleanup(dns.server_close) + self.addCleanup(dns.shutdown) + resolver = f'127.0.0.1:{dns.server_address[1]}' + ordinary = ['youtube.com', 'nflxvideo.net', 'telegram.org', 'ibkr.com', 'interactivebrokers.com', + 'tradingview.com', 'spotify.com', 'twitch.tv', 'wikipedia.org', + 'steampowered.com', 'disneyplus.com', 'reuters.com', 'pypi.org', + 'coursera.org', 'threads.net', 'threads.com', 'discord.com'] + domains = ['baidu.com', 'openai.com', 'unknown.example', 'unlisted.cn', 'apple.cn', + 'netflix.com', 'reddit.com', 'accounts.google.com', 'gemini.google.com', + 'unknown-ai.googleapis.com', 'youtube.com.attacker.example', 'notyoutube.com', + 'ads.google.com', 'business.facebook.com', 'seller.tiktokshop.com', + 'seller.tiktokglobalshop.com', 'ads.tiktok.com', 'x.com', 'grok.com', + 'manager.line.biz', 'brightline.tv', 'future-service.bbc', + 'api.anthropic.com', 'platform.claude.com', 'bridge.claudeusercontent.com', + 'assets-proxy.anthropic.com', 'a.livepreview.claude.app', + 'http-intake.logs.us5.datadoghq.com', 'cdnjs.cloudflare.com', + 'cdn.jsdelivr.net', 'cdn.tailwindcss.com', 'code.jquery.com', + 'unpkg.com', 'fonts.googleapis.com', 'fonts.gstatic.com'] + ordinary + data['dns']['use-system-hosts'] = False + data['dns']['listen'] = '127.0.0.1:0' + for key in ('default-nameserver', 'nameserver', 'proxy-server-nameserver'): + data['dns'][key] = [resolver] + data['dns']['nameserver-policy'] = {key: [resolver] for key in data['dns']['nameserver-policy']} + data['tun']['enable'] = False + data['allow-lan'] = False + data['log-level'] = 'info' + data['profile'] = {'store-selected': False} + data.pop('external-controller', None) + + runtime_logs = [] + def exercise(config): + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + port = sock.getsockname()[1] + with socket.socket() as control: + control.bind(('127.0.0.1', 0)) + control_port = control.getsockname()[1] + config['mixed-port'] = port + config['external-controller'] = f'127.0.0.1:{control_port}' + path = root / 'runtime.yaml' + path.write_text(fixtures.yaml.safe_dump(config, allow_unicode=True, sort_keys=False)) + process = subprocess.Popen([binary, '-d', str(root), '-f', str(path)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + try: + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + deadline = time.monotonic() + 20 + logs = b'' + while b'Mixed(http+socks) proxy listening' not in logs: + remaining = deadline - time.monotonic() + self.assertGreater(remaining, 0, logs.decode(errors='replace')) + self.assertIsNone(process.poll(), logs.decode(errors='replace')) + if selector.select(remaining): + logs += os.read(process.stdout.fileno(), 65536) + def request(host): + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=5) + try: + connection.request('GET', f'http://{host}:{direct_port}/probe') + response = connection.getresponse() + return response.status, response.read().decode() + finally: + connection.close() + # Listener creation precedes tunnel/provider readiness. Wait on + # the observed local response, never a fixed startup sleep. + deadline = time.monotonic() + 10 + while request('127.0.0.1') != (200, 'DIRECT'): + self.assertLess(time.monotonic(), deadline, 'Mihomo readiness timed out') + # The ordinary pool has healthy BACKUP exits; PRIMARY rejects + # only the AUTO probe while remaining healthy for sensitive fallback. + deadline = time.monotonic() + 10 + while request('youtube.com') != (200, 'BACKUP'): + self.assertLess(time.monotonic(), deadline, 'AUTO health selection timed out') + results = {host: request(host) for host in ['127.0.0.1'] + domains} + connection = http.client.HTTPConnection('127.0.0.1', control_port, timeout=5) + try: + connection.request('GET', '/providers/rules') + response = connection.getresponse() + self.assertEqual(response.status, 200) + registry = json.loads(response.read())['providers'] + finally: + connection.close() + for name, provider in config['rule-providers'].items(): + if name.startswith('ordinary-') and provider['type'] == 'file': + self.assertGreater(registry[name]['ruleCount'], 0, name) + return results + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + runtime_logs.append((logs + process.stdout.read()).decode(errors='replace')) + process.stdout.close() + + result = exercise(data) + expected = {host: (200, 'PRIMARY') for host in domains} + expected.update({'127.0.0.1': (200, 'DIRECT'), 'baidu.com': (502, ''), + 'apple.cn': (502, ''), 'reddit.com': (502, '')}) + expected.update({host: (200, 'BACKUP') for host in ordinary}) + self.assertEqual(result, expected, '\n'.join(runtime_logs)) + self.assertIn('dial 🇨🇳 国内流量 (match RuleSet/cn)', runtime_logs[0]) + self.assertIn('dial 🇨🇳 国内流量 (match RuleSet/apple-cn)', runtime_logs[0]) + # Real HTTP 404 with no provider cache must leave this ordinary service + # on the sensitive fallback, never DIRECT or an unrelated AUTO category. + original = data['rule-providers']['ordinary-steam'] + data['rule-providers']['ordinary-steam'] = dict( + type='http', behavior='domain', format='text', interval=86400, + path=str(root / 'uncached-steam.list'), proxy='DIRECT', + url=f'http://127.0.0.1:{direct_port}/missing-rules') + unavailable = exercise(data) + self.assertEqual(unavailable['steampowered.com'], (200, 'PRIMARY'), '\n'.join(runtime_logs)) + data['rule-providers']['ordinary-steam'] = original + # Negative control: reverting the privacy fallback must be detectable. + data['rules'][-1] = 'MATCH,DIRECT' + wrong = exercise(data) + self.assertEqual(wrong['unknown.example'], (502, '')) + self.assertNotEqual(wrong['unknown.example'], expected['unknown.example']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_deploy_output.py b/tests/test_deploy_output.py index aaa62ed..96a6679 100644 --- a/tests/test_deploy_output.py +++ b/tests/test_deploy_output.py @@ -222,9 +222,9 @@ def test_vps_profile_is_passed_to_secret_generation_subprocess(self): printf '%s\\n' \"$output\" test -f \"$STATE_DIR/.secrets.env\" test ! -e \"$PROJECT_DIR/profiles/gcloud/.secrets.env\" - test -f \"$PROJECT_DIR/clash-configs/frantech-client-mac.yaml\" - test -f \"$PROJECT_DIR/clash-configs/frantech-client-iphone.yaml\" - grep -F '配置文件 : {root}/clash-configs/frantech-client-*.yaml' <<<\"$output\" >/dev/null + test -f \"$PROJECT_DIR/clash-configs/stash/frantech-client.yaml\" + test -f \"$PROJECT_DIR/clash-configs/mihomo/frantech-client.yaml\" + grep -F 'Stash 配置: {root}/clash-configs/stash/frantech-client.yaml' <<<\"$output\" >/dev/null """ ) result = subprocess.run( @@ -443,7 +443,7 @@ def test_cdn_setup_runs_with_active_profile_before_host_changes(self): run_deploy >/dev/null test -f "$STATE_DIR/cf-profile" test -f "$STATE_DIR/cdn-probed" - test -f "$PROJECT_DIR/clash-configs/cdn-test-mac.yaml" + test -f "$PROJECT_DIR/clash-configs/stash/cdn-test.yaml" """ ) result = subprocess.run( diff --git a/tests/test_gen_clash.py b/tests/test_gen_clash.py index 47e95d0..c699986 100644 --- a/tests/test_gen_clash.py +++ b/tests/test_gen_clash.py @@ -12,7 +12,7 @@ class GenerateClashConfigTest(unittest.TestCase): - def test_generates_one_config_per_device_from_shared_core(self): + def test_generates_both_clients_with_one_shared_identity(self): with tempfile.TemporaryDirectory() as tmp: root = pathlib.Path(tmp) (root / "deploy.conf").write_text( @@ -61,10 +61,10 @@ def test_generates_one_config_per_device_from_shared_core(self): ) self.assertEqual(result.returncode, 0, result.stderr) - outputs = sorted((root / "clash-configs").glob("*.yaml")) - self.assertEqual([path.name for path in outputs], ["test-mac.yaml", "test-phone.yaml"]) + outputs = sorted((root / "clash-configs").rglob("*.yaml")) + self.assertEqual([str(path.relative_to(root / "clash-configs")) for path in outputs], ["mihomo/test.yaml", "stash/test.yaml"]) - mac_path = root / "clash-configs" / "test-mac.yaml" + mac_path = root / "clash-configs" / "mihomo" / "test.yaml" mac = mac_path.read_text() self.assertEqual(mac_path.stat().st_mode & 0o777, 0o600) self.assertIn("server: 203.0.113.10", mac) @@ -264,7 +264,7 @@ def test_cdn_only_config_omits_direct_ip_nodes(self): check=False, ) self.assertEqual(result.returncode, 0, result.stderr) - config = (root / "clash-configs" / "test-mac.yaml").read_text() + config = (root / "clash-configs" / "mihomo" / "test.yaml").read_text() self.assertIn('name: "US-CDN"', config) self.assertNotIn('name: "US-Reality"', config) self.assertNotIn('name: "US-HY2"', config) @@ -307,7 +307,7 @@ def test_ai_privacy_fallback_prefers_reality_then_cdn(self): check=False, ) self.assertEqual(result.returncode, 0, result.stderr) - config = (root / "clash-configs" / "test-mac.yaml").read_text() + config = (root / "clash-configs" / "mihomo" / "test.yaml").read_text() ai_group = config.split('name: "🤖 AI 隐私出口"', 1)[1].split( '\n - name:', 1 )[0] @@ -347,7 +347,7 @@ def test_warp_reality_node_is_manual_only(self): check=False, ) self.assertEqual(result.returncode, 0, result.stderr) - config = (root / "clash-configs" / "test-mac.yaml").read_text() + config = (root / "clash-configs" / "mihomo" / "test.yaml").read_text() self.assertIn('name: "US-Reality"', config) self.assertIn('name: "US-Reality-WARP"', config) self.assertIn("port: 42000", config) @@ -417,14 +417,14 @@ def test_hysteria_optional_obfuscation_and_port_hopping_are_rendered(self): env["NETWORK_NODE_STATE_DIR"] = str(root) env["NETWORK_NODE_PROFILE"] = "test" result = subprocess.run( - [sys.executable, str(GENERATOR)], + [sys.executable, str(GENERATOR), "--client", "mihomo"], env=env, text=True, capture_output=True, check=False, ) self.assertEqual(result.returncode, 0, result.stderr) - config = (root / "clash-configs" / "test-mac.yaml").read_text() + config = (root / "clash-configs" / "mihomo" / "test.yaml").read_text() self.assertIn("server: 203.0.113.10", config) self.assertIn("ports: 30000-30010", config) self.assertIn("obfs: salamander", config) @@ -463,7 +463,7 @@ def test_only_replaces_yaml_for_the_active_profile(self): self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue(unrelated.exists()) self.assertTrue(stale.exists(), "Unowned legacy outputs must be preserved") - self.assertTrue((clients / "dmit-mac.yaml").exists()) + self.assertTrue((clients / "stash" / "dmit.yaml").exists()) def test_custom_client_file_prefix_does_not_change_profile_state(self): with tempfile.TemporaryDirectory() as tmp: @@ -496,9 +496,9 @@ def test_custom_client_file_prefix_does_not_change_profile_state(self): self.assertEqual(result.returncode, 0, result.stderr) self.assertTrue(stale.exists(), "Unowned legacy outputs must be preserved") self.assertTrue(unrelated.exists()) - self.assertTrue((clients / "cstonecloud-mac.yaml").exists()) + self.assertTrue((clients / "stash" / "cstonecloud.yaml").exists()) - def test_missing_later_device_credentials_preserve_existing_profile_yaml(self): + def test_missing_selected_identity_credentials_preserve_existing_profile_yaml(self): with tempfile.TemporaryDirectory() as tmp: root = pathlib.Path(tmp) clients = root / "clash-configs" @@ -524,7 +524,7 @@ def test_missing_later_device_credentials_preserve_existing_profile_yaml(self): env["NETWORK_NODE_PROFILE"] = "test" result = subprocess.run( - [sys.executable, str(GENERATOR)], env=env, text=True, + [sys.executable, str(GENERATOR), "--identity", "phone"], env=env, text=True, capture_output=True, check=False, ) diff --git a/tests/test_mihomo_config.py b/tests/test_mihomo_config.py index 571f5f6..da510d4 100644 --- a/tests/test_mihomo_config.py +++ b/tests/test_mihomo_config.py @@ -2,6 +2,7 @@ import os import pathlib import subprocess +import shutil import tempfile import unittest @@ -56,7 +57,7 @@ def check_config(self, conf="", secrets=""): env["NETWORK_NODE_STATE_DIR"] = str(root) env["NETWORK_NODE_PROFILE"] = "test" generated = subprocess.run( - [os.environ.get("PYTHON", "python3"), str(PROJECT_ROOT / "core" / "gen-clash.py")], + [os.environ.get("PYTHON", "python3"), str(PROJECT_ROOT / "core" / "gen-clash.py"), "--client", "mihomo"], env=env, text=True, capture_output=True, @@ -64,7 +65,12 @@ def check_config(self, conf="", secrets=""): ) self.assertEqual(generated.returncode, 0, generated.stderr) - config = root / "clash-configs" / "test-mac.yaml" + config = root / "clash-configs" / "mihomo" / "test.yaml" + if os.environ.get('MIHOMO_DATA_DIR'): + for name in ('geoip.dat', 'geosite.dat', 'ASN.mmdb', 'Country.mmdb'): + source = pathlib.Path(os.environ['MIHOMO_DATA_DIR']) / name + if source.is_file(): + shutil.copyfile(source, root / name) parsed = subprocess.run( [mihomo, "-t", "-d", tmp, "-f", str(config)], text=True, diff --git a/tests/test_safety_regressions.py b/tests/test_safety_regressions.py index 22625ef..8fbbea7 100644 --- a/tests/test_safety_regressions.py +++ b/tests/test_safety_regressions.py @@ -35,6 +35,7 @@ def setUp(self): "PROFILE_NAME": "alpha"} def generate(self, *args, success=True): + self.client = args[args.index("--client") + 1] if "--client" in args else "stash" result = subprocess.run([sys.executable, str(ROOT / "core/gen-clash.py"), *args], env=self.env, capture_output=True, text=True) if success: @@ -50,10 +51,19 @@ def run_secrets(self): self.assertEqual(result.returncode, 0, result.stderr) return result + def test_deployment_checks_both_clients_before_external_writes(self): + # A legacy Mihomo-only interval must not pass deployment preflight when + # deployment now publishes both clients after changing the server. + config = {'CLIENT_TARGET': 'mihomo', 'HY2_HOP_INTERVAL': '15-30'} + validate(config) # Explicit Mihomo rendering still supports this form. + with self.assertRaisesRegex(ValueError, '整数秒'): + validate(config, deployment=True) + validate(config | {'HY2_HOP_INTERVAL': '30'}, deployment=True) + def test_strict_public_traffic_cannot_select_another_exit(self): self.add_conf("AI_STRICT_MODE=true\n") self.generate() - text = (self.output / "alpha-mac.yaml").read_text() + text = (self.output / self.client / "alpha.yaml").read_text() groups = text.split("\nproxy-groups:\n")[1].split("\nrule-providers:\n")[0] self.assertEqual(groups.count(" - name:"), 2) self.assertNotIn("- DIRECT", groups) @@ -81,7 +91,7 @@ def test_fallback_checks_are_active_and_stash_benchmarks_are_proxy_scoped(self): with self.subTest(client=client, strict=strict): self.add_conf('AI_STRICT_MODE=' + strict + '\n') self.generate('--client', client) - text = (self.output / 'alpha-mac.yaml').read_text() + text = (self.output / self.client / "alpha.yaml").read_text() groups = text.split('\nproxy-groups:\n', 1)[1].split('\nrule-providers:\n', 1)[0] blocks = groups.split(' - name:') for block in blocks: @@ -99,7 +109,7 @@ def test_default_routes_domestic_direct_without_weakening_ai_priority(self): for client in ("stash", "mihomo"): with self.subTest(client=client): self.generate("--client", client) - config = (self.output / "alpha-mac.yaml").read_text() + config = (self.output / self.client / "alpha.yaml").read_text() cn_group = config.split('name: "🇨🇳 国内流量"', 1)[1].split('name: "🛑 屏蔽流量"', 1)[0] self.assertLess(cn_group.index("- DIRECT"), cn_group.index('- "🌐 代理流量"')) rules = config.split("\nrules:\n", 1)[1] @@ -118,20 +128,20 @@ def test_default_routes_domestic_direct_without_weakening_ai_priority(self): def test_domestic_dns_follows_the_domestic_group_in_both_modes(self): self.add_conf("PRIVACY_MODE=true\n") self.generate() - config = (self.output / "alpha-mac.yaml").read_text() + config = (self.output / self.client / "alpha.yaml").read_text() cn_group = config.split('name: "🇨🇳 国内流量"', 1)[1].split('name: "🛑 屏蔽流量"', 1)[0] self.assertLess(cn_group.index('- "🌐 代理流量"'), cn_group.index("- DIRECT")) self.assertIn("IP-CIDR,223.5.5.5/32,🇨🇳 国内流量,no-resolve", config) self.add_conf("AI_STRICT_MODE=true\n") self.generate() - config = (self.output / "alpha-mac.yaml").read_text() + config = (self.output / self.client / "alpha.yaml").read_text() self.assertNotIn("🇨🇳 国内流量", config) self.assertNotIn("nameserver-policy:", config) self.assertIn("IP-CIDR,223.5.5.5/32,🤖 AI 隐私出口,no-resolve", config) def test_ai_dependencies_are_preserved_before_ad_blocking(self): self.generate() - rules = (self.output / "alpha-mac.yaml").read_text().split("\nrules:\n")[1] + rules = (self.output / self.client / "alpha.yaml").read_text().split("\nrules:\n")[1] ads = rules.index("RULE-SET,ads-lite,") for dependency in ("DOMAIN,rum.browser-intake-datadoghq.com,", "DOMAIN,o207216.ingest.sentry.io,", "DOMAIN,o33249.ingest.sentry.io,", "DOMAIN,cdn.workos.com,", "DOMAIN,humb.apple.com,", "DOMAIN,js.stripe.com,"): @@ -140,7 +150,7 @@ def test_ai_dependencies_are_preserved_before_ad_blocking(self): def test_client_fields_and_anytls_acme_are_independent(self): self.add_conf("HY2_UP=30 mbps\nHY2_DOWN=0.2 gbps\nHY2_ACME_ENABLE=true\nHY2_ACME_DOMAIN=hy2.example.com\n") self.generate() - stash = (self.output / "alpha-mac.yaml").read_text() + stash = (self.output / self.client / "alpha.yaml").read_text() self.assertIn("up-speed: 30", stash) self.assertIn("down-speed: 200", stash) self.assertIn(" follow-rule: true", stash) @@ -151,7 +161,7 @@ def test_client_fields_and_anytls_acme_are_independent(self): self.assertIn("skip-cert-verify: true", anytls) self.assertNotIn("hy2.example.com", anytls) self.generate("--client", "mihomo") - mihomo = (self.output / "alpha-mac.yaml").read_text() + mihomo = (self.output / self.client / "alpha.yaml").read_text() self.assertIn('up: "30 mbps"', mihomo) self.assertIn("respect-rules: true", mihomo) self.assertNotIn("follow-rule:", mihomo) @@ -162,41 +172,66 @@ def test_certificate_pin_uses_client_specific_field(self): with self.secrets.open("a") as handle: handle.write("HY2_CERT_SHA256=" + "ab" * 32 + "\n") self.generate() - self.assertIn("server-cert-fingerprint:", (self.output / "alpha-mac.yaml").read_text()) + self.assertIn("server-cert-fingerprint:", (self.output / self.client / "alpha.yaml").read_text()) self.generate("--client", "mihomo") - text = (self.output / "alpha-mac.yaml").read_text() + text = (self.output / self.client / "alpha.yaml").read_text() self.assertIn(" fingerprint:", text) self.assertNotIn("server-cert-fingerprint:", text) def test_prefix_collision_does_not_delete_other_profile(self): self.env["NETWORK_NODE_PROFILE"] = "alpha-next" self.generate() - other = self.output / "alpha-next-mac.yaml" + other = self.output / self.client / "alpha-next.yaml" before = other.read_bytes() self.env["NETWORK_NODE_PROFILE"] = "alpha" self.generate() self.assertEqual(other.read_bytes(), before) - self.assertTrue((self.output / "alpha-mac.yaml").exists()) + self.assertTrue((self.output / self.client / "alpha.yaml").exists()) def test_collision_rejected_before_overwriting_existing_owner(self): self.generate() - before = (self.output / "alpha-mac.yaml").read_bytes() + before = (self.output / self.client / "alpha.yaml").read_bytes() self.env["NETWORK_NODE_PROFILE"] = "beta" self.add_conf("CLIENT_FILE_PREFIX=alpha\n") result = self.generate(success=False) self.assertNotEqual(result.returncode, 0) - self.assertEqual((self.output / "alpha-mac.yaml").read_bytes(), before) + self.assertEqual((self.output / self.client / "alpha.yaml").read_bytes(), before) - def test_only_owned_unchanged_stale_files_are_deleted(self): + def test_shared_identity_generates_both_without_mutating_accounts_or_legacy_files(self): + # Given existing per-device accounts and an old client file, rendering must + # produce exactly two new configs while preserving accounts and old imports. + before = self.secrets.read_bytes(), self.conf.read_bytes() + self.output.mkdir() + legacy = self.output / "alpha-phone.yaml" + legacy.write_text("legacy imported config\n") self.generate() - legacy = self.output / "alpha-old.yaml" - legacy.write_text("unowned\n") - self.add_conf("DEVICES=mac\n") + for client in ("stash", "mihomo"): + files = list((self.output / client).glob("*.yaml")) + self.assertEqual([p.name for p in files], ["alpha.yaml"]) + text = files[0].read_text() + self.assertIn("test-mac", text) + self.assertNotIn("test-phone", text) + self.assertEqual(before, (self.secrets.read_bytes(), self.conf.read_bytes())) + self.assertEqual(legacy.read_text(), "legacy imported config\n") + + def test_selected_identity_and_client_preserve_other_client(self): + self.generate() + stash = self.output / "stash" / "alpha.yaml" + before = stash.read_bytes() + self.generate("--client", "mihomo", "--identity", "phone") + self.assertEqual(stash.read_bytes(), before) + config = (self.output / "mihomo" / "alpha.yaml").read_text() + self.assertIn("test-phone", config) + self.assertNotIn("test-mac", config) self.assertNotEqual(self.generate("--check", success=False).returncode, 0) - self.assertTrue((self.output / "alpha-phone.yaml").exists()) + + def test_invalid_identity_or_incompatible_second_target_preserves_outputs(self): self.generate() - self.assertFalse((self.output / "alpha-phone.yaml").exists()) - self.assertEqual(legacy.read_text(), "unowned\n") + before = {p: p.read_bytes() for p in self.output.rglob("*") if p.is_file()} + self.assertNotEqual(self.generate("--identity", "../unknown", success=False).returncode, 0) + self.add_conf("HY2_HOP_INTERVAL=15-30\n") + self.assertNotEqual(self.generate(success=False).returncode, 0) + self.assertEqual(before, {p: p.read_bytes() for p in self.output.rglob("*") if p.is_file()}) def test_check_detects_drift_without_writing(self): result = self.generate("--check", success=False) @@ -204,10 +239,10 @@ def test_check_detects_drift_without_writing(self): self.assertFalse(self.output.exists()) self.generate() self.generate("--check") - snapshot = {p.name: p.read_bytes() for p in self.output.iterdir()} + snapshot = {str(p.relative_to(self.output)): p.read_bytes() for p in self.output.rglob("*") if p.is_file()} self.add_conf("AI_STRICT_MODE=true\n") self.assertNotEqual(self.generate("--check", success=False).returncode, 0) - self.assertEqual(snapshot, {p.name: p.read_bytes() for p in self.output.iterdir()}) + self.assertEqual(snapshot, {str(p.relative_to(self.output)): p.read_bytes() for p in self.output.rglob("*") if p.is_file()}) def test_disabled_profile_does_not_recreate_or_change_outputs(self): self.add_conf("CLIENT_CONFIG_ENABLE=false\n") @@ -216,10 +251,10 @@ def test_disabled_profile_does_not_recreate_or_change_outputs(self): self.assertFalse(self.output.exists()) self.add_conf("CLIENT_CONFIG_ENABLE=true\n") self.generate() - snapshot = {p.name: p.read_bytes() for p in self.output.iterdir()} + snapshot = {str(p.relative_to(self.output)): p.read_bytes() for p in self.output.rglob("*") if p.is_file()} self.add_conf("CLIENT_CONFIG_ENABLE=false\n") self.generate() - self.assertEqual(snapshot, {p.name: p.read_bytes() for p in self.output.iterdir()}) + self.assertEqual(snapshot, {str(p.relative_to(self.output)): p.read_bytes() for p in self.output.rglob("*") if p.is_file()}) def test_explicit_port_overrides_state_and_empty_reuses_state(self): self.add_conf("HY2_PORT=32000\n") diff --git a/tests/test_subscription.py b/tests/test_subscription.py new file mode 100644 index 0000000..55173bd --- /dev/null +++ b/tests/test_subscription.py @@ -0,0 +1,102 @@ +"""Subscription transport must not forward a bearer URL through redirects.""" +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import sys +import threading +import unittest +import json +import tempfile +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import subscription + + +class SubscriptionTransportTests(unittest.TestCase): + def test_client_identity_and_redirect_refusal(self): + seen = [] + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + seen.append((self.path, self.headers.get('User-Agent'))) + if self.path.startswith('/redirect'): + self.send_response(302) + self.send_header('Location', '/must-not-receive-token') + self.end_headers() + else: + self.send_response(200) + self.end_headers() + self.wfile.write(b'public test fixture') + + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + base = f'http://127.0.0.1:{server.server_port}' + status, body, _ = subscription.fetch_subscription(base + '/fixture') + self.assertEqual((status, body), (200, b'public test fixture')) + self.assertEqual(seen[-1][1], 'clash-verge/2.5.5') + status, _, _ = subscription.fetch_subscription(base + '/redirect?token=public-test') + self.assertEqual(status, 302) + self.assertEqual(len(seen), 2) + self.assertFalse(any(path.startswith('/must-not') for path, _ in seen)) + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_standalone_name_is_scoped_to_parent(self): + self.assertEqual(subscription.output_name('routing', 'cstone'), 'routing-cstone') + self.assertEqual(subscription.output_name('routing'), 'routing') + for value in ('../dmit', '', 'cstone/Reality', 'x' * 64): + with self.subTest(server=value), self.assertRaises(subscription.PublishError): + subscription.output_name('routing', value) + + def test_retired_profile_cannot_publish_existing_yaml(self): + # A disabled generator exits successfully without checking old output. + # Publishing must independently refuse that stale retired artifact. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state = root / 'profiles/retired' + state.mkdir(parents=True) + (state / 'deploy.conf').write_text('CLIENT_CONFIG_ENABLE=false\n') + target = root / 'clash-configs/mihomo/retired.yaml' + target.parent.mkdir(parents=True) + target.write_text('mode: rule\nproxies:\n []\nrules:\n []\n') + with mock.patch.object(subscription, 'ROOT', root): + with self.assertRaisesRegex(subscription.PublishError, '停用'): + subscription.verified_file('retired') + + def test_custom_prefix_uses_owned_checked_snapshot(self): + # The external generator checker is the boundary: it must succeed before + # bytes are returned, and concurrent file changes must abort publication. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + state = root / 'profiles/server' + state.mkdir(parents=True) + (state / 'deploy.conf').write_text('CLIENT_FILE_PREFIX=custom.edge\n') + target = root / 'clash-configs/mihomo/custom.edge.yaml' + target.parent.mkdir(parents=True) + content = b'mode: rule\nproxies:\n []\nrules:\n []\n' + target.write_bytes(content) + manifest = target.parent / '.network-node-outputs.json' + manifest.write_text(json.dumps({'server': {'custom.edge.yaml': 'fixture'}})) + with mock.patch.object(subscription, 'ROOT', root): + with mock.patch.object(subscription, 'run', return_value=b'') as check: + self.assertEqual(subscription.verified_file('server'), content) + self.assertIn('check', check.call_args.args[0]) + manifest.write_text('{}') + with self.assertRaisesRegex(subscription.PublishError, '归属'): + subscription.verified_file('server') + manifest.write_text(json.dumps({'server': {'custom.edge.yaml': 'fixture'}})) + with mock.patch.object(subscription, 'run', side_effect=lambda *a: target.write_text('changed')): + with self.assertRaisesRegex(subscription.PublishError, '改变'): + subscription.verified_file('server') + + def test_profile_cannot_change_url_path_or_query(self): + for profile in ('../routing', 'routing?token=changed', 'routing/other', ''): + with self.subTest(profile=profile), self.assertRaises(subscription.PublishError): + subscription.subscription_url('https://fixture.example', profile, 'public-test')