From d399155b37f166f02798858d700f48f8b798b22e Mon Sep 17 00:00:00 2001 From: Martin <60476606+mtmtian@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:43:10 +0800 Subject: [PATCH] Harden profile deployment and add verified GCP ingress recovery --- .github/workflows/ci.yml | 15 +- README.md | 105 +++++-- config/deploy.conf.example | 43 ++- core/cdn_probe.py | 204 ++++++++++++ core/client.yaml.tmpl | 436 ++++++++++++++++++++++++++ core/client_output.py | 95 ++++++ core/client_policy.py | 73 +++++ core/cloudflare.sh | 21 +- core/common.sh | 23 +- core/deploy.sh | 25 +- core/download.sh | 55 ++++ core/gen-clash.py | 515 ++++--------------------------- core/net_probe.py | 65 ++++ core/profile_lock.py | 30 ++ core/secrets.sh | 47 ++- core/settings.py | 155 ++++++++++ core/setup-server.sh | 136 +++++--- deploy-gcp.sh | 15 +- deploy-vps.sh | 3 + docs/architecture.md | 6 +- docs/protocol-stack.md | 6 + docs/provider-onboarding.md | 20 +- docs/repository-guide.md | 36 ++- docs/troubleshooting.md | 62 ++-- node.py | 91 ++++++ providers/gcp-preflight.sh | 14 +- providers/gcp-provision.sh | 16 +- providers/gcp.sh | 56 +++- providers/gcp_ip.py | 288 +++++++++++++++++ providers/vps.sh | 98 ++++-- tests/test_cdn_probe.py | 117 +++++++ tests/test_cloudflare_safety.py | 88 ++++++ tests/test_deploy_output.py | 54 ++++ tests/test_gcp_account.py | 98 ++++++ tests/test_gcp_ip.py | 192 ++++++++++++ tests/test_gen_clash.py | 17 +- tests/test_mihomo_config.py | 27 +- tests/test_net_probe.py | 42 +++ tests/test_provider_scripts.py | 12 +- tests/test_safety_regressions.py | 293 ++++++++++++++++++ tests/test_server_safety.py | 150 +++++++++ 41 files changed, 3177 insertions(+), 667 deletions(-) create mode 100644 core/cdn_probe.py create mode 100644 core/client.yaml.tmpl create mode 100644 core/client_output.py create mode 100644 core/client_policy.py create mode 100644 core/net_probe.py create mode 100644 core/profile_lock.py create mode 100644 core/settings.py create mode 100644 node.py create mode 100644 providers/gcp_ip.py create mode 100644 tests/test_cdn_probe.py create mode 100644 tests/test_cloudflare_safety.py create mode 100644 tests/test_gcp_account.py create mode 100644 tests/test_gcp_ip.py create mode 100644 tests/test_net_probe.py create mode 100644 tests/test_safety_regressions.py create mode 100644 tests/test_server_safety.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e05648..947dcdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,20 @@ jobs: run: test -z "$(git ls-files profiles)" - name: Check shell syntax - run: bash -n deploy.sh deploy-gcp.sh deploy-vps.sh core/*.sh providers/*.sh + run: | + for script in deploy.sh deploy-gcp.sh deploy-vps.sh core/*.sh providers/*.sh; do + bash -n "$script" + done - name: Run unit tests run: python3 -m unittest discover -s tests -p 'test_*.py' -v + + - name: Validate with pinned Mihomo + run: | + curl --fail --location --retry 3 --max-time 120 \ + https://github.com/MetaCubeX/mihomo/releases/download/v1.19.30/mihomo-linux-amd64-compatible-v1.19.30.gz \ + --output "$RUNNER_TEMP/mihomo.gz" + 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 diff --git a/README.md b/README.md index f0b032a..5eecdaa 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ One shared proxy core. The active path configures an existing CStoneCloud or oth - Primary: VLESS + Reality - UDP fallback: Hysteria2 - TCP fallback: AnyTLS -- One credential set and YAML per device +- One YAML per device; credentials managed automatically within each profile ## 快速开始 @@ -47,6 +47,10 @@ VPS adapter 会执行以下安全步骤: 重跑时会直接复用已经创建的 `mt` 用户,不再依赖 root。 +主机、SSH 端口、管理员、私钥路径及 `--ssh-interface` 会自动保存在 profile 的 `connection.conf`。 +保存过连接设置后,重跑只需 `./deploy-vps.sh --profile `;显式命令参数优先于保存值。 +复制 profile 只继承协议偏好,默认关闭 CDN、清空原域名并使用新 Tunnel 名,避免误改另一台机器的资源。 + 不要把私钥复制到服务器、Git 跟踪文件或聊天中。 首次部署后可以把主机专用私钥放在 `profiles//ssh/`,也可以继续通过 @@ -56,7 +60,7 @@ VPS adapter 会执行以下安全步骤: ### Google Cloud(当前未使用) -GCP adapter 仍可通过 `./deploy-gcp.sh` 使用,但不属于当前 cstone 主路径。删除它会改变已有公共入口,因此先保留为 dormant adapter,待确认无调用方后再单独清理。 +GCP 节点通过 `./deploy-gcp.sh` 管理,与普通 VPS 共用协议和分流配置。GCP profile 保存 `GCP_ACCOUNT`,后续操作固定使用该账号;更换赠金账户可以改绑现有项目的结算账户,无需重建服务器。流程见 [Provider Onboarding](docs/provider-onboarding.md#google-cloud-account-and-billing-changes)。 ## 目录结构 @@ -86,6 +90,9 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火 | 配置 | 默认值 | 说明 | |---|---|---| +| `CLIENT_CONFIG_ENABLE` | `true` | 设为 `false` 跳过此 profile 的客户端输出,保留状态与凭据;不操作服务器 | +| `CLIENT_TARGET` | `stash` | 生成 Stash 或 `mihomo` 对应字段 | +| `AI_STRICT_MODE` | `false` | 默认日常分流:AI 优先保护、国内默认直连;`true` 才将全部公网统一到 AI 线路 | | `REALITY_PORT` | `443` | Reality 监听端口 | | `REALITY_TARGET` / `REALITY_SNI` | `1.1.1.1:443` / 空 | Reality 目标与客户端 SNI | | `HY2_PORT` | 随机 | Hysteria2 UDP 端口 | @@ -93,8 +100,8 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火 | `HY2_OBFS_ENABLE` | `false` | 可选 Salamander 混淆;开启后不再表现为标准 HTTP/3 | | `HY2_ACME_ENABLE` | `false` | 可选 Cloudflare DNS-01 真实证书 | | `ANYTLS_PORT` | 随机 | AnyTLS TCP 端口 | -| `DEVICES` | `mac iphone` | 每个设备生成独立凭据和 YAML | -| `PRIVACY_MODE` | `true` | CN 流量首次默认走代理;设为 `false` 后首次默认直连并使用国内加密 DoH,STUN 始终走代理 | +| `DEVICES` | `mac iphone` | 每设备独立 Reality/HY2 凭据及 YAML;AnyTLS 为 profile 共享密码,自动管理 | +| `PRIVACY_MODE` | `false` | 仅日常分流生效:国内组默认直连;`true` 则默认代理 | | `CDN_ENABLE` | `false` | 可选 Cloudflare Tunnel 出口 | | `CDN_ONLY` | `false` | 仅使用 Cloudflare WS,并关闭直连代理端口 | | `WARP_ENABLE` | `false` | 可选 Reality-WARP 节点;仅该节点的 Xray 出站经过 WARP | @@ -106,10 +113,20 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火 - `.secrets.env` - `deploy.conf` +- `connection.conf` - `ssh/` 不要提交、转发或粘贴这些文件的内容。 +配置采用字面值 `KEY=value`,支持引号和行末注释;不会执行 shell 或展开 `$HOME`、`$(...)`。 +明确填写的端口优先于已保存状态;端口和 WS 路径留空时复用自动生成的值。 + +**密码无需记忆。** 每个 profile 自动生成并复用凭据,位置固定为 `profiles//.secrets.env`。 +运行 `python3 node.py status --profile ` 可查看文件位置,不显示密码。备份时保存完整 profile(包括 `ssh/`), +不要只备份客户端 YAML。Reality、HY2、CDN 和 WARP 使用独立设备凭据;AnyTLS 官方服务端使用每 profile 一个密码。 +移除设备并重新部署时,工具会自动轮换 AnyTLS 密码、删除该设备的旧凭据;剩余设备需要导入新 YAML。 +单独运行 `render` 不轮换密码,也不会在服务器上撤销设备。 + ## 导入客户端 部署成功后,每个平台默认得到两份名称明确的 YAML: @@ -119,28 +136,39 @@ GCP 和 VPS 真正变化的只有服务器生命周期、连接方式与防火 - `clash-configs/-mac.yaml` - `clash-configs/-iphone.yaml` -生成器只替换当前 profile 前缀的文件,例如 `cstone-next` 只处理 `cstone-next-*.yaml`,不会覆盖 -`cstone-*.yaml`。客户端 YAML 默认权限为 `600`,因为其中含节点地址、UUID 和密码; +生成器通过精确文件归属清单管理输出,不按前缀通配符删除。`cstone` 与 `cstone-next` 可以安全共存; +不同 profile 指定相同输出文件会报错。旧版本未登记的文件、移除设备后被手工修改的文件会保留。 +客户端 YAML 权限为 `600`,输出目录为 `700`,因为其中含节点地址、UUID 和密码; 这可阻止同一台电脑上的其他系统用户读取。iCloud 副本用于设备同步,不作为项目源状态。 - Stash(首要兼容目标):从配置文件页面导入本地或 iCloud YAML - Clash Verge:Settings → Profiles → Import - 其他客户端:使用支持 Reality、Hysteria2 和 AnyTLS 的 Mihomo/Clash.Meta 兼容客户端 -共享 YAML 以 Stash 当前公开配置格式为基线,同时保留 Mihomo 兼容性。不要直接加入 Stash 未支持的 -Mihomo-only 字段,例如数组型 inline rule-provider;确需使用时,应拆分明确的客户端目标并补对应测试。 +`CLIENT_TARGET=stash` 为默认目标,使用 Stash 的 HY2 `auth`、`up-speed/down-speed`、DNS `follow-rule` 和 STUN 协议规则; +`CLIENT_TARGET=mihomo` 生成 Mihomo 字段和 TUN 设置。两种目标共用源规则,但不能把同一份 YAML 视为两端字段完全等价。 +Stash 的节点域名独立解析功能需要 iOS/tvOS 3.6 或 macOS 4.3 及以上版本。 -新生成的配置默认启用 `PRIVACY_MODE=true`,`🇨🇳 国内流量` 默认选择代理,避免检测页面同时观察到国内直连与代理出口;遇到无法使用的 CN 服务时,可在 Stash/Mihomo 里把该组手动切到 `DIRECT`,无需修改 YAML。设置 `PRIVACY_MODE=false` 会让该组首次默认直连,但仍可手动切回代理。局域网地址始终直连,原有 Apple/Spotify 规则保持不变。 +**默认使用日常分流:普通国内网站直连,AI 核心服务及已知依赖优先走 AI 线路。** +`AI_STRICT_MODE=false`、`PRIVACY_MODE=false` 为默认值。静态内网规则最先匹配,AI/认证/第三方依赖保护先于广告拦截和国内域名/IP 分流, +因此一个已识别的 AI 依赖即使解析到国内地址,也不会被后面的 CN 规则抢走。普通国内网站由 `.cn`、CN 域名集和 CN 地址规则送入国内流量组,首次默认 `DIRECT`。 -当 `PRIVACY_MODE=false`,或手动把 `🇨🇳 国内流量` 切到 `DIRECT` 时,`geosite:cn` 域名通过阿里云 / 腾讯云的加密 DoH 解析,解析结果与国内直连出口保持同区,避免海外 DNS 导致 CDN 绕路;这不会产生明文 DNS 请求,但国内 DoH 服务商仍能看到查询出口 IP。其他域名继续使用经代理路由的 Cloudflare / Google DoH,STUN 仍固定走 `🤖 AI 隐私出口`,不受 `PRIVACY_MODE` 影响。DNS 在 Stash 使用 `follow-rule`、在 Mihomo 使用 `respect-rules`,并保留独立 bootstrap 解析器防止递归依赖。 +AI 组只按 Reality → CDN(启用时)切换,使用同一 VPS 的 Xray IPv4 出口,不加入 HY2、AnyTLS 或 WARP。 +普通海外流量保留 Reality → CDN → HY2 → AnyTLS 故障切换;Apple 基础服务和 Spotify 保留原有直连策略,WARP 仅供手动选择。 +未匹配任何规则的流量默认代理,不会因开启国内直连而将全部未知请求直连。 -为覆盖尚未及时进入 `geosite:cn` 的国内产品,生成器还会把所有 `.cn` 域名显式送入 `🇨🇳 国内流量`,并为这些域名优先使用国内 DNS;因此切换该组为 `DIRECT` 后,域名路由和解析会同时走国内路径。 +AI 动态规则使用 MetaCubeX `category-ai-!cn`,既有监控/认证依赖保护保留在广告规则之前, +并补充 [OpenAI 官方网络白名单](https://help.openai.com/en/articles/9247338-network-recommendations-for-chatgpt-errors-on-web) 中的 WorkOS、Statsig、功能开关、支付和 Apple 集成域名。 +规则引擎无法判断未知第三方请求属于哪个页面;发生漏网请求时应补精确依赖规则。广告规则仍可能拦截尚未识别的依赖,可临时将广告组切到代理排查。 -普通流量默认使用 `🛟 自动故障切换`:Reality 正常时行为不变,连接失败时按 Reality → CDN → Hysteria2 → AnyTLS 顺序切换。AI 域名和 STUN 使用单独的 `🤖 AI 隐私出口`,按 Reality → CDN(启用时)切换;两条入口共用服务端 Xray IPv4 出口,不加入 HY2、AnyTLS 或 WARP。CDN-only 时该组只使用 CDN。 +国内域名使用国内加密 DoH,并把这些解析器 IP 显式关联到国内流量组,默认直连;手动切换国内组时,这部分 DNS 连接也跟随该组。 +其他业务 DNS 使用固定经 AI 组出站的 Cloudflare/Google DoH。节点域名仍通过单独的直连加密 DNS 启动解析,避免代理尚未建立时的递归依赖。 +DNS 服务商可看到其收到的查询及来源;客户端配置只控制进入其规则引擎的流量。 -AI 规则来自 MetaCubeX `category-ai-!cn`。生成器把 Anthropic/Claude 的核心域名、认证/CDN、监控与第三方组件、Anthropic IP 段/ASN、NTP 和 STUN 写成高优先级静态规则,置于动态 AI、广告拦截及所有直连规则之前;远程规则集首次下载或刷新失败时,这些流量仍固定走 AI 隐私出口。`IP-ASN` 需要客户端加载 ASN 数据库,NTP 规则需要代理节点支持 UDP(当前节点均开启 UDP)。DeepSeek、通义、Kimi、豆包等中国 AI 域名不在此静态集合内,继续由后续 CN 或兜底规则处理。 +**严格模式保留为可选项,不默认启用。** 设置 `AI_STRICT_MODE=true` 后,国内网站、Apple、Spotify 和未知第三方请求也进入 AI 线路, +只保留 AI 与广告两个策略组、两个远程规则集,不依赖 GeoIP/ASN 数据库完成公网兜底。HY2、AnyTLS、WARP 节点仍被生成,但严格模式不会选择它们。 +AI 入口全部不可用时,公网业务失败;国内业务 DNS 分支也被移除。希望国内网站直连时保持 `AI_STRICT_MODE=false`。 -`US-Reality-WARP` 仅保留为手动可选节点,不进入自动测速或自动故障切换,避免自动选择改变公网出口。 如需真正隐藏源站 IP,把 `CDN_ENABLE=true` 和 `CDN_ONLY=true` 同时设置;这会关闭直连 Reality/Hysteria2/AnyTLS,保留 Cloudflare WS 入口。 ### WARP 出站(低延迟优先的可选路径) @@ -153,6 +181,10 @@ AI 规则来自 MetaCubeX `category-ai-!cn`。生成器把 Anthropic/Claude 的 修改 `DEVICES` 后重跑同一个 profile 的入口,即可增加或撤销设备。 +自签名 HY2 证书在下次成功部署后自动回传 SHA256 指纹并写入 YAML;已有 profile 尚无指纹时保留原兼容行为。 +ACME 证书使用可持久写入的 `/var/lib/hysteria/acme`。AnyTLS 的临时自签名证书与 HY2 ACME 相互独立, +目前仍需跳过证书链验证,且官方服务端密码会出现在服务进程参数中;严格模式不选择该节点。 + ### Cloudflare CDN 首次启用 先把域名接入 Cloudflare,并在目标 profile 的 `.secrets.env` 写入最小权限 API Token: @@ -173,14 +205,51 @@ CDN_TUNNEL_NAME=-cdn ``` 重新运行对应部署入口即可自动创建/复用 Tunnel、配置 Ingress、写入橙云 CNAME 并生成 `US-CDN`。 -首次建议保持 `CDN_ONLY=false`,确认 CDN 节点可用后再单独切换 CDN-only。缺少 API Token 或 +首次保持 `CDN_ONLY=false`;CDN-only 切换前会从本机验证已经部署的 CDN 通道。缺少 API Token 或 Cloudflare 权限不足时,部署会在修改服务器前停止,不会留下半套 VPS 配置。 +CDN 用于本地网络无法直连 VPS IP 时的备用入口,最终仍从同一 VPS 出网。服务端检查 Tunnel +实际连接数,本机再检查所有设备的 TLS → WebSocket → VLESS → HTTPS 请求,全部通过才生成 YAML。 +也可单独运行 `python3 node.py cdn-check --profile gcloud`;这个命令忽略 HTTP 代理环境变量, +不回退到 Reality,但操作系统的 TUN 仍可能影响底层网络,不能代替手机移动网络实测。 +本机运行 TUN 时可在 profile 设置 `PROBE_INTERFACE=en0`(换成实际物理网卡);工具会将 +AliDNS DoH 查询和 CDN/IP 探测连接都绑定到该网卡,避免系统 fake-IP 或其他代理造成假阳性。 +该设置只影响本机验收,不改变客户端流量和 GCP 管理代理;目前支持 macOS/Linux。 + +自动故障切换组每 60 秒检查,关闭 lazy;Stash 节点显式设置 5 秒测试超时。这个间隔是检查频率, +不是保证的恢复时限,已有长连接仍可能需要重连。 + +### GCP IP 故障恢复 + +```bash +python3 node.py ip-check --profile gcloud # 只读核对预留地址、VM 绑定和本地记录 +python3 node.py ip-sync --profile gcloud # 云端绑定正确时,同步本地 IP 和 YAML +python3 node.py ip-rotate --profile gcloud # 显示换 IP 计划 +python3 node.py ip-rotate --profile gcloud --apply +``` + +换 IP 不重装服务、不轮换协议凭据:保留旧静态地址,预留并绑定新地址,经本机 Reality TCP +端口验收后更新 `IP_NAME`、`STATIC_IP` 和 YAML。失败会尝试恢复旧绑定及 YAML;云端仍有未完成 +操作时停止反向修改,保留恢复记录,稍后运行 `ip-rollback --profile gcloud --apply`。 +TCP 可达不等于 Reality 认证通过,仍需在设备上验证实际代理流量。 + +新地址确认可用并完成设备同步后,先运行 `ip-finalize --profile gcloud` 查看释放计划,再加 +`--apply` 永久释放未使用的保留地址。释放后不能回滚;保留地址可能持续计费,不应长期忘记清理。 +存在上次恢复记录时不能再次换 IP。所有操作固定使用 profile 的账号和项目,并与 GCP 部署互斥。 + +源 YAML 位于 `clash-configs/`;iCloud 是分发副本,需要同步文件并让各设备重新加载。 +此工具不会自动修改 iCloud、删除旧客户端配置、判断 IP 已被封,或循环申请地址。 + ## 重跑与维护 - 两个入口均按幂等方式设计,会复用已有服务器和本地密钥。 -- 只重新生成某个 profile 的客户端 YAML:`NETWORK_NODE_PROFILE= python3 core/gen-clash.py` -- 服务端组件版本在 `deploy.conf` 中固定;升级前先改版本并重新部署,避免重跑时无意升级。 +- 本地管理:`python3 node.py --profile `。四个命令均不访问服务器;`render` 只更新本地 YAML。 +- 停用机器可在其 profile 设置 `CLIENT_CONFIG_ENABLE=false`;生成和一致性检查会提示跳过,`status` 可查看启停状态。旧 YAML 不会自动删除,可移入 `clash-configs/archive//` 保存;恢复输出时将开关改回 `true`。 +- `check` 只读检查 YAML 是否对应当前源,`validate` 检查配置完整性;修改规则不必重跑完整部署。 +- 临时指定输出目标:`python3 node.py render --profile --client mihomo`。同一 profile 使用同一组输出文件名,切换目标会替换这些 YAML。 +- 服务端组件版本固定;升级须同时核验并更新 `core/download.sh` 的 SHA256。下载缓存按摘要复用,每次使用仍校验。 +- 远端安装使用独立临时目录和部署锁;二进制及 Xray 配置保存 `.previous`,Xray 先校验再替换。这不是全服务自动回滚。 +- SSH 先检查语法和生效配置再 reload;UFW 开放新端口后移除已登记的旧端口,不清空其他应用的规则。 - GCP 旧命令 `./deploy.sh` 仍可使用,但会提示改用 `./deploy-gcp.sh`。 - 通用排障见 [Troubleshooting](docs/troubleshooting.md)。 - 新服务器接入和隔离规则见 [Provider Onboarding](docs/provider-onboarding.md)。 @@ -193,7 +262,7 @@ Cloudflare 权限不足时,部署会在修改服务器前停止,不会留下 # Active path: configure an existing CStoneCloud/Debian/Ubuntu VPS ./deploy-vps.sh --profile cstone-next --host --ssh-key "$HOME/.ssh/cstone_ed25519" --copy-config-from cstonecloud-cuii-a -# Dormant path: provision a Google Cloud node +# Google Cloud: provision or maintain a node using its saved account ./deploy-gcp.sh ``` diff --git a/config/deploy.conf.example b/config/deploy.conf.example index 7921430..6549420 100644 --- a/config/deploy.conf.example +++ b/config/deploy.conf.example @@ -1,7 +1,27 @@ # ── 复制本文件为 deploy.conf 后按需修改(任一部署入口首次运行会自动创建)── +# 本文件只支持字面值 KEY=value、引号和注释;不执行 shell、不展开 $HOME 或命令。 + +# 停用机器可设为 false:跳过客户端输出,保留 profile 和凭据,不操作服务器。 +CLIENT_CONFIG_ENABLE=true + +# 客户端目标:stash(默认)/ mihomo。两者 DNS、HY2、TUN 字段分别生成。 +CLIENT_TARGET=stash +# 默认日常分流:已知 AI 及其依赖优先走 AI 线路,普通国内流量默认直连。 +# true 为可选严格模式:全部公网固定走同一 VPS 的 Reality/CDN,国内也代理。 +# 严格模式不自动切到 HY2/AnyTLS/WARP;不需要时保持 false。 +AI_STRICT_MODE=false # GCP 项目 ID(仅 GCP 入口必填)。留空时 deploy-gcp.sh 会读取 gcloud 当前项目并提示确认 PROJECT_ID= +# GCP 管理账号。首次配置后自动保存,后续部署不跟随 gcloud 的全局默认账号。 +GCP_ACCOUNT= +# 可选:本机 GCP 管理请求使用的 HTTP 代理(例如 http://127.0.0.1:7890)。 +# IAP 无法连接、但本机代理可用时填写;不影响服务器出口或客户端分流。 +GCP_HTTP_PROXY= +# 可选:本机恢复验收强制从物理网卡发起,避免 TUN / fake-IP 制造可达假象。 +# macOS 常用 en0;留空沿用系统路由。CDN 解析经该网卡访问 AliDNS DoH。 +# 只影响 cdn-check、部署后的 CDN 验收和换 IP 的 TCP 验收,不写入设备 YAML。 +PROBE_INTERFACE= # 区域 / 可用区。Free Tier 免费区:us-west1 / us-central1 / us-east1 REGION=us-west1 @@ -25,7 +45,7 @@ ANYTLS_PORT= # Hysteria2 可选增强:端口跳跃 / QUIC 混淆。默认关闭,开启后需要重新生成并导入客户端 YAML。 # HY2_PORT_RANGE=30000-30010 -# HY2_HOP_INTERVAL=15-30 +# HY2_HOP_INTERVAL=30(整数秒,Stash/Mihomo 均支持;范围仅支持 Mihomo) HY2_PORT_RANGE= HY2_HOP_INTERVAL= HY2_OBFS_ENABLE=false @@ -45,18 +65,21 @@ HY2_ACME_DNS_PROVIDER=cloudflare HY2_UP= HY2_DOWN= -# 要生成客户端配置的设备名(空格分隔,可自由增删) +# 设备 ID:字母、数字、下划线,1-64 位,空格分隔。 +# 移除设备后部署会自动轮换本 profile 的 AnyTLS 密码,剩余设备需重新导入 YAML。 DEVICES="mac iphone" -# 隐私模式:只决定「🇨🇳 国内流量」组的首次默认选项;true 默认代理,false 默认直连。 -# CN 直连时域名使用国内加密 DoH;其他域名和 STUN 仍走代理。客户端可随时手动切换该组; -# 局域网与原有 Apple/Spotify 分流保持不变。 -PRIVACY_MODE=true +# 仅在 AI_STRICT_MODE=false 时生效:决定国内流量组的首次默认选项。 +# true 默认代理,false 默认直连;日常模式的国内域名使用国内加密 DoH。 +# 国内 DoH 解析器 IP 显式关联国内流量组,跟随该组选择;其他业务 DNS 走 AI 出口。 +PRIVACY_MODE=false -# ── Cloudflare CDN 套娃出口(可选)── -# 开启后多一条出口:客户端 → Cloudflare 边缘 → Cloudflare Tunnel → cloudflared@VM +# ── Cloudflare CDN 备用入口(可选)── +# 应对本地网络无法直连 VPS IP;出口仍是同一 VPS,不改变目标网站看到的出口 IP。 +# 开启后多一条入口:客户端 → Cloudflare 边缘 → Cloudflare Tunnel → cloudflared@VM # → xray VLESS+WS(127.0.0.1:8080) → 直出。客户端只与 Cloudflare anycast IP 通信, -# 真实 VM IP 对线路侧不可见。false 时跳过;true 时必须先准备 CF_API_TOKEN,缺失会在改服务器前直接停止。 +# 走 CDN 时线路侧不直连 VM;Reality 优先模式仍会尝试真实 IP。false 时跳过; +# true 时必须先准备 CF_API_TOKEN,缺失会在改服务器前直接停止。 CDN_ENABLE=false # true 时只生成 Cloudflare WS 节点,并关闭直连代理端口;会失去原生 HY2/AnyTLS 直连能力。 CDN_ONLY=false @@ -75,7 +98,7 @@ WARP_ENABLE=false WARP_SOCKS_PORT=40000 WARP_REALITY_PORT= -# 固定服务端组件版本,避免重跑时自动升级造成兼容性漂移。 +# 固定服务端组件版本;升级须同步更新 core/download.sh 中已核验的 SHA256。 XRAY_VERSION=v26.3.27 HYSTERIA_VERSION=app/v2.10.0 ANYTLS_VERSION=0.0.13 diff --git a/core/cdn_probe.py b/core/cdn_probe.py new file mode 100644 index 0000000..14b69e1 --- /dev/null +++ b/core/cdn_probe.py @@ -0,0 +1,204 @@ +"""Direct TLS → WebSocket → VLESS → verified HTTPS probe using the standard library. + +This deliberately ignores HTTP proxy environment variables. Set PROBE_INTERFACE +to bind both DNS and proxy traffic to a physical interface outside a local TUN. +""" +import base64 +import hashlib +import os +from pathlib import Path +import ssl +import struct +import sys +import time +import uuid + +from settings import load_settings, validate +from net_probe import connect + + +def exact(stream, size): + result = bytearray() + while len(result) < size: + block = stream.recv(size - len(result)) + if not block: + raise ValueError('CDN 连接提前关闭') + result.extend(block) + return bytes(result) + + +def websocket_handshake(sock, host, path): + key = base64.b64encode(os.urandom(16)).decode() + sock.sendall((f'GET /{path.lstrip("/")} HTTP/1.1\r\nHost: {host}\r\n' + 'Upgrade: websocket\r\nConnection: Upgrade\r\n' + f'Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n').encode()) + head = bytearray() + while not head.endswith(b'\r\n\r\n'): + if len(head) >= 16384: + raise ValueError('CDN 握手响应过大') + head.extend(exact(sock, 1)) + lines = head.decode('iso-8859-1').split('\r\n') + fields = lines[0].split() + if len(fields) < 2 or fields[1] != '101': + status = fields[1] if len(fields) > 1 and fields[1].isdigit() else 'unknown' + raise ValueError(f'CDN WebSocket 握手失败(HTTP {status});检查 Tunnel、路径及 WAF') + headers = dict((k.lower(), v.strip()) for k, v in (line.split(':', 1) for line in lines[1:] if ':' in line)) + expected = base64.b64encode(hashlib.sha1((key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode() + if (headers.get('sec-websocket-accept') != expected or headers.get('upgrade', '').lower() != 'websocket' + or 'upgrade' not in [token.strip() for token in headers.get('connection', '').lower().split(',')]): + raise ValueError('CDN WebSocket 握手校验失败') + + +class WebSocket: + def __init__(self, sock): + self.sock = sock + self.buffer = b'' + + def send(self, data, opcode=2): + mask = os.urandom(4) + size = len(data) + if size < 126: + length = bytes([size | 128]) + elif size < 65536: + length = bytes([126 | 128]) + struct.pack('!H', size) + else: + length = bytes([127 | 128]) + struct.pack('!Q', size) + payload = bytes(value ^ mask[i % 4] for i, value in enumerate(data)) + self.sock.sendall(bytes([128 | opcode]) + length + mask + payload) + + def recv(self, size): + while not self.buffer: + first, second = exact(self.sock, 2) + opcode, length = first & 15, second & 127 + if first & 112 or second & 128: + raise ValueError('CDN WebSocket 帧格式错误') + if length == 126: + length = struct.unpack('!H', exact(self.sock, 2))[0] + elif length == 127: + length = struct.unpack('!Q', exact(self.sock, 8))[0] + if length > 1048576: + raise ValueError('CDN WebSocket 帧过大') + data = exact(self.sock, length) + if opcode == 9: + self.send(data, opcode=10) + elif opcode == 10: + continue + elif opcode in (0, 2): + self.buffer = data + else: + raise ValueError('CDN WebSocket 关闭或返回非二进制数据') + data, self.buffer = self.buffer[:size], self.buffer[size:] + return data + + +class Vless: + def __init__(self, websocket, device_uuid, target): + self.ws = websocket + self.response = False + host = target.encode('idna') + # Version, UUID, empty addons, TCP command, port, domain address type. + self.ws.send(b'\x00' + uuid.UUID(device_uuid).bytes + b'\x00\x01' + + struct.pack('!H', 443) + b'\x02' + bytes([len(host)]) + host) + + def send(self, data): + self.ws.send(data) + + def recv(self, size): + if not self.response: + version, addons = exact(self.ws, 2) + if version != 0: + raise ValueError('VLESS 响应版本错误') + exact(self.ws, addons) + self.response = True + return self.ws.recv(size) + + +def https_probe(transport, target): + incoming, outgoing = ssl.MemoryBIO(), ssl.MemoryBIO() + tls = ssl.create_default_context().wrap_bio(incoming, outgoing, server_hostname=target) + + def flush(): + while outgoing.pending: + transport.send(outgoing.read()) + + def exchange(operation): + while True: + try: + result = operation() + flush() + return result + except ssl.SSLWantReadError: + flush() + data = transport.recv(16384) + if not data: + raise ValueError('HTTPS 探测连接提前关闭') + incoming.write(data) + + exchange(tls.do_handshake) + request = f'GET /generate_204 HTTP/1.1\r\nHost: {target}\r\nConnection: close\r\n\r\n'.encode() + while request: + request = request[exchange(lambda: tls.write(request)):] + head = bytearray() + while b'\r\n' not in head: + if len(head) > 16384: + raise ValueError('HTTPS 探测响应过大') + block = exchange(lambda: tls.read(4096)) + if not block: + raise ValueError('HTTPS 探测连接提前关闭') + head.extend(block) + status = bytes(head).split(b'\r\n', 1)[0].split() + if len(status) < 2 or status[1] != b'204': + raise ValueError('CDN 已建立连接,但 HTTPS 探测未返回预期的 204') + + +class DeadlineSocket: + def __init__(self, sock, deadline): + self.sock, self.deadline = sock, deadline + + def arm(self): + remaining = self.deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError() + self.sock.settimeout(remaining) + + def sendall(self, data): + self.arm() + self.sock.sendall(data) + + def recv(self, size): + self.arm() + return self.sock.recv(size) + + +def check_cdn(settings, timeout=20): + validate(settings) + required = ('CDN_HOSTNAME', 'CDN_WS_PATH') + devices = settings.get('DEVICES', '').split() + if settings.get('CDN_ENABLE') != 'true' or any(not settings.get(k) for k in required) or not devices: + raise ValueError('CDN 未启用或连接参数不完整') + for device in devices: + if not settings.get('CDN_UUID_' + device): + raise ValueError('CDN 设备凭据不完整') + try: + device_uuid = str(uuid.UUID(settings['CDN_UUID_' + device])) + except ValueError: + raise ValueError('CDN 设备 UUID 格式无效') from None + host = settings['CDN_HOSTNAME'] + deadline = time.monotonic() + timeout + try: + with connect(host, 443, timeout, settings.get('PROBE_INTERFACE', '')) as raw: + with ssl.create_default_context().wrap_socket(raw, server_hostname=host) as secured: + sock = DeadlineSocket(secured, deadline) + websocket_handshake(sock, host, settings['CDN_WS_PATH']) + transport = Vless(WebSocket(sock), device_uuid, 'www.gstatic.com') + https_probe(transport, 'www.gstatic.com') + except (OSError, ssl.SSLError): + raise ValueError('CDN DNS、TLS 或网络连接失败/超时;未输出地址和凭据') from None + + +if __name__ == '__main__': + try: + check_cdn(load_settings(Path(sys.argv[1]))) + print('CDN 所有设备的 TLS / WebSocket / VLESS / HTTPS 请求通过') + except (ValueError, OSError) as exc: + sys.exit(str(exc)) diff --git a/core/client.yaml.tmpl b/core/client.yaml.tmpl new file mode 100644 index 0000000..54b4c5b --- /dev/null +++ b/core/client.yaml.tmpl @@ -0,0 +1,436 @@ +# Network-Node config — device: {DEVICE} +# Profile: {PROFILE_OWNER} +# Client: {TARGET_LABEL}; AI strict: {STRICT_LABEL}; template: {TEMPLATE_REVISION} +# Server: {SERVER_LABEL} + +mixed-port: 7890 +allow-lan: false +mode: rule +log-level: info +ipv6: false +geodata-mode: true +find-process-mode: strict +sniffer: + enable: true + sniff: + HTTP: + ports: + - 80 + - 8080-8880 + override-destination: true + TLS: + ports: + - 443 + - 8443 + QUIC: + ports: + - 443 + - 8443 + +skip-proxy: + - 127.0.0.1 + - 192.168.0.0/16 + - 10.0.0.0/8 + - 172.16.0.0/12 + - 100.64.0.0/10 + - localhost + - "*.local" + - captive.apple.com + +tun: + enable: true + stack: mixed + mtu: 1280 + auto-route: true + auto-detect-interface: true + strict-route: true + dns-hijack: + - "any:53" + - "tcp://any:53" + +dns: + enable: true + listen: 127.0.0.1:1053 + ipv6: false + enhanced-mode: fake-ip + fake-ip-range: 198.18.0.1/16 + # 只输出目标客户端支持的 DNS 路由开关。 +{DNS_FOLLOW_RULE} + fake-ip-filter: + - "*.lan" + - "*.local" + - "*.apple.com" + - "*.apple" + - "app-analytics-services.com" + - "time.*.com" + - "ntp.*.com" + - "*.ntp.org" + - "+.msftconnecttest.com" + - "+.msftncsi.com" + - "localhost.ptlogin2.qq.com" + default-nameserver: + - https://223.5.5.5/dns-query + - https://1.1.1.1/dns-query + # 仅用于代理节点域名的 bootstrap,避免 DNS 经代理时递归依赖。 + proxy-server-nameserver: + - https://223.5.5.5/dns-query + - https://1.12.12.12/dns-query + # 业务 DNS 按代理规则出站;IP 形式避免再次解析 DoH 主机名。 + nameserver: + - https://1.1.1.1/dns-query + - https://8.8.8.8/dns-query + # 国内流量使用国内加密 DNS;解析器 IP 的路由跟随国内流量组。 + nameserver-policy: + '+.cn': + - https://223.5.5.5/dns-query + - https://120.53.53.53/dns-query + 'geosite:cn': + - https://223.5.5.5/dns-query + - https://120.53.53.53/dns-query + +proxies: +{REALITY_PROXY}{HY2_PROXY}{ANYTLS_PROXY} +{WARP_PROXY} +{CDN_PROXY} +proxy-groups: + - name: "🚀 代理策略" + type: select + proxies: + - "🛟 自动故障切换" + - "⚡ 自动测速" + - "🔧 手动选择"{CDN_REF} + - DIRECT + + # AI 与 STUN 只使用共享 Xray IPv4 出口;Reality 失败时可经 CDN 进入同一 Xray。 + - name: "🤖 AI 隐私出口" + type: fallback + lazy: false + url: https://www.gstatic.com/generate_204 + interval: 60 + proxies: +{AI_PROXIES} + + - name: "🛟 自动故障切换" + type: fallback + lazy: false + url: https://www.gstatic.com/generate_204 + interval: 60 + proxies: +{FALLBACK_PROXIES} + + - name: "⚡ 自动测速" + type: url-test + lazy: true + url: https://www.gstatic.com/generate_204 + interval: 600 + tolerance: 150 + proxies: +{AUTO_PROXIES} + + - name: "🔧 手动选择" + type: select + proxies: +{MANUAL_PROXIES} + - DIRECT + + - name: "🌐 代理流量" + type: select + proxies: + - "🚀 代理策略" + - "⚡ 自动测速" + - "🔧 手动选择" +{ALL_PROXIES} + - DIRECT + + - name: "↪️ 直连流量" + type: select + proxies: + - DIRECT + - "🚀 代理策略" + - "🔧 手动选择" + + # PRIVACY_MODE 只决定默认顺序;可在客户端随时切换 CN 流量出口。 + - name: "🇨🇳 国内流量" + type: select + proxies: +{CN_POLICY_OPTIONS} + + - name: "🛑 屏蔽流量" + type: select + proxies: + - REJECT + - DIRECT + - "🚀 代理策略" + + - name: "🎯 兜底策略" + type: select + proxies: + - "🚀 代理策略" + - DIRECT + +rule-providers: + # --- MetaCubeX: AI / Google --- + ai: + type: http + behavior: domain + format: mrs + url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/category-ai-%21cn.mrs" + path: ./ruleset/meta_ai.mrs + interval: 86400 + + google: + type: http + behavior: domain + format: mrs + url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/google.mrs" + path: ./ruleset/meta_google.mrs + interval: 86400 + + # --- blackmatrix7: iOS / Apple 功能补丁 --- + siri: + type: http + behavior: classical + format: yaml + url: "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/Siri/Siri.yaml" + path: ./ruleset/bm7_siri.yaml + interval: 86400 + + icloud-private-relay: + type: http + behavior: classical + format: yaml + url: "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/iCloudPrivateRelay/iCloudPrivateRelay.yaml" + path: ./ruleset/bm7_icloud_private_relay.yaml + interval: 86400 + + # --- MetaCubeX: Apple 基础直连 --- + icloud: + type: http + behavior: domain + format: mrs + url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/icloud.mrs" + path: ./ruleset/meta_icloud.mrs + interval: 86400 + + apple-cn: + type: http + behavior: domain + format: mrs + url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/apple-cn.mrs" + path: ./ruleset/meta_apple_cn.mrs + interval: 86400 + + # --- blackmatrix7: 轻量广告拦截 --- + ads-lite: + type: http + behavior: classical + format: yaml + url: "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/AdvertisingLite/AdvertisingLite.yaml" + path: ./ruleset/bm7_ads_lite.yaml + interval: 86400 + + # --- MetaCubeX: 国内直连 --- + private: + type: http + behavior: domain + format: mrs + url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/private.mrs" + path: ./ruleset/meta_private.mrs + interval: 86400 + + private-ip: + type: http + behavior: ipcidr + format: mrs + url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/private.mrs" + path: ./ruleset/meta_private_ip.mrs + interval: 86400 + + cn: + type: http + behavior: domain + format: mrs + url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/cn.mrs" + path: ./ruleset/meta_cn.mrs + interval: 86400 + + cn-ip: + type: http + behavior: ipcidr + format: mrs + url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/cn.mrs" + path: ./ruleset/meta_cn_ip.mrs + interval: 86400 + + # --- MetaCubeX: 通讯 / 海外服务 --- + telegram: + type: http + behavior: domain + format: mrs + url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/telegram.mrs" + path: ./ruleset/meta_telegram.mrs + interval: 86400 + + telegram-ip: + type: http + behavior: ipcidr + format: mrs + url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/telegram.mrs" + path: ./ruleset/meta_telegram_ip.mrs + interval: 86400 + + tiktok: + type: http + behavior: domain + format: mrs + url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/tiktok.mrs" + path: ./ruleset/meta_tiktok.mrs + interval: 86400 + +rules: + # 静态内网规则无需下载,优先于 NTP/STUN 和所有公网规则。 + - 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 + + # 海外 DoH 与 AI 请求固定同一出口;节点 bootstrap 使用独立解析路径。 + - IP-CIDR,1.1.1.1/32,🤖 AI 隐私出口,no-resolve + - IP-CIDR,8.8.8.8/32,🤖 AI 隐私出口,no-resolve + # 国内 DNS 与国内业务使用同一策略组;默认直连,无需等待 CN 地址库命中。 + - IP-CIDR,223.5.5.5/32,🇨🇳 国内流量,no-resolve + - IP-CIDR,120.53.53.53/32,🇨🇳 国内流量,no-resolve +{STUN_PROTOCOL_RULE} + # --- [P0] 规则更新 / GitHub raw 走代理,避免大陆网络下规则集刷新失败 --- + - DOMAIN-SUFFIX,raw.githubusercontent.com,🤖 AI 隐私出口 + + # --- [P1] Claude / Anthropic 及其依赖固定到同一个 IPv4 Xray 出口 --- + # Stash 不接受 Mihomo 的数组型 inline rule-provider payload;静态锚点直接放在 rules 中。 + # 这些依赖规则必须早于动态 AI、广告拦截和公网直连规则。 + - DOMAIN-KEYWORD,stun,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,anthropic.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,clau.de,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,claude.ai,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,claude.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,claudemcpclient.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,claudemcpcontent.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,claudeusercontent.com,🤖 AI 隐私出口 + - DOMAIN,servd-anthropic-website.b-cdn.net,🤖 AI 隐私出口 + - DOMAIN,anthropic.com.cdn.cloudflare.net,🤖 AI 隐私出口 + - DOMAIN,anthropic.auth0.com,🤖 AI 隐私出口 + - DOMAIN,anthropic-com.ghost.io,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,sentry.io,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,statsigapi.net,🤖 AI 隐私出口 + - DOMAIN,browser-intake-us5-datadoghq.com,🤖 AI 隐私出口 + - DOMAIN-KEYWORD,datadog,🤖 AI 隐私出口 + - DOMAIN-KEYWORD,sentry,🤖 AI 隐私出口 + - DOMAIN-KEYWORD,sift,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,intercom.io,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,intercomcdn.com,🤖 AI 隐私出口 + - DOMAIN,cdn.usefathom.com,🤖 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 隐私出口 + - DOMAIN-SUFFIX,openai.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,chatgpt.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,oaistatic.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,oaiusercontent.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,gemini.google.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,aistudio.google.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,generativelanguage.googleapis.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,notebooklm.google.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,perplexity.ai,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,cursor.com,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,cursor.sh,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,chat.com,🤖 AI 隐私出口 + - DOMAIN,challenges.cloudflare.com,🤖 AI 隐私出口 + - DOMAIN,accounts.google.com,🤖 AI 隐私出口 + - DOMAIN,oauth2.googleapis.com,🤖 AI 隐私出口 + # OpenAI 官方网络白名单(2026-09-05):认证、功能开关、上传、支付及 Apple 集成。 + # https://help.openai.com/en/articles/9247338-network-recommendations-for-chatgpt-errors-on-web + - DOMAIN-SUFFIX,ct.sendgrid.net,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,featuregates.org,🤖 AI 隐私出口 + - DOMAIN-SUFFIX,statsig.com,🤖 AI 隐私出口 + - DOMAIN,featureassets.org,🤖 AI 隐私出口 + - DOMAIN,prodregistryv2.org,🤖 AI 隐私出口 + - DOMAIN,cdn.openaimerge.com,🤖 AI 隐私出口 + - DOMAIN,cdn.workos.com,🤖 AI 隐私出口 + - DOMAIN,forwarder.workos.com,🤖 AI 隐私出口 + - DOMAIN,setup.workos.com,🤖 AI 隐私出口 + - DOMAIN,images.workoscdn.com,🤖 AI 隐私出口 + - DOMAIN,workos.imgix.net,🤖 AI 隐私出口 + - DOMAIN,humb.apple.com,🤖 AI 隐私出口 + - DOMAIN,js.stripe.com,🤖 AI 隐私出口 + - RULE-SET,ai,🤖 AI 隐私出口 + - RULE-SET,google,🌐 代理流量 + + # --- [P2] iOS / Apple 海外能力:Siri 与 iCloud Private Relay 相关域名走代理 --- + - RULE-SET,siri,🌐 代理流量 + - DOMAIN,guzzoni.smoot.apple.com,🌐 代理流量 + - DOMAIN,probe.siri.apple.com,🌐 代理流量 + - DOMAIN,seed.siri.apple.com,🌐 代理流量 + - DOMAIN,seed-sequoia.siri.apple.com,🌐 代理流量 + - DOMAIN,seed-swallow.siri.apple.com,🌐 代理流量 + - DOMAIN,sequoia.apple.com,🌐 代理流量 + - DOMAIN,swallow.apple.com,🌐 代理流量 + - RULE-SET,icloud-private-relay,🌐 代理流量 + + # --- [P3] 业务/归因/广告平台保护:必须放在广告拦截前,避免误杀 --- + - DOMAIN-SUFFIX,tradingview.com,🌐 代理流量 + - DOMAIN-SUFFIX,applovin.com,🌐 代理流量 + - DOMAIN-SUFFIX,applvn.com,🌐 代理流量 + - DOMAIN-SUFFIX,applovinedge.com,🌐 代理流量 + - DOMAIN-SUFFIX,appsflyer.com,🌐 代理流量 + - DOMAIN-SUFFIX,adjust.com,🌐 代理流量 + - DOMAIN-SUFFIX,adj.st,🌐 代理流量 + - DOMAIN-SUFFIX,kochava.com,🌐 代理流量 + - DOMAIN-SUFFIX,branch.io,🌐 代理流量 + - DOMAIN-SUFFIX,singular.net,🌐 代理流量 + - DOMAIN-SUFFIX,ads.google.com,🌐 代理流量 + - DOMAIN-SUFFIX,adwords.google.com,🌐 代理流量 + - DOMAIN-SUFFIX,analytics.google.com,🌐 代理流量 + - DOMAIN-SUFFIX,googletagmanager.com,🌐 代理流量 + - DOMAIN-SUFFIX,googleadservices.com,🌐 代理流量 + - DOMAIN-SUFFIX,googlesyndication.com,🌐 代理流量 + - DOMAIN-SUFFIX,googletagservices.com,🌐 代理流量 + - DOMAIN-SUFFIX,ads.tiktok.com,🌐 代理流量 + - DOMAIN-SUFFIX,business.tiktok.com,🌐 代理流量 + + # --- [P4] 原手写保留项:未确认是否仍需直连,先按旧配置保守保留 --- + - DOMAIN-KEYWORD,spotify,↪️ 直连流量 + - DOMAIN-SUFFIX,scdn.co,↪️ 直连流量 + + # --- [P5] 轻量广告拦截 --- + - RULE-SET,ads-lite,🛑 屏蔽流量 + + # --- [P6] Apple 基础服务直连:放在 Siri/Private Relay 后,避免海外能力被直连抢走 --- + - RULE-SET,icloud,↪️ 直连流量 + - RULE-SET,apple-cn,↪️ 直连流量 + + # --- [P7] 通讯 / 海外 App --- + - RULE-SET,telegram,🌐 代理流量 + - RULE-SET,telegram-ip,🌐 代理流量,no-resolve + - RULE-SET,tiktok,🌐 代理流量 + + # --- [P8] 内网始终直连;公开 CN 流量可在客户端手动切换 --- + - RULE-SET,private,DIRECT + - RULE-SET,private-ip,DIRECT,no-resolve + # 部分国内产品未及时进入 geosite:cn;先按 .cn 后缀送入国内流量组。 + - DOMAIN-SUFFIX,cn,🇨🇳 国内流量 + - RULE-SET,cn,🇨🇳 国内流量 + - RULE-SET,cn-ip,🇨🇳 国内流量,no-resolve + - GEOIP,LAN,DIRECT,no-resolve + - GEOIP,CN,🇨🇳 国内流量,no-resolve + + # --- [P9] 兜底 --- + - MATCH,🎯 兜底策略 diff --git a/core/client_output.py b/core/client_output.py new file mode 100644 index 0000000..ca2994c --- /dev/null +++ b/core/client_output.py @@ -0,0 +1,95 @@ +"""Write client files with exact profile ownership; never glob-delete YAML.""" +import fcntl +import hashlib +import json +import os +from pathlib import Path +import tempfile + + +def atomic_write(path, text): + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as handle: + temporary = Path(handle.name) + try: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + except BaseException: + temporary.unlink(missing_ok=True) + raise + try: + temporary.chmod(0o600) + temporary.replace(path) + finally: + temporary.unlink(missing_ok=True) + + +def write_outputs(directory, profile, rendered, *, check=False): + directory = Path(directory) + if check: + if directory.is_symlink(): + return False + manifest = directory / ".network-node-outputs.json" + if manifest.is_symlink(): + return False + if manifest.exists(): + owners = json.loads(manifest.read_text()) + if not isinstance(owners, dict): + raise ValueError("输出归属清单格式错误") + if profile in owners and set(owners[profile]) != {path.name for path in rendered}: + return False + return all(path.is_file() and not path.is_symlink() and path.read_text() == text + for path, text in rendered.items()) + if directory.is_symlink(): + raise ValueError("客户端输出目录不能是符号链接") + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + directory.chmod(0o700) + manifest_path = directory / ".network-node-outputs.json" + lock_path = directory / ".network-node-outputs.lock" + if manifest_path.is_symlink() or lock_path.is_symlink(): + raise ValueError("输出归属清单/锁不能是符号链接") + with lock_path.open("a") as lock: + lock_path.chmod(0o600) + fcntl.flock(lock, fcntl.LOCK_EX) + owners = json.loads(manifest_path.read_text()) if manifest_path.exists() else {} + if not isinstance(owners, dict): + raise ValueError("输出归属清单格式错误") + for owner, files in owners.items(): + if not isinstance(files, dict) or any(Path(name).name != name or not name.endswith(".yaml") for name in files): + raise ValueError("输出归属清单包含无效文件名") + if owner != profile and any(path.name in files for path in rendered): + raise ValueError("客户端文件名属于另一个 profile,请使用独立 CLIENT_FILE_PREFIX") + for path in rendered: + if path.is_symlink(): + raise ValueError("客户端文件不能是符号链接") + if path.exists(): + header = next((line for line in path.read_text().splitlines()[:5] if line.startswith("# Profile: ")), None) + if header and header != f"# Profile: {profile}": + raise ValueError("客户端文件属于另一个 profile") + previous = owners.get(profile, {}) + # Stage all files before publishing any of them. Legacy files not recorded in + # the manifest are deliberately left alone: their ownership is ambiguous. + staged = [] + try: + for path, text in rendered.items(): + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=directory, delete=False) as handle: + tmp = Path(handle.name) + staged.append((tmp, path)) + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + tmp.chmod(0o600) + for temporary, path in staged: + temporary.replace(path) + owners[profile] = {path.name: hashlib.sha256(text.encode()).hexdigest() for path, text in rendered.items()} + atomic_write(manifest_path, json.dumps(owners, indent=2, ensure_ascii=False) + "\n") + for name, digest in previous.items(): + path = directory / name + if path not in rendered and path.is_file() and not path.is_symlink(): + # Preserve files edited manually since our last generation. + if hashlib.sha256(path.read_bytes()).hexdigest() == digest: + path.unlink() + finally: + for temporary, _ in staged: + temporary.unlink(missing_ok=True) + return True diff --git a/core/client_policy.py b/core/client_policy.py new file mode 100644 index 0000000..846bf80 --- /dev/null +++ b/core/client_policy.py @@ -0,0 +1,73 @@ +"""Client-specific settings and strict AI routing over one shared template.""" +import re + + +def remove_section(config, start, end): + first = config.index(start) + last = config.index(end, first) + return config[:first] + config[last:] + + +def adapt_config(config, target, strict, ai_nodes): + if target == "stash": + # Stash manages its own TUN and protocol sniffing. + config = remove_section(config, "geodata-mode: true\n", "skip-proxy:\n") + config = remove_section(config, "tun:\n", "dns:\n") + # Stash's probe URL/timeout belong to each proxy, not the group. + before, proxies = config.split('\nproxies:\n', 1) + proxies, after = proxies.split('\nproxy-groups:\n', 1) + proxies = re.sub(r'(?m)^( type: .+)$', r'\1\n benchmark-url: http://www.gstatic.com/generate_204\n benchmark-timeout: 5', proxies) + config = before + '\nproxies:\n' + proxies + '\nproxy-groups:\n' + after + else: + config = remove_section(config, "skip-proxy:\n", "tun:\n") + if not strict: + return config + + config = config.replace(" - captive.apple.com\n", "") + config = remove_section(config, " # 国内流量使用国内加密 DNS", "\nproxies:\n") + first = config.index("proxy-groups:\n") + last = config.index("rule-providers:\n", first) + nodes = "\n".join(f' - "{name}"' for name in ai_nodes) + config = config[:first] + f'''proxy-groups: + - name: "🤖 AI 隐私出口" + type: fallback + lazy: false + url: https://www.gstatic.com/generate_204 + interval: 60 + proxies: +{nodes} + - name: "🛑 屏蔽流量" + type: select + proxies: + - REJECT + - "🤖 AI 隐私出口" + +''' + config[last:] + + prefix, rules = config.split("\nrules:\n", 1) + strict_rules = [] + for line in rules.splitlines(): + if not line.startswith(" - "): + continue + fields = line[4:].split(",") + kind = fields[0] + if kind == "RULE-SET" and fields[1] not in ("ai", "ads-lite"): + continue + if kind in ("GEOSITE", "GEOIP", "IP-ASN") or fields[:2] == ["DOMAIN-SUFFIX", "cn"]: + continue + target_index = 1 if kind == "MATCH" else 2 + if fields[target_index] not in ("DIRECT", "🛑 屏蔽流量"): + fields[target_index] = "🤖 AI 隐私出口" + strict_rules.append(" - " + ",".join(fields)) + rules = "\n".join(strict_rules) + "\n" + + # Only AI/ad sets are needed; strict routing has no implicit GeoSite/ASN DB. + before, providers = prefix.split("rule-providers:\n", 1) + used = set(re.findall(r"RULE-SET,([^,]+),", rules)) + blocks = re.findall(r"(?m)^ ([a-z][a-z0-9-]*):\n(.*?)(?=^ [a-z][a-z0-9-]*:|\Z)", providers, re.S) + retained = [] + for name, body in blocks: + if name in used: + body = "\n".join(line for line in body.splitlines() if not line.lstrip().startswith("#")) + retained.append(f" {name}:\n{body.rstrip()}\n") + return before + "rule-providers:\n" + "\n".join(retained) + "\nrules:\n" + rules diff --git a/core/cloudflare.sh b/core/cloudflare.sh index e7ee43c..762ad50 100755 --- a/core/cloudflare.sh +++ b/core/cloudflare.sh @@ -40,16 +40,13 @@ cf_api() { fi if [ "$attempt" -lt 3 ]; then warn "CF API $method $path 失败(HTTP $http),${delay}s 后重试..."; sleep "$delay"; delay=$((delay*2)); fi done - # 最终失败:打印 CF 的错误信息(不含 token) + # Only report numeric error codes; upstream error bodies can echo request data. local msg msg="$(printf '%s' "$resp" | python3 -c 'import sys,json try: - d=json.load(sys.stdin); print("; ".join(e.get("message","") for e in d.get("errors",[])) or d) + d=json.load(sys.stdin); print(",".join(str(e["code"]) for e in d.get("errors",[]) if isinstance(e.get("code"), int)) or "unknown") except Exception: print("无法解析响应")' 2>/dev/null)" - case "$msg" in - *9109*|*[Aa]uthentication*|*[Tt]oken*) die "CF API 鉴权失败:$msg(确认 token 含 Account>Cloudflare Tunnel:Edit + Zone>DNS:Edit)" ;; - *) die "CF API $method $path 失败:$msg" ;; - esac + die "CF API $method $path 失败(HTTP ${http},错误码 ${msg});请检查网络及 Token 权限" } jget() { python3 -c "import sys,json;print(json.load(sys.stdin)$1)" 2>/dev/null; } @@ -81,13 +78,18 @@ if [ -z "$TUNNEL_ID" ] || [ "$TUNNEL_ID" = "None" ]; then TUNNEL_ID="$(printf '%s' "$CREATE_JSON" | jget '["result"]["id"]')" ok "新建 tunnel:$TUNNEL_ID" else + SAVED_TUNNEL_ID="$(secret_get CDN_TUNNEL_ID)" + [ "$SAVED_TUNNEL_ID" = "$TUNNEL_ID" ] \ + || die "同名 Tunnel 已存在,但不属于当前 profile 的已保存状态;请使用独立 CDN_TUNNEL_NAME,迁移时先确认资源归属" ok "复用已存在 tunnel:$TUNNEL_ID" fi [ -n "$TUNNEL_ID" ] && [ "$TUNNEL_ID" != "None" ] || die "未取得 tunnel id" +# Persist ownership before subsequent API steps, so a retry can safely reuse it. +setkv CDN_TUNNEL_ID "$TUNNEL_ID" -say "[CF 3/5] 配置 ingress:$CDN_HOSTNAME -> http://localhost:8080" +say "[CF 3/5] 配置 ingress:$CDN_HOSTNAME -> http://127.0.0.1:8080" cf_api PUT "/accounts/$ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/configurations" \ - "{\"config\":{\"ingress\":[{\"hostname\":\"$CDN_HOSTNAME\",\"service\":\"http://localhost:8080\"},{\"service\":\"http_status:404\"}]}}" >/dev/null + "{\"config\":{\"ingress\":[{\"hostname\":\"$CDN_HOSTNAME\",\"service\":\"http://127.0.0.1:8080\"},{\"service\":\"http_status:404\"}]}}" >/dev/null ok "ingress 已设置" say "[CF 4/5] 配置 DNS:$CDN_HOSTNAME CNAME -> $TUNNEL_ID.cfargotunnel.com (proxied)" @@ -98,6 +100,9 @@ if [ -z "$REC_ID" ] || [ "$REC_ID" = "None" ]; then cf_api POST "/zones/$ZONE_ID/dns_records" "$DNS_BODY" >/dev/null ok "DNS 记录已创建" else + DNS_TARGET="$(printf '%s' "$DNS_JSON" | jget '["result"][0]["content"]')" + [ "$DNS_TARGET" = "$TUNNEL_ID.cfargotunnel.com" ] \ + || die "同名 DNS 指向另一条 Tunnel;已停止覆盖,请先确认域名归属" cf_api PUT "/zones/$ZONE_ID/dns_records/$REC_ID" "$DNS_BODY" >/dev/null ok "DNS 记录已更新" fi diff --git a/core/common.sh b/core/common.sh index e4f7fce..2217116 100755 --- a/core/common.sh +++ b/core/common.sh @@ -16,22 +16,35 @@ warn() { printf '\033[1;33m! %s\033[0m\n' "$*" >&2; } die() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; } # --- config / secrets loading --- -load_conf() { if [ -f "$CONF_FILE" ]; then set -a; . "$CONF_FILE"; set +a; fi; return 0; } -load_secrets() { if [ -f "$SECRETS_FILE" ]; then set -a; . "$SECRETS_FILE"; set +a; fi; return 0; } +load_conf() { + local values + values="$(python3 "$PROJECT_DIR/core/settings.py" conf-shell "$STATE_DIR")" || return + eval "$values" +} +load_secrets() { + local values + values="$(python3 "$PROJECT_DIR/core/settings.py" settings-shell "$STATE_DIR")" || return + eval "$values" +} # secret_get KEY -> prints current value from .secrets.env (empty if absent) secret_get() { [ -f "$SECRETS_FILE" ] || return 0 - { grep -E "^$1=" "$SECRETS_FILE" || true; } | tail -1 | cut -d= -f2- + python3 - "$PROJECT_DIR/core" "$SECRETS_FILE" "$1" <<'PY' +import sys +sys.path.insert(0, sys.argv[1]) +from settings import load_kv +print(load_kv(sys.argv[2]).get(sys.argv[3], ""), end="") +PY } # setkv KEY VALUE -> replace-or-add in .secrets.env and export into env setkv() { local k="$1" v="$2" tmp touch "$SECRETS_FILE"; chmod 600 "$SECRETS_FILE" - tmp="$(mktemp)" + tmp="$(mktemp "$STATE_DIR/.secrets.XXXXXX")" grep -vE "^$k=" "$SECRETS_FILE" > "$tmp" 2>/dev/null || true - printf '%s=%s\n' "$k" "$v" >> "$tmp" + printf '%s' "$v" | python3 -c 'import shlex, sys; print(sys.argv[1] + "=" + shlex.quote(sys.stdin.read()))' "$k" >> "$tmp" mv "$tmp" "$SECRETS_FILE"; chmod 600 "$SECRETS_FILE" export "$k=$v" } diff --git a/core/deploy.sh b/core/deploy.sh index ab4a281..a0bd182 100755 --- a/core/deploy.sh +++ b/core/deploy.sh @@ -43,7 +43,7 @@ cleanup_deploy_tmp() { } redact_server_output() { - sed -E 's/^(REALITY_PUBLIC_KEY=).*/\1[redacted]/' + sed -E 's/^(REALITY_PUBLIC_KEY=|HY2_CERT_SHA256=).*/\1[redacted]/' } run_deploy() { @@ -56,6 +56,12 @@ run_deploy() { fi provider_configure load_conf + python3 "$PROJECT_DIR/core/settings.py" validate "$STATE_DIR" + # CDN-only must first work from this client network while direct ingress is + # still available. A first deployment therefore uses the dual-entry mode. + if [ "${CDN_ONLY:-false}" = "true" ]; then + python3 "$PROJECT_DIR/core/cdn_probe.py" "$STATE_DIR" + fi REALITY_TARGET="${REALITY_TARGET:-${REALITY_SNI}:443}" export REALITY_TARGET if [ "${CDN_ONLY:-false}" = "true" ] && [ "${CDN_ENABLE:-false}" != "true" ]; then @@ -71,6 +77,9 @@ run_deploy() { bash "$PROJECT_DIR/core/secrets.sh" load_secrets + # Validate generated ports/state as well, before any external mutation. + python3 "$PROJECT_DIR/core/settings.py" validate "$STATE_DIR" + # Cloudflare setup is local/API-only. Do it before touching the host so a # missing permission or unreachable API cannot leave a half-updated server. if [ "${CDN_ENABLE:-false}" = "true" ]; then @@ -100,8 +109,19 @@ run_deploy() { pub="$(echo "$srv_out" | grep '^REALITY_PUBLIC_KEY=' | tail -1 | cut -d= -f2)" [ -n "$pub" ] || die "未能取回 Reality 公钥" setkv REALITY_PUBLIC "$pub" + local cert_fingerprint + cert_fingerprint="$(printf '%s\n' "$srv_out" | sed -n 's/^HY2_CERT_SHA256=//p' | tail -1)" + if [ -n "$cert_fingerprint" ]; then + setkv HY2_CERT_SHA256 "$cert_fingerprint" + fi + + if [ "${CDN_ENABLE:-false}" = "true" ]; then + say "从本机验证 CDN 的 TLS / WebSocket / VLESS / HTTPS 完整链路" + python3 "$PROJECT_DIR/core/cdn_probe.py" "$STATE_DIR" \ + || die "CDN 端到端验收失败;服务器可能已更新,本次未发布客户端 YAML。请检查后重试" + fi - say "生成 Clash/Mihomo 配置" + say "生成目标客户端配置(${CLIENT_TARGET:-stash})" NETWORK_NODE_ROOT="$PROJECT_DIR" \ NETWORK_NODE_STATE_DIR="$STATE_DIR" \ NETWORK_NODE_CLIENTS_DIR="$CLIENTS_DIR" \ @@ -124,4 +144,5 @@ run_deploy() { echo " CDN : $CDN_HOSTNAME" fi echo " 配置文件 : $CLIENTS_DIR/${CLIENT_FILE_PREFIX:-$PROFILE_NAME}-*.yaml" + echo " 凭据位置 : ${SECRETS_FILE}(工具自动管理,无需记忆;不要复制到聊天)" } diff --git a/core/download.sh b/core/download.sh index 59a1f81..2c140b0 100755 --- a/core/download.sh +++ b/core/download.sh @@ -20,6 +20,61 @@ fetch_url() { curl_retry "$1" } +# SHA256 digests verified against official release asset metadata on 2026-09-05. +# Updating a component version requires updating its reviewed digest here as well. +release_sha256() { + case "$1" in + https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-64.zip) echo 23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae ;; + https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-arm64-v8a.zip) echo 4d30283ae614e3057f730f67cd088a42be6fdf91f8639d82cb69e48cde80413c ;; + https://github.com/apernet/hysteria/releases/download/app/v2.10.0/hysteria-linux-amd64) echo 04f7804159ef1d798de12a817d73aab4b9040ebe45fc62e223000c5c59e987fe ;; + https://github.com/apernet/hysteria/releases/download/app/v2.10.0/hysteria-linux-arm64) echo 8995b33085f7b07769955e23c1c53468064ebf6c408b1d7b663044556898426a ;; + https://github.com/anytls/anytls-go/releases/download/v0.0.13/anytls_0.0.13_linux_amd64.zip) echo 7e80fc099ea54a71110d256dd60648c47c63c70a3c499eb1f6d7aaa4edb7016f ;; + https://github.com/anytls/anytls-go/releases/download/v0.0.13/anytls_0.0.13_linux_arm64.zip) echo 88cb762c3c8eb56b46a2d8d6feab9c0858655192143fc164874229499246a956 ;; + https://github.com/cloudflare/cloudflared/releases/download/2026.7.2/cloudflared-linux-amd64) echo ec905ea7b7e327ff8abdde8cb64697a2152de74dbcdbf6aec9db8364eb3886cd ;; + https://github.com/cloudflare/cloudflared/releases/download/2026.7.2/cloudflared-linux-arm64) echo 405df476437e027fc6d18729a5a77155c0a33a6082aeee60a799a688f3052e66 ;; + *) echo "组件版本没有已验证的 SHA256;请先更新 core/download.sh" >&2; return 1 ;; + esac +} + +verify_sha256() { + local actual + actual="$(sha256sum < "$1")" || return + [ "${actual%% *}" = "$2" ] +} + +download_release() { + local destination="$1" url="$2" expected cache_file + expected="$(release_sha256 "$url")" || return + if [ -n "${NETWORK_NODE_DOWNLOAD_CACHE:-}" ]; then + mkdir -p "$NETWORK_NODE_DOWNLOAD_CACHE" + cache_file="$NETWORK_NODE_DOWNLOAD_CACHE/$expected" + if [ -f "$cache_file" ] && verify_sha256 "$cache_file" "$expected"; then + cp "$cache_file" "$destination" + return + fi + fi + download_file "$destination" "$url" || return + if ! verify_sha256 "$destination" "$expected"; then + rm -f "$destination" + echo "组件 SHA256 校验失败,已停止安装" >&2 + return 1 + fi + if [ -n "${cache_file:-}" ]; then + cp "$destination" "$cache_file.new" + mv "$cache_file.new" "$cache_file" + fi +} + +install_binary() { + local source="$1" destination="$2" + if [ -f "$destination" ] && cmp -s "$source" "$destination"; then + return + fi + [ ! -f "$destination" ] || cp -p "$destination" "$destination.previous" + install -m 0755 "$source" "$destination.new" + mv "$destination.new" "$destination" +} + print_first_line() { local output output="$("$@")" diff --git a/core/gen-clash.py b/core/gen-clash.py index 2c82d6f..4284574 100755 --- a/core/gen-clash.py +++ b/core/gen-clash.py @@ -5,15 +5,24 @@ .secrets.env (STATIC_IP, REALITY_PUBLIC, REALITY_SHORTID, HY2_PORT, ANYTLS_PORT, ANYTLS_PASS, and per-device REALITY_UUID_ / HY2_PASS_). -Each device gets its OWN Reality UUID and Hysteria2 password so a single device -can be revoked without affecting the others. Primary node is VLESS+Reality; -Hysteria2 and AnyTLS are fallback options for compatible Stash/Mihomo clients. +Reality/Hysteria2 use per-device credentials. AnyTLS uses one automatically +managed password per profile; removing a device rotates it during deployment. +Strict routing uses Reality/CDN only; daily routing also offers HY2/AnyTLS. """ import pathlib import os import re import sys -import tempfile +import argparse +import hashlib +from settings import load_settings, validate +from client_output import write_outputs +from client_policy import adapt_config + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--check", action="store_true", help="只检查输出是否与当前配置一致,不写文件") +parser.add_argument("--client", choices=("stash", "mihomo"), help="客户端目标,默认读取 CLIENT_TARGET/stash") +args = parser.parse_args() ROOT = pathlib.Path(os.environ.get("NETWORK_NODE_ROOT", pathlib.Path(__file__).resolve().parent.parent)) PROFILE = os.environ.get("NETWORK_NODE_PROFILE", "").strip() @@ -26,22 +35,18 @@ OUT_DIR = pathlib.Path(os.environ.get("NETWORK_NODE_CLIENTS_DIR", ROOT / "clash-configs")) -def load_kv(path): - data = {} - if not path.exists(): - return data - for line in path.read_text().splitlines(): - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - k, v = line.split("=", 1) - data[k.strip()] = v.strip().strip('"').strip("'") - return data - - -env = {} -env.update(load_kv(STATE_DIR / "deploy.conf")) -env.update(load_kv(STATE_DIR / ".secrets.env")) +try: + env = load_settings(STATE_DIR) + if args.client: + env["CLIENT_TARGET"] = args.client + validate(env) +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() @@ -69,7 +74,7 @@ def load_kv(path): WARP_ENABLE = env.get("WARP_ENABLE", "false") == "true" WARP_REALITY_PORT = env.get("WARP_REALITY_PORT", "").strip() -PRIVACY_MODE = env.get("PRIVACY_MODE", "true") == "true" +PRIVACY_MODE = env.get("PRIVACY_MODE", "false") == "true" if WARP_ENABLE and CDN_ONLY: sys.exit("ERROR: WARP_ENABLE=true 不能与 CDN_ONLY=true 同时使用(会重新暴露 VPS 入口)") @@ -111,6 +116,13 @@ def load_kv(path): sys.exit("ERROR: HY2_ACME_ENABLE=true 但缺少 HY2_ACME_DOMAIN") HY2_CLIENT_SNI = HY2_ACME_DOMAIN if HY2_ACME_ENABLE else HY2_SNI HY2_SKIP_CERT_VERIFY = "false" if HY2_ACME_ENABLE else "true" +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: @@ -124,6 +136,11 @@ def load_kv(path): 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): @@ -212,421 +229,35 @@ def direct_proxy_blocks(dev_uuid, hy2_password): type: hysteria2 server: {env['STATIC_IP']} {hy2_port} password: "{hy2_password}" - auth: "{hy2_password}" sni: {HY2_CLIENT_SNI} skip-cert-verify: {HY2_SKIP_CERT_VERIFY} alpn: - h3 -{HY2_BW}{hy2_hop}{hy2_obfs}''' +{HY2_PIN}{HY2_BW}{hy2_hop}{hy2_obfs}''' + if CLIENT_TARGET == "stash": + hy2 = hy2.replace(" password:", " auth:") anytls = f''' - name: "US-AnyTLS" type: anytls server: {env['STATIC_IP']} port: {env['ANYTLS_PORT']} password: "{env['ANYTLS_PASS']}" - sni: {HY2_CLIENT_SNI} - skip-cert-verify: {HY2_SKIP_CERT_VERIFY} + sni: {HY2_SNI} + skip-cert-verify: true client-fingerprint: chrome udp: true ''' return reality, hy2, anytls -TEMPLATE = """# Stash-first / Mihomo-compatible config — device: {DEVICE} -# Server: {SERVER_LABEL} - -mixed-port: 7890 -allow-lan: false -mode: rule -log-level: info -ipv6: false -geodata-mode: true -find-process-mode: strict -sniffer: - enable: true - sniff: - HTTP: - ports: - - 80 - - 8080-8880 - override-destination: true - TLS: - ports: - - 443 - - 8443 - QUIC: - ports: - - 443 - - 8443 - -skip-proxy: - - 127.0.0.1 - - 192.168.0.0/16 - - 10.0.0.0/8 - - 172.16.0.0/12 - - 100.64.0.0/10 - - localhost - - "*.local" - - captive.apple.com - -tun: - enable: true - stack: mixed - mtu: 1280 - auto-route: true - auto-detect-interface: true - strict-route: true - dns-hijack: - - "any:53" - - "tcp://any:53" - -dns: - enable: true - listen: 127.0.0.1:1053 - ipv6: false - enhanced-mode: fake-ip - fake-ip-range: 198.18.0.1/16 - # Mihomo 与 Stash 分别使用 respect-rules / follow-rule。 - respect-rules: true - follow-rule: true - fake-ip-filter: - - "*.lan" - - "*.local" - - "*.apple.com" - - "*.apple" - - "app-analytics-services.com" - - "time.*.com" - - "ntp.*.com" - - "*.ntp.org" - - "+.msftconnecttest.com" - - "+.msftncsi.com" - - "localhost.ptlogin2.qq.com" - default-nameserver: - - 223.5.5.5 - - 1.1.1.1 - # 仅用于代理节点域名的 bootstrap,避免 DNS 经代理时递归依赖。 - proxy-server-nameserver: - - https://223.5.5.5/dns-query - - https://1.12.12.12/dns-query - # 业务 DNS 按代理规则出站;IP 形式避免再次解析 DoH 主机名。 - nameserver: - - https://1.1.1.1/dns-query - - https://8.8.8.8/dns-query - # 国内流量使用国内加密 DNS,避免 DIRECT 连接仍拿海外 DoH 的解析结果或明文泄漏。 - nameserver-policy: - '+.cn': - - https://223.5.5.5/dns-query - - https://120.53.53.53/dns-query - 'geosite:cn': - - https://223.5.5.5/dns-query - - https://120.53.53.53/dns-query - -proxies: -{REALITY_PROXY}{HY2_PROXY}{ANYTLS_PROXY} -{WARP_PROXY} -{CDN_PROXY} -proxy-groups: - - name: "🚀 代理策略" - type: select - proxies: - - "🛟 自动故障切换" - - "⚡ 自动测速" - - "🔧 手动选择"{CDN_REF} - - DIRECT - - # AI 与 STUN 只使用共享 Xray IPv4 出口;Reality 失败时可经 CDN 进入同一 Xray。 - - name: "🤖 AI 隐私出口" - type: fallback - lazy: true - url: https://www.gstatic.com/generate_204 - interval: 300 - proxies: -{AI_PROXIES} - - - name: "🛟 自动故障切换" - type: fallback - lazy: true - url: https://www.gstatic.com/generate_204 - interval: 300 - proxies: -{FALLBACK_PROXIES} - - - name: "⚡ 自动测速" - type: url-test - lazy: true - url: https://www.gstatic.com/generate_204 - interval: 600 - tolerance: 150 - proxies: -{AUTO_PROXIES} - - - name: "🔧 手动选择" - type: select - proxies: -{MANUAL_PROXIES} - - DIRECT - - - name: "🌐 代理流量" - type: select - proxies: - - "🚀 代理策略" - - "⚡ 自动测速" - - "🔧 手动选择" -{ALL_PROXIES} - - DIRECT - - - name: "↪️ 直连流量" - type: select - proxies: - - DIRECT - - "🚀 代理策略" - - "🔧 手动选择" - - # PRIVACY_MODE 只决定默认顺序;可在客户端随时切换 CN 流量出口。 - - name: "🇨🇳 国内流量" - type: select - proxies: -{CN_POLICY_OPTIONS} - - - name: "🛑 屏蔽流量" - type: select - proxies: - - REJECT - - DIRECT - - "🚀 代理策略" - - - name: "🎯 兜底策略" - type: select - proxies: - - "🚀 代理策略" - - DIRECT - -rule-providers: - # --- MetaCubeX: AI / Google --- - ai: - type: http - behavior: domain - format: mrs - url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/category-ai-%21cn.mrs" - path: ./ruleset/meta_ai.mrs - interval: 86400 - - google: - type: http - behavior: domain - format: mrs - url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/google.mrs" - path: ./ruleset/meta_google.mrs - interval: 86400 - - # --- blackmatrix7: iOS / Apple 功能补丁 --- - siri: - type: http - behavior: classical - format: yaml - url: "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/Siri/Siri.yaml" - path: ./ruleset/bm7_siri.yaml - interval: 86400 +TEMPLATE_PATH = pathlib.Path(__file__).with_name("client.yaml.tmpl") +TEMPLATE = TEMPLATE_PATH.read_text() - icloud-private-relay: - type: http - behavior: classical - format: yaml - url: "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/iCloudPrivateRelay/iCloudPrivateRelay.yaml" - path: ./ruleset/bm7_icloud_private_relay.yaml - interval: 86400 - - # --- MetaCubeX: Apple 基础直连 --- - icloud: - type: http - behavior: domain - format: mrs - url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/icloud.mrs" - path: ./ruleset/meta_icloud.mrs - interval: 86400 - - apple-cn: - type: http - behavior: domain - format: mrs - url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/apple-cn.mrs" - path: ./ruleset/meta_apple_cn.mrs - interval: 86400 - - # --- blackmatrix7: 轻量广告拦截 --- - ads-lite: - type: http - behavior: classical - format: yaml - url: "https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Clash/AdvertisingLite/AdvertisingLite.yaml" - path: ./ruleset/bm7_ads_lite.yaml - interval: 86400 - - # --- MetaCubeX: 国内直连 --- - private: - type: http - behavior: domain - format: mrs - url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/private.mrs" - path: ./ruleset/meta_private.mrs - interval: 86400 - - private-ip: - type: http - behavior: ipcidr - format: mrs - url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/private.mrs" - path: ./ruleset/meta_private_ip.mrs - interval: 86400 - - cn: - type: http - behavior: domain - format: mrs - url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/cn.mrs" - path: ./ruleset/meta_cn.mrs - interval: 86400 - - cn-ip: - type: http - behavior: ipcidr - format: mrs - url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/cn.mrs" - path: ./ruleset/meta_cn_ip.mrs - interval: 86400 - - # --- MetaCubeX: 通讯 / 海外服务 --- - telegram: - type: http - behavior: domain - format: mrs - url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/telegram.mrs" - path: ./ruleset/meta_telegram.mrs - interval: 86400 - - telegram-ip: - type: http - behavior: ipcidr - format: mrs - url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/telegram.mrs" - path: ./ruleset/meta_telegram_ip.mrs - interval: 86400 - - tiktok: - type: http - behavior: domain - format: mrs - url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/tiktok.mrs" - path: ./ruleset/meta_tiktok.mrs - interval: 86400 - -rules: - # --- [P0] 规则更新 / GitHub raw 走代理,避免大陆网络下规则集刷新失败 --- - - DOMAIN-SUFFIX,raw.githubusercontent.com,🌐 代理流量 - - # --- [P1] Claude / Anthropic 及其依赖固定到同一个 IPv4 Xray 出口 --- - # Stash 不接受 Mihomo 的数组型 inline rule-provider payload;静态锚点直接放在 rules 中。 - # 这些规则必须早于动态 AI、广告拦截和所有直连规则。 - - DOMAIN-KEYWORD,stun,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,anthropic.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,clau.de,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,claude.ai,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,claude.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,claudemcpclient.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,claudemcpcontent.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,claudeusercontent.com,🤖 AI 隐私出口 - - DOMAIN,servd-anthropic-website.b-cdn.net,🤖 AI 隐私出口 - - DOMAIN,anthropic.com.cdn.cloudflare.net,🤖 AI 隐私出口 - - DOMAIN,anthropic.auth0.com,🤖 AI 隐私出口 - - DOMAIN,anthropic-com.ghost.io,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,sentry.io,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,statsigapi.net,🤖 AI 隐私出口 - - DOMAIN,browser-intake-us5-datadoghq.com,🤖 AI 隐私出口 - - DOMAIN-KEYWORD,datadog,🤖 AI 隐私出口 - - DOMAIN-KEYWORD,sentry,🤖 AI 隐私出口 - - DOMAIN-KEYWORD,sift,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,intercom.io,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,intercomcdn.com,🤖 AI 隐私出口 - - DOMAIN,cdn.usefathom.com,🤖 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 隐私出口 - - DOMAIN-SUFFIX,openai.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,chatgpt.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,oaistatic.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,oaiusercontent.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,gemini.google.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,aistudio.google.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,generativelanguage.googleapis.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,notebooklm.google.com,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,perplexity.ai,🤖 AI 隐私出口 - - DOMAIN-SUFFIX,cursor.com,🤖 AI 隐私出口 - - RULE-SET,ai,🤖 AI 隐私出口 - - RULE-SET,google,🌐 代理流量 - - # --- [P2] iOS / Apple 海外能力:Siri 与 iCloud Private Relay 相关域名走代理 --- - - RULE-SET,siri,🌐 代理流量 - - DOMAIN,guzzoni.smoot.apple.com,🌐 代理流量 - - DOMAIN,probe.siri.apple.com,🌐 代理流量 - - DOMAIN,seed.siri.apple.com,🌐 代理流量 - - DOMAIN,seed-sequoia.siri.apple.com,🌐 代理流量 - - DOMAIN,seed-swallow.siri.apple.com,🌐 代理流量 - - DOMAIN,sequoia.apple.com,🌐 代理流量 - - DOMAIN,swallow.apple.com,🌐 代理流量 - - RULE-SET,icloud-private-relay,🌐 代理流量 - - # --- [P3] 业务/归因/广告平台保护:必须放在广告拦截前,避免误杀 --- - - DOMAIN-SUFFIX,tradingview.com,🌐 代理流量 - - DOMAIN-SUFFIX,applovin.com,🌐 代理流量 - - DOMAIN-SUFFIX,applvn.com,🌐 代理流量 - - DOMAIN-SUFFIX,applovinedge.com,🌐 代理流量 - - DOMAIN-SUFFIX,appsflyer.com,🌐 代理流量 - - DOMAIN-SUFFIX,adjust.com,🌐 代理流量 - - DOMAIN-SUFFIX,adj.st,🌐 代理流量 - - DOMAIN-SUFFIX,kochava.com,🌐 代理流量 - - DOMAIN-SUFFIX,branch.io,🌐 代理流量 - - DOMAIN-SUFFIX,singular.net,🌐 代理流量 - - DOMAIN-SUFFIX,ads.google.com,🌐 代理流量 - - DOMAIN-SUFFIX,adwords.google.com,🌐 代理流量 - - DOMAIN-SUFFIX,analytics.google.com,🌐 代理流量 - - DOMAIN-SUFFIX,googletagmanager.com,🌐 代理流量 - - DOMAIN-SUFFIX,googleadservices.com,🌐 代理流量 - - DOMAIN-SUFFIX,googlesyndication.com,🌐 代理流量 - - DOMAIN-SUFFIX,googletagservices.com,🌐 代理流量 - - DOMAIN-SUFFIX,ads.tiktok.com,🌐 代理流量 - - DOMAIN-SUFFIX,business.tiktok.com,🌐 代理流量 - - # --- [P4] 原手写保留项:未确认是否仍需直连,先按旧配置保守保留 --- - - DOMAIN-KEYWORD,spotify,↪️ 直连流量 - - DOMAIN-SUFFIX,scdn.co,↪️ 直连流量 - - # --- [P5] 轻量广告拦截 --- - - RULE-SET,ads-lite,🛑 屏蔽流量 - - # --- [P6] Apple 基础服务直连:放在 Siri/Private Relay 后,避免海外能力被直连抢走 --- - - RULE-SET,icloud,↪️ 直连流量 - - RULE-SET,apple-cn,↪️ 直连流量 - - # --- [P7] 通讯 / 海外 App --- - - RULE-SET,telegram,🌐 代理流量 - - RULE-SET,telegram-ip,🌐 代理流量,no-resolve - - RULE-SET,tiktok,🌐 代理流量 - - # --- [P8] 内网始终直连;公开 CN 流量可在客户端手动切换 --- - - RULE-SET,private,DIRECT - - RULE-SET,private-ip,DIRECT,no-resolve - # 部分国内产品未及时进入 geosite:cn;先按 .cn 后缀送入国内流量组。 - - DOMAIN-SUFFIX,cn,🇨🇳 国内流量 - - RULE-SET,cn,🇨🇳 国内流量 - - RULE-SET,cn-ip,🇨🇳 国内流量,no-resolve - - GEOIP,LAN,DIRECT,no-resolve - - GEOIP,CN,🇨🇳 国内流量,no-resolve - - # --- [P9] 兜底 --- - - MATCH,🎯 兜底策略 -""" rendered = {} +template_revision = hashlib.sha256( + pathlib.Path(__file__).read_bytes() + TEMPLATE_PATH.read_bytes() + + pathlib.Path(__file__).with_name("settings.py").read_bytes() + + pathlib.Path(__file__).with_name("client_policy.py").read_bytes() +).hexdigest()[:12] for dev in devices: uuid = env.get(f"REALITY_UUID_{dev}") hy2pw = env.get(f"HY2_PASS_{dev}") @@ -658,9 +289,15 @@ def direct_proxy_blocks(dev_uuid, hy2_password): yaml = TEMPLATE.format( DEVICE=dev, + PROFILE_OWNER=PROFILE or FILE_PREFIX, + TARGET_LABEL=CLIENT_TARGET, + STRICT_LABEL=str(AI_STRICT_MODE).lower(), + TEMPLATE_REVISION=template_revision, + 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=( f"{env['STATIC_IP']} | primary: VLESS+Reality:{env['REALITY_PORT']} | " - f"fallback: Hysteria2:{env['HY2_PORT']}/udp, AnyTLS:{env['ANYTLS_PORT']}/tcp" + f"{'reserved' if AI_STRICT_MODE else 'fallback'}: Hysteria2:{env['HY2_PORT']}/udp, AnyTLS:{env['ANYTLS_PORT']}/tcp" if not CDN_ONLY else "Cloudflare Tunnel only" ), @@ -678,37 +315,15 @@ def direct_proxy_blocks(dev_uuid, hy2_password): ALL_PROXIES=node_ref_block(all_nodes), **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 -OUT_DIR.mkdir(parents=True, exist_ok=True, mode=0o700) -OUT_DIR.chmod(0o700) -temporary = [] try: - for path, yaml in rendered.items(): - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=OUT_DIR, - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as handle: - handle.write(yaml) - handle.flush() - os.fsync(handle.fileno()) - temporary.append((pathlib.Path(handle.name), path)) - pathlib.Path(handle.name).chmod(0o600) - for temporary_path, path in temporary: - os.replace(temporary_path, path) - print(f" wrote {path.name} ({len(rendered[path])} bytes)") -finally: - for temporary_path, _ in temporary: - temporary_path.unlink(missing_ok=True) - -expected_paths = set(rendered) -for stale in OUT_DIR.glob(f"{FILE_PREFIX}-*.yaml"): - if stale not in expected_paths: - stale.unlink() - -print(f"\n全部 {len(devices)} 份配置已写入 {OUT_DIR}") + current = write_outputs(OUT_DIR, PROFILE or FILE_PREFIX, rendered, check=args.check) +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}") diff --git a/core/net_probe.py b/core/net_probe.py new file mode 100644 index 0000000..0fcedc5 --- /dev/null +++ b/core/net_probe.py @@ -0,0 +1,65 @@ +"""Optional physical-interface probes, including DNS outside a local proxy TUN.""" +import ipaddress +import json +import socket +import subprocess +import sys +import time +from urllib.parse import urlencode + + +def resolve_ipv4(host, interface, timeout): + try: + return [str(ipaddress.IPv4Address(host))] + except ipaddress.AddressValueError: + pass + # Use the same domestic DoH provider as the client bootstrap, with explicit + # routing and TLS verification. System DNS may return a Stash fake IP. + url = 'https://dns.alidns.com/resolve?' + urlencode({'name': host, 'type': 'A'}) + try: + result = subprocess.run(['curl', '--silent', '--show-error', '--fail', '--noproxy', '*', + '--max-time', str(timeout), '--interface', interface, + '--resolve', 'dns.alidns.com:443:223.5.5.5', url], + capture_output=True, text=True, timeout=timeout + 1) + if result.returncode: + raise ValueError() + data = json.loads(result.stdout) + if data.get('Status') != 0: + raise ValueError() + answers = list(dict.fromkeys(str(ipaddress.IPv4Address(row['data'])) + for row in data.get('Answer', []) if row.get('type') == 1)) + if not answers or any(not ipaddress.ip_address(ip).is_global for ip in answers): + raise ValueError() + return answers + except (OSError, ValueError, KeyError, subprocess.TimeoutExpired): + raise ValueError('物理网卡 DoH 解析失败;未使用系统代理 DNS 或 fake IP 回退') from None + + +def connect(host, port, timeout, interface=''): + if not interface: + return socket.create_connection((host, port), timeout=timeout) + index = socket.if_nametoindex(interface) + deadline = time.monotonic() + timeout + addresses = resolve_ipv4(host, interface, min(timeout, 10)) + for address in addresses: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + if sys.platform == 'darwin': + # Darwin SDK netinet/in.h: IP_BOUND_IF = 25. + sock.setsockopt(socket.IPPROTO_IP, 25, index) + elif sys.platform.startswith('linux'): + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, interface.encode() + b'\x00') + else: + raise ValueError('当前平台不支持物理网卡绑定探测') + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError() + sock.settimeout(min(remaining, 5)) + sock.connect((address, port)) + return sock + except OSError: + sock.close() + except BaseException: + sock.close() + raise + raise OSError('物理网卡连接失败') diff --git a/core/profile_lock.py b/core/profile_lock.py new file mode 100644 index 0000000..a5adabd --- /dev/null +++ b/core/profile_lock.py @@ -0,0 +1,30 @@ +"""One local GCP deployment or address operation at a time.""" +from contextlib import contextmanager +import fcntl +from pathlib import Path +import subprocess +import sys + + +@contextmanager +def profile_lock(state): + state = Path(state) + state.mkdir(parents=True, exist_ok=True, mode=0o700) + path = state / '.operation.lock' + if path.is_symlink(): + raise ValueError('profile 锁不能是符号链接') + with path.open('a') as handle: + path.chmod(0o600) + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise ValueError('该 profile 正在部署或更换 IP,请等待操作结束') from None + yield + + +if __name__ == '__main__': + try: + with profile_lock(sys.argv[1]): + sys.exit(subprocess.call(sys.argv[2:])) + except (OSError, ValueError) as exc: + sys.exit(str(exc)) diff --git a/core/secrets.sh b/core/secrets.sh index 9da180f..300f3e5 100755 --- a/core/secrets.sh +++ b/core/secrets.sh @@ -4,6 +4,7 @@ PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" . "$PROJECT_DIR/core/common.sh" load_conf load_secrets +python3 "$PROJECT_DIR/core/settings.py" validate "$STATE_DIR" say "生成/复用本地密钥..." @@ -28,11 +29,16 @@ ensure_port() { current="$(secret_get "$key")" [ -n "$current" ] && return - if command -v shuf >/dev/null 2>&1; then - port="$(shuf -i "${min}-${max}" -n1)" - else - port="$(python3 -c "import random; print(random.randint($min,$max))")" - fi + port="$(python3 - "$min" "$max" <<'PY' +import os, secrets, sys +used = {os.environ.get(key, "") for key in ("REALITY_PORT", "ANYTLS_PORT", "HY2_PORT", "WARP_REALITY_PORT", "VPS_SSH_PORT")} +used.add(os.environ.get("WARP_SOCKS_PORT", "40000")) +if os.environ.get("CDN_ENABLE") == "true": + used.update(("8080", "20241")) +available = [port for port in range(int(sys.argv[1]), int(sys.argv[2]) + 1) if str(port) not in used] +print(secrets.choice(available)) +PY +)" setkv "$key" "$port" } @@ -47,6 +53,31 @@ fi ensure_secret REALITY_SHORTID "$(rand_short)" ensure_secret ANYTLS_PASS "$(rand_psk)" +# The reference AnyTLS server has one password per profile. Revoke it automatically +# when a device disappears, including the first run after upgrading this tool. +previous_devices="$(secret_get ANYTLS_DEVICES)" +if [ -z "$previous_devices" ]; then + previous_devices="$(sed -n 's/^REALITY_UUID_\([A-Za-z0-9_]*\)=.*/\1/p' "$SECRETS_FILE")" +fi +rotated=false +for old_device in $previous_devices; do + case " ${DEVICES:-mac iphone} " in + *" $old_device "*) ;; + *) + if [ "$rotated" = "false" ]; then + setkv ANYTLS_PASS "$(rand_psk)" + warn "设备列表有移除:已自动轮换此 profile 的 AnyTLS 密码;部署后请在剩余设备导入新 YAML" + rotated=true + fi + removed_tmp="$(mktemp "$STATE_DIR/.revoked.XXXXXX")" + grep -vE "^(REALITY_UUID|HY2_PASS|CDN_UUID|WARP_REALITY_UUID)_${old_device}=" "$SECRETS_FILE" > "$removed_tmp" || true + mv "$removed_tmp" "$SECRETS_FILE" + chmod 600 "$SECRETS_FILE" + ;; + esac +done +setkv ANYTLS_DEVICES "${DEVICES:-mac iphone}" + # 每设备独立 Reality UUID 与 Hysteria2 密码(可单独作废) for d in ${DEVICES:-mac iphone}; do ensure_secret "REALITY_UUID_$d" "$(gen_uuid)" @@ -77,8 +108,4 @@ if [ "${HY2_OBFS_ENABLE:-false}" = "true" ]; then ensure_secret HY2_OBFS_PASSWORD "$(rand_psk)" fi -if [ "${HY2_ACME_ENABLE:-false}" = "true" ] && [ -z "$(secret_get HY2_ACME_DNS_TOKEN)" ]; then - warn "HY2_ACME_ENABLE=true 但缺 HY2_ACME_DNS_TOKEN;请把 Cloudflare DNS API token 写入 .secrets.env 后重跑" -fi - -ok "密钥就绪(已写入 .secrets.env)" +ok "密钥由工具自动管理;位置:${SECRETS_FILE}(无需记忆,不显示密码)" diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..8650add --- /dev/null +++ b/core/settings.py @@ -0,0 +1,155 @@ +"""Shared, non-executing parser for local configuration and generated state.""" +import argparse +import os +from pathlib import Path +import re +import shlex +import sys + +NAME = 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 = ( + "VPS_HOST", "VPS_SSH_PORT", "VPS_SSH_KEY", "VPS_ADMIN_USER", + "VPS_BOOTSTRAP_USER", "VPS_SSH_INTERFACE", +) + + +def load_kv(path): + """Read literal KEY=value; never evaluate shell expansions or commands.""" + path = Path(path) + if not path.exists(): + return {} + result = {} + for number, line in enumerate(path.read_text().splitlines(), 1): + line = line.strip() + if not line or line.startswith("#"): + continue + key, sep, raw = line.partition("=") + key = key.strip() + if not sep or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key): + raise ValueError(f"{path.name}:{number}: 无效配置键(未显示内容)") + try: + result[key] = " ".join(shlex.split(raw, comments=True)) + except ValueError: + raise ValueError(f"{path.name}:{number}: 引号未闭合(未显示内容)") from None + return result + + +def load_settings(state_dir): + state_dir = Path(state_dir) + state = load_kv(state_dir / ".secrets.env") + for key, value in load_kv(state_dir / "deploy.conf").items(): + if key in GENERATED_OPTIONS and not value and state.get(key): + continue + state[key] = value + return state + + +def validate(settings, *, deployment=False): + devices = settings.get("DEVICES", "mac iphone").split() + if not devices or len(set(devices)) != len(devices): + raise ValueError("DEVICES 不能为空或重复") + if any(not DEVICE.fullmatch(device) for device in devices): + raise ValueError("设备 ID 仅支持 1-64 位字母、数字、下划线;不支持点或连字符") + if any(not DEVICE.fullmatch(device) for device in settings.get("ANYTLS_DEVICES", "").split()): + raise ValueError("已保存的 AnyTLS 设备清单无效") + 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"): + raise ValueError(f"{key} 只能为 true/false") + for key in ("REALITY_PORT", "HY2_PORT", "ANYTLS_PORT", "WARP_REALITY_PORT", "WARP_SOCKS_PORT"): + value = settings.get(key, "") + if value and (not value.isdecimal() or not 1 <= int(value) <= 65535): + raise ValueError(f"{key} 必须是 1-65535 的整数") + tcp = [settings.get(key) for key in ("REALITY_PORT", "ANYTLS_PORT")] + if settings.get("WARP_ENABLE") == "true": + tcp += [settings.get("WARP_REALITY_PORT"), settings.get("WARP_SOCKS_PORT", "40000")] + if settings.get("CDN_ENABLE") == "true": + tcp += ['8080', '20241'] # Xray WS origin and cloudflared loopback metrics. + tcp = [int(port) for port in tcp if port] + if len(set(tcp)) != len(tcp): + raise ValueError("TCP 监听端口不能重复") + port_range = settings.get("HY2_PORT_RANGE", "") + if port_range: + match = re.fullmatch(r"(\d+)-(\d+)", port_range) + if not match or not 1 <= int(match[1]) <= int(match[2]) <= 65535: + raise ValueError("HY2_PORT_RANGE 必须是有效的起止范围,如 30000-30010") + target = settings.get("CLIENT_TARGET", "stash") + if settings.get('PROBE_INTERFACE') and not re.fullmatch(r'[A-Za-z][A-Za-z0-9_.:-]{0,63}', settings['PROBE_INTERFACE']): + raise ValueError('PROBE_INTERFACE 必须是有效网卡名称,如 en0') + if target not in ("stash", "mihomo"): + raise ValueError("CLIENT_TARGET 只能为 stash/mihomo") + hop = settings.get("HY2_HOP_INTERVAL", "") + if hop: + 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") + 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 地址") + if settings.get("CDN_TUNNEL_NAME") and not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}", settings["CDN_TUNNEL_NAME"]): + raise ValueError("CDN_TUNNEL_NAME 仅支持 1-128 位字母、数字、下划线和连字符") + if settings.get("CDN_WS_PATH") and not re.fullmatch(r"[A-Za-z0-9_./~-]+", settings["CDN_WS_PATH"]): + raise ValueError("CDN_WS_PATH 只能包含 URL 路径字符,不支持空格、引号或查询参数") + for key in ("HY2_UP", "HY2_DOWN"): + if settings.get(key) and not re.fullmatch(r"\d+(?:\.\d+)?(?:\s*[kmg]?bps)?", settings[key], re.I): + raise ValueError(f"{key} 必须是带宽值,如 30 mbps") + if bool(settings.get("HY2_UP")) != bool(settings.get("HY2_DOWN")): + raise ValueError("HY2_UP 与 HY2_DOWN 必须同时设置") + if settings.get("CDN_ONLY") == "true": + if settings.get("CDN_ENABLE") != "true": + raise ValueError("CDN_ONLY=true 必须同时设置 CDN_ENABLE=true") + if settings.get("WARP_ENABLE") == "true": + raise ValueError("WARP_ENABLE=true 不能与 CDN_ONLY=true 同时使用") + if deployment: + if settings.get("CDN_ENABLE") == "true": + required = ("CF_API_TOKEN", "CDN_HOSTNAME", "CDN_TUNNEL_NAME") + if any(not settings.get(key) for key in required): + raise ValueError("CDN 缺少 CF_API_TOKEN/CDN_HOSTNAME/CDN_TUNNEL_NAME") + if settings.get("HY2_ACME_ENABLE") == "true": + required = ("HY2_ACME_DOMAIN", "HY2_ACME_EMAIL", "HY2_ACME_DNS_TOKEN") + if any(not settings.get(key) for key in required): + raise ValueError("ACME 缺少域名、邮箱或 DNS token;已在修改服务器前停止") + if settings.get("HY2_ACME_DNS_PROVIDER", "cloudflare") != "cloudflare": + raise ValueError("ACME 当前仅支持 cloudflare DNS provider") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("conf-shell", "settings-shell", "validate", "connection-shell", "save-connection")) + parser.add_argument("state_dir", type=Path) + args = parser.parse_args() + if args.action == "validate": + validate(load_settings(args.state_dir), deployment=True) + return + if args.action == "save-connection": + import tempfile + path = args.state_dir / "connection.conf" + with tempfile.NamedTemporaryFile(mode="w", dir=args.state_dir, delete=False) as handle: + temporary = Path(handle.name) + for key in CONNECTION_KEYS: + handle.write(f"{key}={shlex.quote(os.environ.get(key, ''))}\n") + temporary.chmod(0o600) + temporary.replace(path) + return + if args.action == "connection-shell": + data = load_kv(args.state_dir / "connection.conf") + # Only fill absent CLI/environment options. Connection data is never executed. + for key in CONNECTION_KEYS: + if data.get(key): + print(f"{key}=${{{key}:-{shlex.quote(data[key])}}}") + return + data = (load_kv(args.state_dir / "deploy.conf") if args.action == "conf-shell" + else load_settings(args.state_dir)) + for key, value in data.items(): + print(f"export {key}={shlex.quote(value)}") + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError) as exc: + sys.exit(f"ERROR: {exc}") diff --git a/core/setup-server.sh b/core/setup-server.sh index 1a802da..f3f9af7 100755 --- a/core/setup-server.sh +++ b/core/setup-server.sh @@ -1,8 +1,13 @@ #!/usr/bin/env bash -# Runs ON the GCP VM. Reads /tmp/server-env.sh for credentials, installs +# Runs on the target VPS/VM. Reads the supplied temporary environment, installs # Xray (VLESS+Reality), Hysteria2, AnyTLS, and optional Cloudflare WARP, then prints # REALITY_PUBLIC_KEY= on stdout so the local deployer can pick it up. set -euo pipefail +if [ "$(id -u)" -ne 0 ]; then + exec sudo bash "$0" "$@" +fi +exec 9>/run/lock/network-node-deploy.lock +flock -n 9 || { echo "另一项部署正在运行;请稍后重试" >&2; exit 1; } export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -11,6 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${1:-/tmp/server-env.sh}" # shellcheck disable=SC1090 . "$ENV_FILE" +umask 077 XRAY_VERSION="${XRAY_VERSION:-v26.3.27}" HYSTERIA_VERSION="${HYSTERIA_VERSION:-app/v2.10.0}" ANYTLS_VERSION="${ANYTLS_VERSION:-0.0.13}" @@ -81,12 +87,9 @@ WARP_PACKAGES="" [ "$WARP_ENABLE" = "true" ] && WARP_PACKAGES="gnupg" sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl unzip xz-utils openssl $WARP_PACKAGES -# Fixed /tmp names can be owned by a different gcloud SSH user after an -# interrupted deployment. Remove only this installer's known artifacts before -# downloading so a later profile rebuild cannot fail with curl exit 23. -sudo rm -f /tmp/xray.zip /tmp/hysteria /tmp/anytls.zip \ - /tmp/cloudflared /tmp/cloudflare-warp-archive-keyring.gpg /tmp/hy2.key -sudo rm -rf /tmp/anytls-extract +INSTALL_TMP="$(mktemp -d)" +trap 'rm -rf "$INSTALL_TMP"' EXIT +export NETWORK_NODE_DOWNLOAD_CACHE=/var/cache/network-node/releases ARCH="$(uname -m)" @@ -96,10 +99,10 @@ case "$ARCH" in aarch64) XRAY_ZIP="Xray-linux-arm64-v8a.zip" ;; *) echo "Unsupported arch: $ARCH"; exit 1 ;; esac -download_file /tmp/xray.zip \ +download_release "${INSTALL_TMP}/xray.zip" \ "https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/${XRAY_ZIP}" -sudo unzip -oq /tmp/xray.zip -d /usr/local/bin xray -sudo chmod 0755 /usr/local/bin/xray +sudo unzip -oq "${INSTALL_TMP}/xray.zip" -d "$INSTALL_TMP" xray +install_binary "$INSTALL_TMP/xray" /usr/local/bin/xray print_first_line /usr/local/bin/xray version echo "=== [4/8] Installing Hysteria2 ===" @@ -107,9 +110,9 @@ case "$ARCH" in x86_64) HY2_BIN="hysteria-linux-amd64" ;; aarch64) HY2_BIN="hysteria-linux-arm64" ;; esac -download_file /tmp/hysteria \ +download_release "${INSTALL_TMP}/hysteria" \ "https://github.com/apernet/hysteria/releases/download/${HYSTERIA_VERSION}/${HY2_BIN}" -sudo install -m 0755 /tmp/hysteria /usr/local/bin/hysteria +install_binary "${INSTALL_TMP}/hysteria" /usr/local/bin/hysteria print_first_line /usr/local/bin/hysteria version echo "=== [5/8] Installing AnyTLS ===" @@ -119,11 +122,11 @@ case "$ARCH" in esac AT_VER="$ANYTLS_VERSION" [ -n "$AT_VER" ] || { echo "ANYTLS_VERSION 不能为空"; exit 1; } -download_file /tmp/anytls.zip \ +download_release "${INSTALL_TMP}/anytls.zip" \ "https://github.com/anytls/anytls-go/releases/download/v${AT_VER}/anytls_${AT_VER}_linux_${AT_ARCH}.zip" -sudo rm -rf /tmp/anytls-extract -sudo unzip -oq /tmp/anytls.zip -d /tmp/anytls-extract -sudo install -m 0755 /tmp/anytls-extract/anytls-server /usr/local/bin/anytls-server +sudo rm -rf "${INSTALL_TMP}/anytls-extract" +sudo unzip -oq "${INSTALL_TMP}/anytls.zip" -d "${INSTALL_TMP}/anytls-extract" +install_binary "${INSTALL_TMP}/anytls-extract/anytls-server" /usr/local/bin/anytls-server echo "anytls-server v${AT_VER} installed" if [ "${CDN_ENABLE:-false}" = "true" ]; then @@ -132,17 +135,17 @@ if [ "${CDN_ENABLE:-false}" = "true" ]; then x86_64) CF_BIN="cloudflared-linux-amd64" ;; aarch64) CF_BIN="cloudflared-linux-arm64" ;; esac - download_file /tmp/cloudflared \ + download_release "${INSTALL_TMP}/cloudflared" \ "https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${CF_BIN}" - sudo install -m 0755 /tmp/cloudflared /usr/local/bin/cloudflared + install_binary "${INSTALL_TMP}/cloudflared" /usr/local/bin/cloudflared print_first_line /usr/local/bin/cloudflared --version fi if [ "$WARP_ENABLE" = "true" ]; then echo "=== [5c] Installing Cloudflare WARP proxy ===" curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg \ - | gpg --yes --dearmor --output /tmp/cloudflare-warp-archive-keyring.gpg - sudo install -m 0644 /tmp/cloudflare-warp-archive-keyring.gpg \ + | gpg --yes --dearmor --output "${INSTALL_TMP}/cloudflare-warp-archive-keyring.gpg" + sudo install -m 0644 "${INSTALL_TMP}/cloudflare-warp-archive-keyring.gpg" \ /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg echo 'deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ bookworm main' \ | sudo tee /etc/apt/sources.list.d/cloudflare-client.list > /dev/null @@ -314,8 +317,8 @@ if [ "$WARP_ENABLE" = "true" ]; then }' fi -sudo mkdir -p /usr/local/etc/xray -sudo tee /usr/local/etc/xray/config.json > /dev/null < /dev/null < /dev/null </dev/null 2>&1 - sudo openssl req -new -x509 -days 3650 -key /tmp/hy2.key \ + sudo openssl ecparam -genkey -name prime256v1 -out "${INSTALL_TMP}/hy2.key" >/dev/null 2>&1 + sudo openssl req -new -x509 -days 3650 -key "${INSTALL_TMP}/hy2.key" \ -out /etc/hysteria/cert.crt -subj "/CN=${HY2_SNI}" >/dev/null 2>&1 - sudo mv /tmp/hy2.key /etc/hysteria/cert.key + sudo mv "${INSTALL_TMP}/hy2.key" /etc/hysteria/cert.key fi sudo chown root:hysteria /etc/hysteria/cert.crt /etc/hysteria/cert.key sudo chmod 640 /etc/hysteria/cert.crt /etc/hysteria/cert.key @@ -458,6 +465,8 @@ ProtectSystem=strict ProtectHome=true PrivateTmp=true ReadOnlyPaths=/etc/hysteria +StateDirectory=hysteria +StateDirectoryMode=0700 [Install] WantedBy=multi-user.target @@ -547,7 +556,7 @@ Wants=network-online.target [Service] Type=simple EnvironmentFile=/etc/cloudflared/env -ExecStart=/usr/local/bin/cloudflared --no-autoupdate tunnel run +ExecStart=/usr/local/bin/cloudflared --no-autoupdate tunnel --metrics 127.0.0.1:20241 run Restart=on-failure RestartSec=5 NoNewPrivileges=true @@ -583,15 +592,68 @@ for service in $PROXY_SERVICES $CDN_SERVICES $WARP_SERVICES; do fi done +if [ "${CDN_ENABLE:-false}" = "true" ]; then + echo "=== Waiting for Cloudflare Tunnel connections ===" + tunnel_ready=false + for attempt in $(seq 1 30); do + if curl -fsS --connect-timeout 1 --max-time 2 http://127.0.0.1:20241/metrics \ + | awk '/^cloudflared_tunnel_ha_connections(\{| )/ {if ($NF > 0) ready=1} END {exit !ready}'; then + tunnel_ready=true + break + fi + sleep 2 + done + [ "$tunnel_ready" = true ] || { echo "cloudflared 在运行,但未建立可用 Tunnel 连接" >&2; exit 1; } +fi + echo "=== [8/8] Hardening (SSH + auto-updates) ===" sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq unattended-upgrades sudo dpkg-reconfigure -f noninteractive unattended-upgrades || true -sudo sed -i \ - -e 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' \ - -e 's/^#*PermitRootLogin.*/PermitRootLogin no/' \ - -e 's/^#*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication no/' \ - /etc/ssh/sshd_config -sudo systemctl reload ssh || sudo systemctl reload sshd || true +sudo mkdir -p /etc/ssh/sshd_config.d +sudo cp -p /etc/ssh/sshd_config "$INSTALL_TMP/sshd_config.previous" +SSH_FRAGMENT=/etc/ssh/sshd_config.d/00-network-node.conf +if [ -f "$SSH_FRAGMENT" ]; then + sudo cp -p "$SSH_FRAGMENT" "$INSTALL_TMP/ssh-fragment.previous" +fi +sudo tee "$SSH_FRAGMENT" >/dev/null <<'SSHCONF' +PasswordAuthentication no +PermitRootLogin no +KbdInteractiveAuthentication no +PubkeyAuthentication yes +SSHCONF +# Put our explicit include first; a later distro wildcard include is harmless. +{ printf 'Include %s\n' "$SSH_FRAGMENT"; sudo sed '\|^Include /etc/ssh/sshd_config.d/00-network-node.conf$|d' /etc/ssh/sshd_config; } > "$INSTALL_TMP/sshd_config" +sudo install -m 600 "$INSTALL_TMP/sshd_config" /etc/ssh/sshd_config +restore_ssh_config() { + sudo cp -p "$INSTALL_TMP/sshd_config.previous" /etc/ssh/sshd_config + if [ -f "$INSTALL_TMP/ssh-fragment.previous" ]; then + sudo cp -p "$INSTALL_TMP/ssh-fragment.previous" "$SSH_FRAGMENT" + else + sudo rm -f "$SSH_FRAGMENT" + fi +} +if ! sudo /usr/sbin/sshd -t; then + restore_ssh_config + echo "SSH 配置无效;已恢复原配置,未 reload" >&2 + exit 1 +fi +if ! SSH_EFFECTIVE="$(sudo /usr/sbin/sshd -T)"; then + restore_ssh_config + echo "无法检查 SSH 生效配置;已恢复原配置,未 reload" >&2 + exit 1 +fi +for setting in 'passwordauthentication no' 'permitrootlogin no' 'kbdinteractiveauthentication no' 'pubkeyauthentication yes'; do + if ! printf '%s\n' "$SSH_EFFECTIVE" | grep -Fx "$setting" >/dev/null; then + restore_ssh_config + echo "SSH 加固未生效;已恢复原配置" >&2 + exit 1 + fi +done +if ! sudo systemctl reload ssh && ! sudo systemctl reload sshd; then + restore_ssh_config + echo "SSH reload 失败;已恢复配置,请通过控制台检查" >&2 + exit 1 +fi echo "" echo "=== Listening sockets ===" @@ -608,4 +670,8 @@ fi # machine-readable handoff line — local deployer greps this echo "" echo "REALITY_PUBLIC_KEY=${REALITY_PUBLIC}" +if [ "$HY2_ACME_ENABLE" != "true" ]; then + HY2_FINGERPRINT="$(sudo openssl x509 -in /etc/hysteria/cert.crt -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f')" + echo "HY2_CERT_SHA256=${HY2_FINGERPRINT}" +fi echo "=== DONE ===" diff --git a/deploy-gcp.sh b/deploy-gcp.sh index 5e92166..050bb2c 100755 --- a/deploy-gcp.sh +++ b/deploy-gcp.sh @@ -2,8 +2,13 @@ # Entry point: provision and configure a Google Cloud node. set -euo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROFILE_NAME=gcloud -. "$PROJECT_DIR/core/common.sh" -. "$PROJECT_DIR/providers/gcp.sh" -. "$PROJECT_DIR/core/deploy.sh" -run_deploy "$@" +export PROFILE_NAME=gcloud VPS_PROFILE=gcloud +STATE_DIR="${NETWORK_NODE_STATE_DIR:-$PROJECT_DIR/profiles/gcloud}" +python3 "$PROJECT_DIR/core/profile_lock.py" "$STATE_DIR" bash -c ' + set -euo pipefail + PROJECT_DIR="$1"; shift + . "$PROJECT_DIR/core/common.sh" + . "$PROJECT_DIR/providers/gcp.sh" + . "$PROJECT_DIR/core/deploy.sh" + run_deploy "$@" +' bash "$PROJECT_DIR" "$@" diff --git a/deploy-vps.sh b/deploy-vps.sh index 52223ed..31ddb7c 100755 --- a/deploy-vps.sh +++ b/deploy-vps.sh @@ -14,6 +14,7 @@ usage() { --host HOST 已安装 Debian/Ubuntu 且可 SSH 的主机 --ssh-key PATH 本机 SSH 私钥路径 --ssh-port PORT SSH 端口,默认 22 + --ssh-interface INTERFACE SSH/SCP 绑定本机网络接口(用于绕过本地 TUN) --bootstrap-user USER 首次 root 登录用户,默认 root --admin-user USER 部署后的 sudo 用户,默认 mt --install-key 若新机只有 root 密码,先交互式安装本机公钥 @@ -49,6 +50,8 @@ while [ "$#" -gt 0 ]; do need_value "$@"; VPS_SSH_KEY="$2"; shift 2 ;; --ssh-port) need_value "$@"; VPS_SSH_PORT="$2"; shift 2 ;; + --ssh-interface) + need_value "$@"; VPS_SSH_INTERFACE="$2"; shift 2 ;; --bootstrap-user) need_value "$@"; VPS_BOOTSTRAP_USER="$2"; shift 2 ;; --admin-user) diff --git a/docs/architecture.md b/docs/architecture.md index a3db4be..0894bc5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,7 @@ The repository keeps protocol behaviour and client routing rules in one shared c - `deploy-vps.sh` is the active CStoneCloud/generic Debian/Ubuntu path and requires an explicit profile. - `deploy.sh` is a compatibility alias for the GCP entry point. -All entry points hand control to `core/deploy.sh`. +Deployment entry points hand control to `core/deploy.sh`. `node.py` handles local status, validation, rendering and drift checks without connecting to a server. ## Provider seam @@ -58,4 +58,6 @@ clash-configs/ The entire `profiles/` tree is gitignored. This keeps host lifecycle state and credentials separate while both providers continue to consume the same protocol installer and routing-rule template. -Each profile owns its state and optional host credentials. Generated client YAML is centralized in `clash-configs/` and the generator removes only files prefixed with the active profile name, so running one adapter cannot overwrite another adapter's outputs. The VPS entry point rejects missing or unsafe profile names to prevent accidental cross-provider writes. +Each profile owns its state and optional host credentials. Generated client YAML is centralized in `clash-configs/` and an exact ownership manifest prevents overlapping prefixes from deleting another profile's files. Unknown legacy files and manually edited stale files are retained. The VPS entry point rejects missing or unsafe profile names to prevent accidental cross-provider writes. + +The client pipeline is `settings.py` (literal configuration and validation) → `gen-clash.py` (device nodes) → `client.yaml.tmpl` and `client_policy.py` (shared rules, explicit Stash/Mihomo fields, strict/daily routing) → `client_output.py` (locked output ownership and atomic file replacement). Daily routing is the default: known AI dependencies take priority, ordinary domestic traffic goes direct, and domestic DNS follows the domestic group. Strict mode is opt-in and has two groups and two remote rule sets, routing all public application traffic through the shared Xray IPv4 exit. diff --git a/docs/protocol-stack.md b/docs/protocol-stack.md index 5e8e2f8..a1f1579 100644 --- a/docs/protocol-stack.md +++ b/docs/protocol-stack.md @@ -32,6 +32,12 @@ The template currently uses `1.1.1.1:443` with empty SNI. Xray's official docume Do not mechanically add a fixed rate limit or pick another global CDN hostname. The better follow-up is to choose and validate a target appropriate to the server ASN, then update `REALITY_TARGET` and `REALITY_SNI` per profile. Until that decision is tested on cstone, the existing value remains unchanged to avoid breaking the active node. +## Strict routing update (2026-09-05) + +Daily routing is the default: known AI dependencies use Reality/CDN through the same Xray IPv4 exit, while ordinary domestic traffic defaults to DIRECT. Strict AI routing remains opt-in and sends all public application traffic through that AI exit. HY2 and AnyTLS remain installed and rendered for daily mode but are absent from strict mode's policy groups. AnyTLS still has one password per profile; removing a device automatically rotates it on deployment and requires remaining devices to import fresh YAML. Its reference server still uses a temporary self-signed certificate and a password command-line argument. HY2 ACME is independent of AnyTLS; self-signed HY2 gains certificate pinning after the next successful deployment retrieves its fingerprint. + +Pinned component versions remain unchanged. Updating them requires reviewing and changing the official release SHA256 values in `core/download.sh`; downloads are verified before use and cached by digest. No live server upgrade is implied by these repository changes. + ## AnyTLS exit criterion Do not remove AnyTLS based only on lack of recent manual use. First confirm that clients are not selecting it during a defined observation window and that Reality plus Hysteria cover the required failure modes. If that evidence holds, make AnyTLS opt-in for new profiles, verify cstone migration, and only then remove its firewall rule, binary, unit, secret and generated node as one coordinated change. diff --git a/docs/provider-onboarding.md b/docs/provider-onboarding.md index 207d9cf..d32ddc2 100644 --- a/docs/provider-onboarding.md +++ b/docs/provider-onboarding.md @@ -14,7 +14,11 @@ Use one profile per server. A profile name is a local namespace for the server's --copy-config-from cstonecloud-cuii-a ``` -Add `--install-key` only if the provider did not install the public key. Use a different profile for every server, for example `cstonecloud-cuii-a`, `cstone-next`, or `new-york-01`. Do not run `./deploy-vps.sh` without a profile. When copying a profile, the new profile resets `CLIENT_FILE_PREFIX` to its own name so both generations of client YAML can coexist during cutover. +Add `--install-key` only if the provider did not install the public key. Use a different profile for every server, for example `cstonecloud-cuii-a`, `cstone-next`, or `new-york-01`. Do not run `./deploy-vps.sh` without a profile. Copying a profile resets `CLIENT_FILE_PREFIX`, disables CDN, clears its hostname/path, and assigns a new Tunnel name. Confirm a distinct hostname before enabling CDN on the new server. + +If a local TUN/VPN intercepts SSH traffic, pass the physical interface explicitly, for example `--ssh-interface en0`. This applies `BindInterface` to both SSH and SCP without changing the server or disabling the local proxy. + +Connection options are saved automatically in `profiles//connection.conf`. Later runs only need `./deploy-vps.sh --profile `; explicit CLI options take precedence. Credentials remain in `.secrets.env` and need not be memorized. `python3 node.py status --profile ` shows their location without showing their values. The first run creates `profiles//deploy.conf` and `.secrets.env`, secures the host, creates the `mt` sudo user, installs the shared protocols, and writes: @@ -29,6 +33,16 @@ After the first successful run, root login is disabled and `mt` is the maintenan ssh -i "$HOME/.ssh/cstone_ed25519" mt@ ``` +## Google Cloud account and billing changes + +Use `./deploy-gcp.sh` for the existing Google Cloud profile. Its first successful configuration saves `GCP_ACCOUNT` in `profiles/gcloud/deploy.conf`; preflight verifies that account can refresh its authorization, and provisioning, SSH, and SCP use it explicitly. Switching the global gcloud account does not change the profile's account. SSH private keys stay in the profile's `ssh/` directory. + +If IAP needs the workstation's local proxy, set `GCP_HTTP_PROXY` to its HTTP proxy URL in that profile. The deployment process passes it to gcloud; it does not change client routing or server egress. Keep the local proxy running while using IAP. Leave it empty where direct access works. + +When only promotional credits are expiring, prefer changing the billing account attached to the existing Cloud project. Verify the destination account is open and the credit has been redeemed with a scope covering Compute Engine. Grant the destination user the project roles needed for billing and node administration before changing the billing link. Personal projects can require an invitation to add another Owner; use the required service roles when full ownership transfer is unnecessary. + +Changing the billing link keeps the VM, IP, credentials, and client YAML. Verify the new billing association and node connectivity, then save the intended administration account as `GCP_ACCOUNT`. Do not delete the old VM or project as a billing migration step. Charges incurred before the switch remain on the original billing account; monthly credits and budget alerts do not impose a hard spending cap. + ## Adding another provider lifecycle Use `deploy-vps.sh` when the provider already exposes a reachable Debian/Ubuntu host. Add a new adapter under `providers/` only when the provider needs different lifecycle, authentication, or firewall operations. Keep shared protocol installation, secrets, routing, and client generation in `core/`. @@ -41,9 +55,11 @@ Use `deploy-vps.sh` when the provider already exposes a reachable Debian/Ubuntu - Regenerate one profile's clients with: ```bash -NETWORK_NODE_PROFILE= python3 core/gen-clash.py +python3 node.py render --profile ``` +The default client is Stash with daily split routing (`AI_STRICT_MODE=false`, `PRIVACY_MODE=false`): ordinary domestic traffic goes direct and known AI dependencies take priority on the AI route. Set `CLIENT_TARGET=mihomo` for Mihomo. Strict routing of all public traffic, including domestic sites, requires explicitly setting `AI_STRICT_MODE=true`. Removing a device and deploying automatically rotates the profile's shared AnyTLS password; remaining devices must import fresh YAML. Rendering alone does not revoke server credentials. + - Before pushing, verify: ```bash diff --git a/docs/repository-guide.md b/docs/repository-guide.md index a4e5c38..92b25c0 100644 --- a/docs/repository-guide.md +++ b/docs/repository-guide.md @@ -33,8 +33,11 @@ docs/ 架构、排障和运维说明 | `deploy.sh` | 编排共享部署流程,调用 provider 和客户端生成器 | | `cloudflare.sh` | 创建/复用 Tunnel、配置 Ingress、写入 CNAME 和连接 Token | | `setup-server.sh` | 在远端安装 Xray、Hysteria2、AnyTLS、cloudflared 和 systemd 服务 | -| `download.sh` | 远端二进制下载、重试和超时 | -| `gen-clash.py` | 每个设备生成一份 Stash-first、Mihomo-compatible YAML | +| `download.sh` | 下载、SHA256 校验、缓存和二进制替换 | +| `gen-clash.py` | 每设备生成指定 Stash/Mihomo 目标的节点配置 | +| `settings.py` | 统一字面值解析、校验和 SSH 连接设置保存 | +| `client.yaml.tmpl` / `client_policy.py` | 共享规则、严格/日常分流与客户端字段差异 | +| `client_output.py` | 精确输出归属、写入锁与文件替换 | ## 部署路径 @@ -97,11 +100,11 @@ US-CDN → cdn.example.com → Cloudflare → Tunnel → VPS localhost:8080 - `CDN_ONLY=false` 时,直连节点继续保留,适合先灰度验证 CDN。 - `CDN_ONLY=true` 时,服务端关闭 Reality/Hysteria2/AnyTLS 直连入口,只保留 Cloudflare WS;切换前必须重新生成并导入 YAML。 - `WARP_ENABLE=true` 与 `CDN_ONLY=true` 互斥。 -- `🛟 自动故障切换`、`⚡ 自动测速` 只是客户端策略组,不是额外的服务器节点。 -- `PRIVACY_MODE=true`(默认)让 `🇨🇳 国内流量` 首次默认走代理;客户端可手动切到 `DIRECT`,`false` 则让该组首次默认直连。直连时,`geosite:cn` 域名使用阿里云 / 腾讯云的加密 DoH,避免明文 DNS 和海外解析造成的 CDN 绕路;国内 DoH 服务商仍能看到查询出口 IP。其他域名继续使用经代理路由的 Cloudflare / Google DoH。局域网与原有 Apple/Spotify 规则不受影响。 -- CN 判定依次使用 MetaCubeX `cn` 域名集、`cn-ip` 地址集和 Mihomo `GEOIP,CN` 兜底;AI、Google、Apple、Telegram、广告等更高优先级规则先匹配,`private`/LAN 则始终固定直连。 -- `🤖 AI 隐私出口` 只使用共享 Xray IPv4 出口,按 Reality → CDN(启用时)故障切换;STUN 同组且不受 `PRIVACY_MODE` 影响,避免 AI HTTP 与 WebRTC UDP 因双栈或 WARP 显示不同地址。 -- AI 域名使用 MetaCubeX `category-ai-!cn`;Anthropic/Claude 的核心域名、认证/CDN、监控与第三方组件、Anthropic IP 段/ASN、NTP 和 STUN 使用高优先级静态规则,置于广告拦截及所有直连规则之前。`IP-ASN` 需要客户端加载 ASN 数据库,NTP 需要代理节点支持 UDP;中国 AI 域名仍不在该集合内。 +- 默认 `AI_STRICT_MODE=false`、`PRIVACY_MODE=false`:普通国内流量直连,AI 核心服务及其已知依赖固定 AI 组。AI 规则先于广告和国内域名/IP 规则,未匹配的其他流量默认代理。 +- AI 组按 Reality → CDN(启用时)切换,共享 Xray IPv4 出口。普通海外流量可使用 HY2/AnyTLS 故障切换,WARP 仅供手动选择。Apple 基础服务与 Spotify 保留原直连策略。 +- `PRIVACY_MODE` 决定国内组首次选择;国内 DoH 解析器 IP 显式关联国内组,DNS 连接随该组选择,默认直连。其他业务 DNS 固定走 AI 组;节点域名独立加密解析。 +- 静态内网规则先于 NTP/STUN;既有 AI 监控、认证依赖保护保留,并补充 OpenAI 官方网络白名单。未知第三方请求无法可靠归因到具体页面,漏网依赖需要补精确规则。 +- `AI_STRICT_MODE=true` 仅为可选的全公网 AI 路由:国内也代理,只保留 AI/广告两个策略组及两个动态规则集,AI 线路不可用时公网业务失败。日常使用保持 false。 ## Profile 和文件安全边界 @@ -110,7 +113,8 @@ US-CDN → cdn.example.com → Cloudflare → Tunnel → VPS localhost:8080 ```text profiles// ├── deploy.conf # 本机部署参数 -├── .secrets.env # 端口、UUID、密码、Token +├── .secrets.env # 工具自动管理的端口、UUID、密码、Token +├── connection.conf # SSH 连接参数,无需每次重填 └── ssh/ # 本机 SSH 私钥 ``` @@ -118,7 +122,9 @@ profiles// 公共代码只应依赖环境变量和 profile 状态,不要把某台服务器的 IP、域名、Token 或 SSH 文件写进 `core/`、`providers/`、`README.md` 或测试样例。 -客户端文件默认使用 `-.yaml` 命名。如果某台服务器需要更短的设备文件名,可在该 profile 的 `deploy.conf` 设置 `CLIENT_FILE_PREFIX`;前缀和设备名只允许字母、数字、点、下划线和连字符。生成器先验证全部设备凭据并原子替换每份 YAML,全部新文件就绪后才清理当前前缀的旧文件;这只改变 `clash-configs/` 下的文件前缀和清理范围,不改变 profile 状态目录,也不会让不同服务器共用凭据。 +客户端文件默认使用 `-.yaml` 命名,`CLIENT_FILE_PREFIX` 可指定独立前缀。设备 ID 只支持 1-64 位字母、数字、下划线;前缀还可包含点和连字符。生成器验证全部设备后写入,以精确归属清单清理已登记且未手工修改的旧文件;不会用短前缀通配符删除长前缀 profile 的输出。未知旧文件保留。 + +密码不需要记忆。`python3 node.py status --profile ` 只显示存储位置。移除设备并部署会自动轮换 profile 共享的 AnyTLS 密码,删除移除设备的其他凭据;剩余设备需重新导入新 YAML。仅 `render` 不触发轮换或服务器撤销。 ### 交接时的文件边界 @@ -135,16 +141,16 @@ profiles// ### 重跑同一台服务器 ```bash -./deploy-vps.sh --profile \ - --ssh-key "$PWD/profiles//ssh/id_rsa.pem" +./deploy-vps.sh --profile ``` -重跑会复用本地凭据和已有管理员。只有修改服务端参数、协议凭据或组件版本时,才需要重跑;只改客户端规则时不需要重启服务器。 +重跑会复用本地凭据、已有管理员和已保存的 connection.conf;旧 profile 未保存过连接设置时,首次仍需传入 SSH 参数。只有修改服务端参数、协议凭据或组件版本时,才需要重跑;只改客户端规则时不需要重启服务器。 ### 只重新生成客户端 YAML ```bash -NETWORK_NODE_PROFILE= python3 core/gen-clash.py +python3 node.py render --profile +python3 node.py check --profile ``` ### 启用 CDN @@ -171,7 +177,9 @@ NETWORK_NODE_PROFILE= python3 core/gen-clash.py ```bash python3 -m unittest discover -s tests -p 'test_*.py' -v -bash -n deploy.sh deploy-gcp.sh deploy-vps.sh core/*.sh providers/*.sh +for script in deploy.sh deploy-gcp.sh deploy-vps.sh core/*.sh providers/*.sh; do + bash -n "$script" || exit +done git diff --check git ls-files profiles ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5f634ca..4631038 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -23,60 +23,37 @@ When the client reports the node itself as timeout, check the server address fir ### Checks -Run these locally after loading the GCloud profile state. +Use the profile-aware read-only check. It pins the account/project and compares +the reservation's owner, tier and address against the VM and local state without displaying credentials. ```bash -set -a -. profiles/gcloud/deploy.conf -. profiles/gcloud/.secrets.env -set +a +python3 node.py ip-check --profile gcloud ``` -```bash -gcloud --project "$PROJECT_ID" compute instances describe "$INSTANCE_NAME" \ - --zone "$ZONE" \ - --format="value(networkInterfaces[0].accessConfigs[0].natIP)" -``` - -```bash -grep '^STATIC_IP=' profiles/gcloud/.secrets.env -``` - -The two IPs must match. - -Then confirm the VM and firewall are healthy. +If the cloud reservation and VM disagree, fix the intended binding first. Re-running +deployment now stops on this mismatch instead of publishing the unattached reservation. -```bash -gcloud --project "$PROJECT_ID" compute instances describe "$INSTANCE_NAME" \ - --zone "$ZONE" \ - --format="yaml(name,status,tags.items,networkInterfaces[0].accessConfigs[0].natIP)" -``` - -```bash -gcloud --project "$PROJECT_ID" compute firewall-rules list \ - --filter="name=(allow-proxy allow-iap-ssh)" \ - --format="table(name,direction,disabled,sourceRanges.list(),allowed[].map().firewall_rule().list(),targetTags.list())" -``` +### Fix -If you can access the VM over IAP SSH, check systemd services and listeners. +1. When the cloud binding is correct but the local IP is stale, synchronize the IP and regenerate YAML: ```bash -gcloud --project "$PROJECT_ID" compute ssh "$INSTANCE_NAME" \ - --zone "$ZONE" \ - --tunnel-through-iap \ - --command "sudo systemctl is-active xray hysteria anytls; sudo ss -tulnp | grep -E 'xray|hysteria|anytls'" +python3 node.py ip-sync --profile gcloud ``` -### Fix +2. Synchronize distribution copies and reload the regenerated YAML on each device. Verify the active client actually uses the new node configuration. -1. Update `profiles/gcloud/.secrets.env` so `STATIC_IP` matches the VM external IP. -2. Regenerate GCloud client configs: +If the direct IP is blocked from the client network, first test the independent CDN entrance: ```bash -NETWORK_NODE_PROFILE=gcloud python3 core/gen-clash.py +python3 node.py cdn-check --profile gcloud ``` -3. Re-import the regenerated YAML into the client. Delete the old client profile first if the app caches old proxy entries. +This verifies authenticated VLESS traffic through CF and a verified HTTPS target. It does +not change the GCP egress IP, and OS-level TUN routing can still affect the local test. +For address replacement, rollback and release of retained addresses, use the +[GCP recovery commands](../README.md#gcp-ip-故障恢复). Do not delete the old reservation +before validating the new connection and synchronizing devices. Restarting the VM is not required when only the generated client YAML is wrong. Re-run the same provider entry point or restart services only when the server-side ports, credentials, or service configs changed. @@ -94,10 +71,9 @@ Restarting the VM is not required when only the generated client YAML is wrong. 处理方式: -1. 对比 GCP VM 当前外部 IP 和 `profiles/gcloud/.secrets.env` 里的 `STATIC_IP`。 -2. 把 `profiles/gcloud/.secrets.env` 更新为当前 VM IP。 -3. 运行 `NETWORK_NODE_PROFILE=gcloud python3 core/gen-clash.py`。 -4. 在客户端删除旧 profile 后重新导入生成的 YAML。 +1. 运行 `python3 node.py ip-check --profile gcloud`。 +2. 云端绑定正确但本地记录过期时,运行 `python3 node.py ip-sync --profile gcloud`。 +3. 同步 iCloud 等分发副本,并在客户端重新加载、验证实际代理连接。 只修正客户端 YAML 时不需要重启 VM。只有服务端端口、凭据或 systemd 服务配置发生变化时,才需要重跑原平台入口或重启相关服务。 diff --git a/node.py b/node.py new file mode 100644 index 0000000..83b494d --- /dev/null +++ b/node.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Profile maintenance; cloud address writes require an explicit --apply.""" +import argparse +import os +from pathlib import Path +import subprocess +import sys + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT / "core")) +from settings import NAME, load_settings, validate + + +def main(): + parser = argparse.ArgumentParser(description="配置管理与入口恢复;不显示凭据,云端写入需 --apply") + 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("--apply", action="store_true", help="执行换 IP、回滚或释放保留地址;省略时仅显示计划") + args = parser.parse_args() + if not NAME.fullmatch(args.profile): + parser.error("profile 仅支持 1-64 位字母、数字、下划线和连字符") + state = ROOT / "profiles" / args.profile + if not (state / "deploy.conf").is_file(): + parser.error("profile 不存在;请先通过部署入口创建") + settings = load_settings(state) + if args.apply and args.action not in ('ip-rotate', 'ip-rollback', 'ip-finalize'): + parser.error('--apply 仅用于 ip-rotate / ip-rollback / ip-finalize') + if args.action.startswith('ip-'): + if args.profile != 'gcloud': + parser.error('当前 GCP 入口恢复仅支持 gcloud profile') + if args.client: + parser.error('IP 操作沿用 profile 的 CLIENT_TARGET,不接受 --client') + from profile_lock import profile_lock + sys.path.insert(0, str(ROOT / 'providers')) + from gcp_ip import Rotation, check, render + with profile_lock(state): + if args.action in ('ip-check', 'ip-sync'): + if args.action == 'ip-sync' and (state / 'ip-rotation.json').exists(): + parser.error('存在换 IP 记录;先完成回滚或释放保留地址') + check(state, persist=args.action == 'ip-sync') + if args.action == 'ip-sync': + render(state) + print('云端地址归属与绑定检查通过' + (';已同步本地 YAML' if args.action == 'ip-sync' else ';本地 IP 一致')) + else: + operation = Rotation(state) + getattr(operation, {'ip-rotate': 'rotate', 'ip-rollback': 'rollback', + 'ip-finalize': 'finalize'}[args.action])(apply=args.apply) + if args.action == 'ip-rollback' and args.apply: + print('原地址绑定和本地 YAML 已恢复;请同步设备,未使用的新地址可用 ip-finalize 释放') + return + if args.action == 'cdn-check': + from cdn_probe import check_cdn + check_cdn(settings) + print('CDN TLS / WebSocket / VLESS / HTTPS 请求全部通过;未通过直连节点回退') + return + if args.client: + settings["CLIENT_TARGET"] = args.client + if args.action == "status": + print(f"Profile: {args.profile}") + print("客户端输出: " + ("已停用" if settings.get("CLIENT_CONFIG_ENABLE", "true") == "false" else "已启用")) + print(f"配置位置: {state / 'deploy.conf'}") + print(f"密码/凭据位置: {state / '.secrets.env'}(工具自动管理,不需要记忆)") + print(f"连接设置位置: {state / 'connection.conf'}") + print(f"客户端文件位置: {ROOT / 'clash-configs'}") + print("备份完整 profile 及其中的 ssh/;不要只备份 YAML,也不要上传或粘贴凭据。") + return + validate(settings, deployment=args.action == "validate") + if args.action == "validate": + print("本地配置校验通过;未修改凭据或访问服务器") + return + command = [sys.executable, str(ROOT / "core" / "gen-clash.py")] + if args.action == "check": + command.append("--check") + if args.client: + 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': + from profile_lock import profile_lock + with profile_lock(state): + return subprocess.call(command, env=env) + return subprocess.call(command, env=env) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (ValueError, OSError) as exc: + sys.exit(f"ERROR: {exc}") diff --git a/providers/gcp-preflight.sh b/providers/gcp-preflight.sh index 07da926..26b1397 100755 --- a/providers/gcp-preflight.sh +++ b/providers/gcp-preflight.sh @@ -19,6 +19,7 @@ need() { # need CMD HINT need gcloud "安装方式见 https://cloud.google.com/sdk/docs/install" need python3 "macOS: brew install python3 ;Debian/Ubuntu: sudo apt install python3" need openssl "通常系统自带;缺失请用包管理器安装" +need ssh-keygen "通常由 OpenSSH 提供" # uuid 来源:uuidgen 或 python3 均可 if command -v uuidgen >/dev/null 2>&1; then @@ -29,12 +30,13 @@ fi [ "$missing" -eq 0 ] || die "请先安装上述缺失的依赖,再重新运行 ./deploy-gcp.sh" -# gcloud 登录态 -if gcloud auth list --filter=status:ACTIVE --format='value(account)' 2>/dev/null | grep -q .; then - acct="$(gcloud auth list --filter=status:ACTIVE --format='value(account)' | head -1)" - ok "gcloud 已登录:$acct" -else - die "gcloud 未登录。请先运行:gcloud auth login" +# A saved account must take precedence over the workstation's active account. +load_conf +acct="${GCP_ACCOUNT:-$(gcloud config get-value account 2>/dev/null || true)}" +[ -n "$acct" ] && [ "$acct" != '(unset)' ] || die "请先登录 gcloud,并在 deploy.conf 设置 GCP_ACCOUNT" +if ! gcloud --account "$acct" auth print-access-token >/dev/null 2>&1; then + die "GCP_ACCOUNT 的授权不可用,请重新登录该账号后重试" fi +ok "指定 GCP 账号的授权有效" ok "预检通过" diff --git a/providers/gcp-provision.sh b/providers/gcp-provision.sh index d569ad9..2dd10ea 100755 --- a/providers/gcp-provision.sh +++ b/providers/gcp-provision.sh @@ -13,7 +13,8 @@ load_secrets : "${ANYTLS_PORT:?ANYTLS_PORT 未生成,请先运行 secrets.sh}" HY2_FIREWALL_PORT="${HY2_PORT_RANGE:-$HY2_PORT}" -GC=(gcloud --project "$PROJECT_ID" --quiet) +: "${GCP_ACCOUNT:?deploy.conf 缺少 GCP_ACCOUNT,请通过 deploy-gcp.sh 初始化}" +GC=(gcloud --account "$GCP_ACCOUNT" --project "$PROJECT_ID" --quiet) gcloud_retry() { local attempt status delay @@ -33,18 +34,21 @@ gcloud_retry() { return "$status" } +# Existing VMs must already own the configured reservation. Never silently +# publish an unattached address after a manual address change. say "[1/4] 启用 Compute Engine API(幂等)" gcloud_retry services enable compute.googleapis.com +python3 "$PROJECT_DIR/providers/gcp_ip.py" preflight "$STATE_DIR" say "[2/4] 预留静态外部 IP:${IP_NAME}(${REGION} / ${NETWORK_TIER})" -if gcloud_retry compute addresses describe "$IP_NAME" --region "$REGION" >/dev/null 2>&1; then +IP_EXISTS="$(gcloud_retry compute addresses list --regions "$REGION" --filter "name=$IP_NAME" --format='value(name)')" +if [ -n "$IP_EXISTS" ]; then ok "IP 已存在,复用" else gcloud_retry compute addresses create "$IP_NAME" --region "$REGION" --network-tier "$NETWORK_TIER" fi STATIC_IP="$(gcloud_retry compute addresses describe "$IP_NAME" --region "$REGION" --format='value(address)')" -setkv STATIC_IP "$STATIC_IP" -ok "静态 IP:$STATIC_IP" +ok "已取得预留静态 IP,等待核验 VM 绑定后保存" say "[3/4] 防火墙规则(幂等)" FW_RULES="tcp:${REALITY_PORT},udp:${HY2_FIREWALL_PORT},tcp:${ANYTLS_PORT}" @@ -85,7 +89,8 @@ fi ok "防火墙就绪(代理端口对公网、SSH 仅 IAP)" say "[4/4] 创建 VM:${INSTANCE_NAME}(${MACHINE_TYPE} / Debian 12 / ${ZONE})" -if gcloud_retry compute instances describe "$INSTANCE_NAME" --zone "$ZONE" >/dev/null 2>&1; then +VM_EXISTS="$(gcloud_retry compute instances list --zones "$ZONE" --filter "name=$INSTANCE_NAME" --format='value(name)')" +if [ -n "$VM_EXISTS" ]; then ok "VM 已存在,跳过创建" else gcloud_retry compute instances create "$INSTANCE_NAME" \ @@ -100,4 +105,5 @@ else sleep 20 fi +python3 "$PROJECT_DIR/providers/gcp_ip.py" persist "$STATE_DIR" ok "云资源就绪" diff --git a/providers/gcp.sh b/providers/gcp.sh index 705650d..d2920d5 100755 --- a/providers/gcp.sh +++ b/providers/gcp.sh @@ -3,10 +3,16 @@ PROVIDER_TITLE="GCP 代理一键部署" PROVIDER_DESCRIPTION="provider=GCP" -provider_init() { :; } +provider_init() { + load_conf + if [ -n "${GCP_HTTP_PROXY:-}" ]; then + export HTTPS_PROXY="$GCP_HTTP_PROXY" HTTP_PROXY="$GCP_HTTP_PROXY" + fi +} provider_preflight() { - bash "$PROJECT_DIR/providers/gcp-preflight.sh" + PROFILE_NAME="$PROFILE_NAME" NETWORK_NODE_STATE_DIR="$STATE_DIR" \ + bash "$PROJECT_DIR/providers/gcp-preflight.sh" } provider_configure() { @@ -31,30 +37,56 @@ provider_configure() { -e "s|^ZONE=.*|ZONE=${zone}|" \ -e "s|^DEVICES=.*|DEVICES=\"${devs}\"|" \ "$CONFIG_TEMPLATE" > "$CONF_FILE" - ok "已写入 profiles/gcloud/deploy.conf" + chmod 600 "$CONF_FILE" + ok "已创建 GCP profile 配置" fi load_conf + GCP_ACCOUNT="${GCP_ACCOUNT:-$(gcloud config get-value account 2>/dev/null || true)}" + [ -n "$GCP_ACCOUNT" ] && [ "$GCP_ACCOUNT" != '(unset)' ] || die "缺少 GCP_ACCOUNT" + export GCP_ACCOUNT + # Pin the account on the first successful setup; later runs ignore global switches. + python3 - "$CONF_FILE" <<'PY' +import os, pathlib, re, shlex, sys +path = pathlib.Path(sys.argv[1]) +text = path.read_text() +line = 'GCP_ACCOUNT=' + shlex.quote(os.environ['GCP_ACCOUNT']) +text = re.sub(r'^GCP_ACCOUNT=.*$', line, text, flags=re.M) if re.search(r'^GCP_ACCOUNT=', text, re.M) else text.rstrip() + '\n' + line + '\n' +path.write_text(text) +path.chmod(0o600) +PY PROVIDER_DESCRIPTION="provider=GCP 项目=$PROJECT_ID 区域=$REGION" } provider_provision() { - bash "$PROJECT_DIR/providers/gcp-provision.sh" + PROFILE_NAME="$PROFILE_NAME" NETWORK_NODE_STATE_DIR="$STATE_DIR" \ + bash "$PROJECT_DIR/providers/gcp-provision.sh" } provider_install() { - local setup_script="$1" download_script="$2" env_file="$3" attempt scp_ok=0 - local -a gc=(gcloud --project "$PROJECT_ID" --quiet) + local setup_script="$1" download_script="$2" env_file="$3" attempt remote_dir="" + : "${GCP_ACCOUNT:?deploy.conf 缺少 GCP_ACCOUNT}" + local -a gc=(gcloud --account "$GCP_ACCOUNT" --project "$PROJECT_ID" --quiet) + mkdir -p "$SSH_DIR" + chmod 700 "$SSH_DIR" + local ssh_key="$SSH_DIR/google_compute_engine" for attempt in 1 2 3; do - if "${gc[@]}" compute scp --tunnel-through-iap --zone "$ZONE" \ - "$setup_script" "$download_script" "$env_file" "$INSTANCE_NAME":/tmp/ 2>/dev/null; then - scp_ok=1; break + if remote_dir="$("${gc[@]}" compute ssh --ssh-key-file "$ssh_key" --tunnel-through-iap --zone "$ZONE" "$INSTANCE_NAME" \ + --command 'umask 077; mktemp -d /tmp/network-node.XXXXXXXX')"; then + break fi warn "SSH 尚未就绪,等待重试 ($attempt/3)..." sleep 15 done - [ "$scp_ok" -eq 1 ] || die "无法通过 IAP SSH 连接到 VM" - "${gc[@]}" compute ssh --tunnel-through-iap --zone "$ZONE" "$INSTANCE_NAME" \ - --command 'bash /tmp/setup-server.sh /tmp/server-env.sh; rc=$?; rm -f /tmp/server-env.sh /tmp/setup-server.sh /tmp/download.sh; exit $rc' + [[ "$remote_dir" =~ ^/tmp/network-node\.[A-Za-z0-9]+$ ]] || die "无法创建安全的远端临时目录" + if ! "${gc[@]}" compute scp --ssh-key-file "$ssh_key" --tunnel-through-iap --zone "$ZONE" \ + "$setup_script" "$download_script" "$env_file" "$INSTANCE_NAME:$remote_dir/"; then + "${gc[@]}" compute ssh --ssh-key-file "$ssh_key" --tunnel-through-iap --zone "$ZONE" "$INSTANCE_NAME" \ + --command "rm -rf '$remote_dir'" || true + die "上传失败,已尝试清理远端临时文件" + fi + "${gc[@]}" compute ssh --ssh-key-file "$ssh_key" --tunnel-through-iap --zone "$ZONE" "$INSTANCE_NAME" \ + --command "trap 'sudo rm -rf $remote_dir' EXIT HUP INT TERM; sudo bash '$remote_dir/setup-server.sh' '$remote_dir/server-env.sh'; exit \$?" + } provider_print_summary() { diff --git a/providers/gcp_ip.py b/providers/gcp_ip.py new file mode 100644 index 0000000..c477d9f --- /dev/null +++ b/providers/gcp_ip.py @@ -0,0 +1,288 @@ +"""GCP IPv4 consistency and recoverable replacement; never display profile values.""" +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import time +import uuid + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'core')) +from client_output import atomic_write +from settings import load_kv, load_settings +from net_probe import connect + + +class CloudError(ValueError): + pass + + +class Gcp: + def __init__(self, settings): + self.s = settings + for key in ('GCP_ACCOUNT', 'PROJECT_ID', 'ZONE', 'REGION', 'INSTANCE_NAME', 'IP_NAME'): + if not settings.get(key): + raise ValueError(f'缺少 {key}') + if settings['ZONE'].rsplit('-', 1)[0] != settings['REGION']: + raise ValueError('ZONE 与 REGION 不一致') + self.env = os.environ.copy() + if settings.get('GCP_HTTP_PROXY'): + self.env.update(HTTPS_PROXY=settings['GCP_HTTP_PROXY'], HTTP_PROXY=settings['GCP_HTTP_PROXY']) + + def call(self, *args, read=False): + command = ['gcloud', '--account', self.s['GCP_ACCOUNT'], '--project', + self.s['PROJECT_ID'], '--quiet', *args, '--format=json'] + for attempt in range(3 if read else 1): + try: + result = subprocess.run(command, env=self.env, capture_output=True, text=True, timeout=30 if read else 180) + if result.returncode == 0: + return json.loads(result.stdout or 'null') + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError): + pass + if read and attempt < 2: + time.sleep(2 ** attempt) + # CLI diagnostics can contain identifiers or account information. + raise CloudError('GCP 请求失败或超时;未输出账号、地址或凭据,请检查登录、权限及管理网络') + + def vm(self, optional=False): + rows = self.call('compute', 'instances', 'list', '--zones', self.s['ZONE'], + '--filter', 'name=' + self.s['INSTANCE_NAME'], read=True) + rows = [row for row in rows if row.get('name') == self.s['INSTANCE_NAME']] + if not rows and optional: + return None + if len(rows) != 1: + raise ValueError('未找到唯一的目标 VM;已停止操作') + return rows[0] + + def address(self, name): + rows = self.call('compute', 'addresses', 'list', '--regions', self.s['REGION'], + '--filter', 'name=' + name, read=True) + rows = [row for row in rows if row.get('name') == name] + if len(rows) > 1: + raise ValueError('预留地址不唯一') + return rows[0] if rows else None + + def idle(self, vm): + operations = self.call('compute', 'operations', 'list', '--zones', self.s['ZONE'], + '--filter', 'status!=DONE', read=True) + if any(op.get('targetLink') == vm['selfLink'] and op.get('status') != 'DONE' for op in operations): + raise ValueError('VM 仍有未完成的云端操作;请稍后重试,暂不反向修改地址') + + +def binding(vm): + nics = vm.get('networkInterfaces', []) + if len(nics) != 1: + raise ValueError('当前工具只支持单网卡 VM;已停止操作') + configs = nics[0].get('accessConfigs', []) + if len(configs) > 1: + raise ValueError('存在多个外部地址配置;已停止操作') + return nics[0]['name'], configs[0] if configs else None + + +def verified_address(client, vm, name): + address = client.address(name) + _, access = binding(vm) + if not address or not access or address.get('address') != access.get('natIP'): + raise ValueError('预留 IP 与 VM 实际绑定不一致;未更新本地地址,请先处理云端绑定') + if (address.get('status') != 'IN_USE' or vm['selfLink'] not in address.get('users', []) + or address.get('networkTier') != access.get('networkTier') + or address.get('networkTier') != client.s.get('NETWORK_TIER', 'PREMIUM')): + raise ValueError('预留 IP 的归属、使用状态或网络等级不一致') + return address + + +def update_kv(path, changes): + """Preserve comments and unrelated settings, replacing literal assignments atomically.""" + path = Path(path) + if path.is_symlink(): + raise ValueError('配置文件不能是符号链接') + lines = path.read_text().splitlines() if path.exists() else [] + lines = [line for line in lines if line.partition('=')[0].strip() not in changes] + lines += [key + '=' + shlex.quote(value) for key, value in changes.items()] + atomic_write(path, '\n'.join(lines) + '\n') + + +def save_binding(state, name, ip): + update_kv(state / 'deploy.conf', {'IP_NAME': name}) + # A legacy STATIC_IP in deploy.conf would otherwise override generated state. + if 'STATIC_IP' in load_kv(state / 'deploy.conf'): + update_kv(state / 'deploy.conf', {'STATIC_IP': ip}) + update_kv(state / '.secrets.env', {'STATIC_IP': ip}) + + +def check(state, *, optional=False, persist=False): + journal = Path(state) / 'ip-rotation.json' + if journal.exists() and json.loads(journal.read_text()).get('phase') not in ('complete', 'rolled-back'): + raise ValueError('存在未完成的换 IP 操作;先运行 ip-rollback,不能继续部署或同步') + settings = load_settings(state) + client = Gcp(settings) + vm = client.vm(optional=optional) + if vm is None: + return + address = verified_address(client, vm, settings['IP_NAME']) + if persist: + save_binding(Path(state), settings['IP_NAME'], address['address']) + elif not optional and settings.get('STATIC_IP') != address['address']: + raise ValueError('VM 绑定正常,但本地 STATIC_IP 已过期;使用 ip-sync 同步') + + +def render(state): + env = os.environ | {'NETWORK_NODE_ROOT': str(ROOT), 'NETWORK_NODE_STATE_DIR': str(state), + 'NETWORK_NODE_PROFILE': state.name} + result = subprocess.run([sys.executable, str(ROOT / 'core/gen-clash.py')], + env=env, capture_output=True, text=True, timeout=60) + if result.returncode: + raise ValueError('客户端生成失败;原始配置与凭据未输出') + + +def tcp_probe(ip, port, interface=''): + for attempt in range(6): + try: + with connect(ip, port, 3, interface): + return + except OSError: + if attempt < 5: + time.sleep(2) + raise ValueError('从本机到新 IP 的 Reality TCP 端口不可达;尚未验证代理协议') + + +class Rotation: + def __init__(self, state, *, client=None, probe=None, renderer=render): + self.state = Path(state) + self.s = load_settings(state) + self.client = client or Gcp(self.s) + self.probe = probe or (lambda ip, port: tcp_probe(ip, port, self.s.get('PROBE_INTERFACE', ''))) + self.renderer = renderer + self.path = self.state / 'ip-rotation.json' + if self.path.is_symlink(): + raise ValueError('恢复记录不能是符号链接') + + def write(self, journal, phase): + journal['phase'] = phase + atomic_write(self.path, json.dumps(journal, indent=2) + '\n') + + def journal(self): + if not self.path.exists(): + raise ValueError('没有待恢复的 IP 操作') + journal = json.loads(self.path.read_text()) + if journal['identity'] != {k: self.s[k] for k in ('GCP_ACCOUNT', 'PROJECT_ID', 'ZONE', 'INSTANCE_NAME')}: + raise ValueError('profile 已指向其他账号或 VM,不能使用旧恢复记录') + return journal + + def switch(self, journal, target): + vm = self.client.vm() + if vm.get('id') != journal['vm_id']: + raise ValueError('VM 已重建,拒绝使用旧恢复记录') + self.client.idle(vm) + nic, current = binding(vm) + if nic != journal['nic']: + raise ValueError('网卡已改变') + if current and current.get('natIP') == target['ip']: + return + if current and current.get('natIP') not in (journal['old']['ip'], journal['new'].get('ip')): + raise ValueError('检测到第三方地址变更,已停止覆盖') + address = self.client.address(target['name']) + if not address or address.get('address') != target['ip'] or address.get('users'): + raise ValueError('目标预留地址不存在、已变化或已被占用') + if current: + self.client.call('compute', 'instances', 'delete-access-config', self.s['INSTANCE_NAME'], + '--zone', self.s['ZONE'], '--network-interface', nic, + '--access-config-name', current['name']) + self.client.call('compute', 'instances', 'add-access-config', self.s['INSTANCE_NAME'], + '--zone', self.s['ZONE'], '--network-interface', nic, + '--access-config-name', journal['access_name'], '--address', target['ip'], + '--network-tier', journal['tier']) + + def commit(self, journal, target): + address = verified_address(self.client, self.client.vm(), target['name']) + save_binding(self.state, target['name'], address['address']) + self.renderer(self.state) + + def rotate(self, apply=False): + if self.path.exists(): + raise ValueError('已有换 IP 记录;先 ip-rollback 恢复或 ip-finalize 释放保留地址') + if self.s.get('CDN_ONLY') == 'true' or self.s.get('CLIENT_CONFIG_ENABLE') == 'false': + raise ValueError('换 IP 工具要求启用直连入口及客户端输出') + vm = self.client.vm() + self.client.idle(vm) + old = verified_address(self.client, vm, self.s['IP_NAME']) + if vm.get('status') != 'RUNNING': + raise ValueError('VM 未运行,不能进行连接验收') + if not apply: + print('计划:保留旧静态 IP → 申请新 IP → 更换绑定 → TCP 验收 → 同步 YAML;使用 --apply 执行') + return + # Ensure client generation works before disconnecting anything. + if self.s.get('STATIC_IP') != old['address']: + raise ValueError('本地地址已过期;先 ip-sync,再更换地址') + self.renderer(self.state) + nic, access = binding(vm) + journal = {'identity': {k: self.s[k] for k in ('GCP_ACCOUNT', 'PROJECT_ID', 'ZONE', 'INSTANCE_NAME')}, + 'vm_id': vm['id'], 'nic': nic, 'access_name': access['name'], + 'tier': access['networkTier'], 'old': {'name': self.s['IP_NAME'], 'ip': old['address']}, + 'new': {'name': 'network-node-' + uuid.uuid4().hex[:20]}} + self.write(journal, 'allocating') + try: + self.client.call('compute', 'addresses', 'create', journal['new']['name'], + '--region', self.s['REGION'], '--network-tier', journal['tier']) + new = self.client.address(journal['new']['name']) + if not new or new.get('users') or new.get('networkTier') != journal['tier']: + raise ValueError('新 IP 预留状态异常') + journal['new']['ip'] = new['address'] + self.write(journal, 'switching') + self.switch(journal, journal['new']) + verified_address(self.client, self.client.vm(), journal['new']['name']) + self.probe(journal['new']['ip'], int(self.s.get('REALITY_PORT', '443'))) + self.write(journal, 'publishing') + self.commit(journal, journal['new']) + self.write(journal, 'complete') + except (ValueError, OSError, subprocess.TimeoutExpired, KeyboardInterrupt) as exc: + try: + self.rollback(apply=True) + except (ValueError, OSError, subprocess.TimeoutExpired, KeyboardInterrupt): + raise ValueError('换 IP 未完成,自动恢复也未完成;保留了恢复记录,请稍后 ip-rollback --apply') from None + raise ValueError('换 IP 未完成,已恢复原地址和本地 YAML;保留新预留资源,使用 ip-finalize 清理') from exc + print('新 IP 绑定、TCP 端口和本地 YAML 已验证;旧 IP 保留待回滚。请同步设备并验证实际代理流量') + + def rollback(self, apply=False): + journal = self.journal() + if not apply: + print('计划:恢复原静态 IP 和 YAML,保留新预留地址;使用 --apply 执行') + return + self.write(journal, 'rolling-back') + self.switch(journal, journal['old']) + self.commit(journal, journal['old']) + self.write(journal, 'rolled-back') + + def finalize(self, apply=False): + journal = self.journal() + if journal['phase'] not in ('complete', 'rolled-back'): + raise ValueError('操作未完成;先 ip-rollback 恢复,不能释放地址') + active, unused = ((journal['new'], journal['old']) if journal['phase'] == 'complete' + else (journal['old'], journal['new'])) + vm = self.client.vm() + if vm.get('id') != journal['vm_id']: + raise ValueError('VM 已重建,不能使用旧记录释放地址') + self.client.idle(vm) + verified_address(self.client, vm, active['name']) + if self.s.get('IP_NAME') != active['name'] or self.s.get('STATIC_IP') != active['ip']: + raise ValueError('本地状态与当前绑定不一致;不能释放恢复地址') + address = self.client.address(unused['name']) + if address and (address.get('users') or (unused.get('ip') and address['address'] != unused['ip'])): + raise ValueError('待释放地址已变化或仍被使用,已停止') + if not apply: + print('计划:永久释放未使用的保留 IP 并删除恢复记录;设备验证完成后使用 --apply 执行') + return + if address: + self.client.call('compute', 'addresses', 'delete', unused['name'], '--region', self.s['REGION']) + self.path.unlink() + print('未使用的保留地址已释放;此次换 IP 不再支持回滚') + + +if __name__ == '__main__': + try: + action, state = sys.argv[1:] + check(Path(state), optional=action == 'preflight', persist=action == 'persist') + except (ValueError, OSError) as exc: + sys.exit(str(exc)) diff --git a/providers/vps.sh b/providers/vps.sh index 98e9bef..af13101 100755 --- a/providers/vps.sh +++ b/providers/vps.sh @@ -4,12 +4,15 @@ PROVIDER_TITLE="VPS 代理一键部署" PROVIDER_DESCRIPTION="provider=VPS" provider_init() { - local profile_key="$SSH_DIR/id_rsa.pem" + local profile_key="$SSH_DIR/id_rsa.pem" connection_values + connection_values="$(python3 "$PROJECT_DIR/core/settings.py" connection-shell "$STATE_DIR")" || return + eval "$connection_values" VPS_HOST="${VPS_HOST:-${1:-$(secret_get STATIC_IP)}}" [ -n "$VPS_HOST" ] || die "缺少主机:使用 --host 或位置参数" VPS_BOOTSTRAP_USER="${VPS_BOOTSTRAP_USER:-root}" VPS_ADMIN_USER="${VPS_ADMIN_USER:-mt}" VPS_SSH_PORT="${VPS_SSH_PORT:-22}" + VPS_SSH_INTERFACE="${VPS_SSH_INTERFACE:-}" if [ -f "$profile_key" ]; then VPS_SSH_KEY="${VPS_SSH_KEY:-$profile_key}" else @@ -17,6 +20,10 @@ provider_init() { fi VPS_SSH_OPTS=(-i "$VPS_SSH_KEY" -p "$VPS_SSH_PORT" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=4) VPS_SCP_OPTS=(-i "$VPS_SSH_KEY" -P "$VPS_SSH_PORT" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=4) + if [ -n "$VPS_SSH_INTERFACE" ]; then + VPS_SSH_OPTS+=(-o "BindInterface=$VPS_SSH_INTERFACE") + VPS_SCP_OPTS+=(-o "BindInterface=$VPS_SSH_INTERFACE") + fi } provider_preflight() { @@ -30,6 +37,10 @@ provider_preflight() { esac [ "$VPS_SSH_PORT" -ge 1 ] && [ "$VPS_SSH_PORT" -le 65535 ] \ || die "SSH 端口必须是 1-65535 的整数:$VPS_SSH_PORT" + case "$VPS_SSH_INTERFACE" in + "") ;; + *[!A-Za-z0-9_.:-]*) die "非法 SSH 网络接口:$VPS_SSH_INTERFACE" ;; + esac case "$VPS_BOOTSTRAP_USER" in ""|[!A-Za-z_]*|*[!A-Za-z0-9_-]*) die "非法 bootstrap 用户名:$VPS_BOOTSTRAP_USER" ;; esac @@ -49,9 +60,10 @@ provider_preflight() { ok "目标已接受当前 SSH key,跳过公钥安装" else say "将交互式安装 SSH 公钥;请输入商家面板中的 root 密码" + local -a copy_opts=(-o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10) + [ -z "$VPS_SSH_INTERFACE" ] || copy_opts+=(-o "BindInterface=$VPS_SSH_INTERFACE") ssh-copy-id -i "${VPS_SSH_KEY}.pub" -p "$VPS_SSH_PORT" \ - -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new \ - -o ConnectTimeout=10 "${VPS_BOOTSTRAP_USER}@${VPS_HOST}" + "${copy_opts[@]}" "${VPS_BOOTSTRAP_USER}@${VPS_HOST}" fi fi } @@ -104,9 +116,19 @@ provider_configure() { [ -f "$source_conf" ] \ || die "来源 profile 不存在 deploy.conf:profiles/$VPS_CONFIG_FROM_PROFILE/" cp "$source_conf" "$CONF_FILE" - sed -i.bak -e "s|^CLIENT_FILE_PREFIX=.*|CLIENT_FILE_PREFIX=$PROFILE_NAME|" "$CONF_FILE" - rm -f "$CONF_FILE.bak" - ok "已从 $VPS_CONFIG_FROM_PROFILE 复制非密钥部署配置;新 profile 会生成独立凭据和客户端文件" + python3 - "$PROJECT_DIR/core" "$CONF_FILE" "$PROFILE_NAME" <<'PY' +import pathlib, shlex, sys +sys.path.insert(0, sys.argv[1]) +from settings import load_kv, CONNECTION_KEYS +path = pathlib.Path(sys.argv[2]) +data = load_kv(path) +for key in CONNECTION_KEYS: + data.pop(key, None) +data.update(CLIENT_FILE_PREFIX=sys.argv[3], CDN_ENABLE="false", CDN_ONLY="false", + CDN_HOSTNAME="", CDN_TUNNEL_NAME=sys.argv[3] + "-cdn", CDN_WS_PATH="") +path.write_text("".join(f"{key}={shlex.quote(value)}\n" for key, value in data.items())) +PY + ok "已复制协议偏好;CDN 已关闭并清空旧身份,新 profile 会生成独立凭据和客户端文件" else cp "$CONFIG_TEMPLATE" "$CONF_FILE" sed -i.bak -e 's|^PROJECT_ID=.*|PROJECT_ID=vps|' "$CONF_FILE" @@ -114,7 +136,12 @@ provider_configure() { ok "已创建 profiles/$PROFILE_NAME/deploy.conf;可按需修改端口" fi fi + chmod 600 "$CONF_FILE" load_conf + VPS_HOST="$VPS_HOST" VPS_SSH_PORT="$VPS_SSH_PORT" VPS_SSH_KEY="$VPS_SSH_KEY" \ + VPS_ADMIN_USER="$VPS_ADMIN_USER" VPS_BOOTSTRAP_USER="$VPS_BOOTSTRAP_USER" \ + VPS_SSH_INTERFACE="$VPS_SSH_INTERFACE" \ + python3 "$PROJECT_DIR/core/settings.py" save-connection "$STATE_DIR" PROVIDER_DESCRIPTION="provider=VPS 主机=$VPS_HOST" } @@ -155,33 +182,56 @@ REMOTE_BOOTSTRAP ssh "${VPS_SSH_OPTS[@]}" "${VPS_ADMIN_USER}@${VPS_HOST}" \ "SSH_PORT='$VPS_SSH_PORT' REALITY_PORT='$REALITY_PORT' HY2_PORT='$HY2_PORT' HY2_PORT_RANGE='${HY2_PORT_RANGE:-}' ANYTLS_PORT='$ANYTLS_PORT' WARP_ENABLE='${WARP_ENABLE:-false}' WARP_REALITY_PORT='${WARP_REALITY_PORT:-}' CDN_ONLY='${CDN_ONLY:-false}' bash -s" <<'REMOTE_FIREWALL' set -euo pipefail +HY2_FIREWALL_PORT="${HY2_PORT_RANGE:-${HY2_PORT}}" +HY2_FIREWALL_PORT="${HY2_FIREWALL_PORT/-/:}" +expected="$(mktemp)" +trap 'rm -f "$expected"' EXIT +printf '%s/tcp\n' "$SSH_PORT" > "$expected" +if [ "$CDN_ONLY" != "true" ]; then + printf '%s/tcp\n%s/udp\n%s/tcp\n' "$REALITY_PORT" "$HY2_FIREWALL_PORT" "$ANYTLS_PORT" >> "$expected" + [ "$WARP_ENABLE" != "true" ] || printf '%s/tcp\n' "$WARP_REALITY_PORT" >> "$expected" +fi sudo ufw default deny incoming >/dev/null sudo ufw default allow outgoing >/dev/null -sudo ufw allow "${SSH_PORT}/tcp" >/dev/null -if [ "${CDN_ONLY}" = "true" ]; then - sudo ufw delete allow "${REALITY_PORT}/tcp" >/dev/null 2>&1 || true - sudo ufw delete allow "${HY2_PORT_RANGE:-${HY2_PORT}}/udp" >/dev/null 2>&1 || true - sudo ufw delete allow "${ANYTLS_PORT}/tcp" >/dev/null 2>&1 || true - [ -n "${WARP_REALITY_PORT}" ] && sudo ufw delete allow "${WARP_REALITY_PORT}/tcp" >/dev/null 2>&1 || true -else - sudo ufw allow "${REALITY_PORT}/tcp" >/dev/null - sudo ufw allow "${HY2_PORT_RANGE:-${HY2_PORT}}/udp" >/dev/null - sudo ufw allow "${ANYTLS_PORT}/tcp" >/dev/null - if [ "${WARP_ENABLE}" = "true" ]; then - sudo ufw allow "${WARP_REALITY_PORT}/tcp" >/dev/null - else - [ -n "${WARP_REALITY_PORT}" ] && sudo ufw delete allow "${WARP_REALITY_PORT}/tcp" >/dev/null 2>&1 || true - fi +# Open the desired SSH port before removing any previously managed rules. +while IFS= read -r port_rule; do + sudo ufw allow "$port_rule" comment 'network-node' >/dev/null +done < "$expected" +sudo install -d -m 700 /var/lib/network-node +previous=/var/lib/network-node/ufw-ports +if sudo test -f "$previous"; then + while IFS= read -r old_rule; do + [ -z "$old_rule" ] || sudo ufw delete allow "$old_rule" >/dev/null + done < <(comm -23 <(sudo cat "$previous" | sort -u) <(sort -u "$expected")) +fi +# Adopt the original direct-port rules on the first transition to CDN-only. +if [ "$CDN_ONLY" = "true" ]; then + for port_rule in "$REALITY_PORT/tcp" "$HY2_FIREWALL_PORT/udp" "$ANYTLS_PORT/tcp" "${WARP_REALITY_PORT:-}/tcp"; do + [ "$port_rule" != /tcp ] || continue + if ! grep -Fx -- "$port_rule" "$expected" >/dev/null; then + sudo ufw delete allow "$port_rule" >/dev/null 2>&1 || true + fi + done fi sudo ufw --force enable >/dev/null +sudo install -m 600 "$expected" "$previous.new" +sudo mv "$previous.new" "$previous" + REMOTE_FIREWALL } provider_install() { - local setup_script="$1" download_script="$2" env_file="$3" - scp "${VPS_SCP_OPTS[@]}" "$setup_script" "$download_script" "$env_file" "${VPS_ADMIN_USER}@${VPS_HOST}:/tmp/" + local setup_script="$1" download_script="$2" env_file="$3" remote_dir + remote_dir="$(ssh "${VPS_SSH_OPTS[@]}" "${VPS_ADMIN_USER}@${VPS_HOST}" 'umask 077; mktemp -d /tmp/network-node.XXXXXXXX')" + [[ "$remote_dir" =~ ^/tmp/network-node\.[A-Za-z0-9]+$ ]] || die "无法创建安全的远端临时目录" + if ! scp "${VPS_SCP_OPTS[@]}" "$setup_script" "$download_script" "$env_file" "${VPS_ADMIN_USER}@${VPS_HOST}:$remote_dir/"; then + ssh "${VPS_SSH_OPTS[@]}" "${VPS_ADMIN_USER}@${VPS_HOST}" "rm -rf '$remote_dir'" || true + die "上传失败,已尝试清理远端临时文件" + fi ssh "${VPS_SSH_OPTS[@]}" "${VPS_ADMIN_USER}@${VPS_HOST}" \ - 'sudo bash /tmp/setup-server.sh /tmp/server-env.sh; rc=$?; sudo rm -f /tmp/server-env.sh /tmp/setup-server.sh /tmp/download.sh; exit $rc' + "trap 'sudo rm -rf $remote_dir' EXIT HUP INT TERM; sudo bash '$remote_dir/setup-server.sh' '$remote_dir/server-env.sh'; exit \$?" || return + ssh "${VPS_SSH_OPTS[@]}" "${VPS_ADMIN_USER}@${VPS_HOST}" 'sudo -n true' \ + || die "部署后管理员新连接验证失败,请保留控制台访问" } provider_print_summary() { diff --git a/tests/test_cdn_probe.py b/tests/test_cdn_probe.py new file mode 100644 index 0000000..22a5270 --- /dev/null +++ b/tests/test_cdn_probe.py @@ -0,0 +1,117 @@ +"""Protocol checks use synthetic frames and an in-memory TLS server, never CF.""" +import base64 +import hashlib +import io +from pathlib import Path +import ssl +import struct +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'core')) +from cdn_probe import Vless, WebSocket, check_cdn, https_probe, websocket_handshake + + +class BytesSocket: + def __init__(self, data=b''): + self.incoming = io.BytesIO(data) + self.sent = bytearray() + + def recv(self, size): + return self.incoming.read(size) + + def sendall(self, data): + self.sent.extend(data) + + +class CdnProbeTests(unittest.TestCase): + def test_websocket_accept_is_verified(self): + key = base64.b64encode(b'x' * 16).decode() + accept = base64.b64encode(hashlib.sha1((key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode() + response = ('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n' + f'Connection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n').encode() + sock = BytesSocket(response) + with patch('cdn_probe.os.urandom', return_value=b'x' * 16): + websocket_handshake(sock, 'cdn.example.com', 'path') + with self.assertRaisesRegex(ValueError, '校验失败'): + websocket_handshake(BytesSocket(response.replace(accept.encode(), b'wrong')), 'cdn.example.com', 'path') + self.assertIn(b'GET /path HTTP/1.1', sock.sent) + + def test_waf_challenge_is_not_success_and_body_is_not_logged(self): + sock = BytesSocket(b'HTTP/1.1 403 Forbidden\r\n\r\nsecret-server-body') + with self.assertRaisesRegex(ValueError, 'HTTP 403') as raised: + websocket_handshake(sock, 'cdn.example.com', 'path') + self.assertNotIn('secret-server-body', str(raised.exception)) + + def test_ping_is_answered_and_split_vless_header_is_consumed(self): + sock = BytesSocket(b'\x89\x02hi' + b'\x82\x01\x00' + b'\x82\x04\x00abc') + stream = Vless(WebSocket(sock), '00000000-0000-4000-8000-000000000001', 'www.gstatic.com') + self.assertEqual(stream.recv(3), b'abc') + self.assertTrue(stream.response) + self.assertEqual(sock.sent[0], 0x82) + self.assertTrue(sock.sent[1] & 0x80) # Client frames must be masked. + self.assertIn(0x8a, sock.sent) # Pong frame. + + def test_oversized_or_closed_frames_are_rejected(self): + for frame in (b'\x82\x7f' + struct.pack('!Q', 1048577), b'\x88\x00'): + with self.assertRaises(ValueError): + WebSocket(BytesSocket(frame)).recv(1) + + def test_missing_configuration_fails_before_network(self): + with patch('cdn_probe.connect') as connect: + with self.assertRaisesRegex(ValueError, '未启用'): + check_cdn({'CDN_ENABLE': 'false'}) + with self.assertRaisesRegex(ValueError, '凭据不完整'): + check_cdn({'CDN_ENABLE': 'true', 'CDN_HOSTNAME': 'cdn.example.com', + 'CDN_WS_PATH': 'path', 'DEVICES': 'mac'}) + connect.assert_not_called() + + def test_real_tls_handshake_http_status_and_certificate_validation(self): + with tempfile.TemporaryDirectory() as td: + cert, key = Path(td) / 'cert.pem', Path(td) / 'key.pem' + subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', + '-subj', '/CN=probe.test', '-addext', 'subjectAltName=DNS:probe.test', + '-keyout', str(key), '-out', str(cert)], capture_output=True, check=True) + client_context = ssl.create_default_context(cafile=str(cert)) + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.load_cert_chain(cert, key) + + class Server: + def __init__(self, response=b'HTTP/1.1 204 No Content\r\n\r\n'): + self.incoming, self.outgoing = ssl.MemoryBIO(), ssl.MemoryBIO() + self.tls = server_context.wrap_bio(self.incoming, self.outgoing, server_side=True) + self.ready = False + self.request = b'' + self.response = response + + def send(self, data): + self.incoming.write(data) + try: + if not self.ready: + self.tls.do_handshake() + self.ready = True + self.request += self.tls.read(16384) + if b'\r\n\r\n' in self.request: + self.tls.write(self.response) + except ssl.SSLWantReadError: + pass + + def recv(self, size): + return self.outgoing.read(size) + + with patch('cdn_probe.ssl.create_default_context', return_value=client_context): + server = Server() + https_probe(server, 'probe.test') + self.assertIn(b'GET /generate_204', server.request) + with self.assertRaisesRegex(ValueError, '204'): + https_probe(Server(b'HTTP/1.1 503 Unavailable\r\n\r\n'), 'probe.test') + with self.assertRaises(ssl.SSLCertVerificationError): + https_probe(Server(), 'wrong-host.test') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_cloudflare_safety.py b/tests/test_cloudflare_safety.py new file mode 100644 index 0000000..41fe21f --- /dev/null +++ b/tests/test_cloudflare_safety.py @@ -0,0 +1,88 @@ +"""Cloudflare ownership and retry tests; every HTTP request is intercepted locally.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "core")) +from settings import load_kv + + +class CloudflareSafetyTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.bin = self.root / "bin" + self.bin.mkdir() + (self.root / "deploy.conf").write_text( + "CDN_ENABLE=true\nCDN_HOSTNAME=cdn.example.com\nCDN_TUNNEL_NAME=test-cdn\n") + (self.root / ".secrets.env").write_text("CF_API_TOKEN=test-api-secret\n") + curl = self.bin / "curl" + curl.write_text('''#!/usr/bin/env python3 +import json, os, pathlib, sys +args = sys.argv[1:] +url = next(arg for arg in args if arg.startswith("https://api.cloudflare.com/")) +method = args[args.index("-X")+1] +mode = os.environ["CF_TEST_MODE"] +with open("requests.log", "a") as log: log.write(method + " " + url + "\\n") +response = {"success": True, "result": {}} +if "/zones?" in url: + response["result"] = [{"id": "zone-one", "name": "example.com", "account": {"id": "account-one"}}] +elif "cfd_tunnel?" in url: + response["result"] = [] if mode == "new-fails" else [{"id": "tunnel-one"}] +elif method == "POST" and url.endswith("cfd_tunnel"): + response["result"] = {"id": "tunnel-one"} +elif url.endswith("configurations") and mode == "new-fails": + response = {"success": False, "errors": [{"code": 1000, "message": "test-api-secret"}]} +elif "dns_records?" in url: + response["result"] = [{"id": "record-one", "content": "other.cfargotunnel.com"}] if mode == "dns-conflict" else [] +elif url.endswith("/token"): + response["result"] = "test-connector-secret" +print(json.dumps(response)); print(200) +''') + curl.chmod(0o755) + (self.bin / "sleep").write_text("#!/bin/sh\nexit 0\n") + (self.bin / "sleep").chmod(0o755) + + def run_cf(self, mode): + env = os.environ | {"NETWORK_NODE_STATE_DIR": str(self.root), "PROFILE_NAME": "test", + "PATH": str(self.bin) + os.pathsep + os.environ["PATH"], "CF_TEST_MODE": mode} + return subprocess.run(["bash", str(ROOT / "core/cloudflare.sh")], cwd=self.root, + env=env, capture_output=True, text=True) + + def claim_tunnel(self): + with (self.root / ".secrets.env").open("a") as handle: + handle.write("CDN_TUNNEL_ID=tunnel-one\n") + + def test_new_tunnel_ownership_survives_partial_failure_and_retry(self): + result = self.run_cf("new-fails") + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("test-api-secret", result.stdout + result.stderr) + self.assertEqual(load_kv(self.root / ".secrets.env")["CDN_TUNNEL_ID"], "tunnel-one") + result = self.run_cf("retry") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn("test-connector-secret", result.stdout + result.stderr) + self.assertEqual(load_kv(self.root / ".secrets.env")["CF_TUNNEL_TOKEN"], "test-connector-secret") + + def test_unowned_tunnel_cannot_be_overwritten(self): + result = self.run_cf("existing") + self.assertNotEqual(result.returncode, 0) + requests = (self.root / "requests.log").read_text() + self.assertNotIn("PUT ", requests) + self.assertNotIn("POST ", requests) + + def test_conflicting_dns_is_not_overwritten(self): + self.claim_tunnel() + result = self.run_cf("dns-conflict") + self.assertNotEqual(result.returncode, 0) + requests = (self.root / "requests.log").read_text().splitlines() + self.assertFalse(any(line.startswith("PUT ") and "dns_records/" in line for line in requests)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_deploy_output.py b/tests/test_deploy_output.py index e27e322..aaa62ed 100644 --- a/tests/test_deploy_output.py +++ b/tests/test_deploy_output.py @@ -24,6 +24,7 @@ def test_vps_help_documents_one_command_migration(self): self.assertIn("--copy-config-from", result.stdout) self.assertIn("--check-only", result.stdout) self.assertIn("--install-key", result.stdout) + self.assertIn("--ssh-interface", result.stdout) def test_vps_entrypoint_requires_explicit_profile(self): result = subprocess.run( @@ -111,6 +112,49 @@ def test_vps_check_only_preserves_remote_readiness_diagnostic(self): self.assertNotEqual(result.returncode, 0) self.assertIn("当前为 alpine", result.stderr) + def test_vps_check_only_binds_ssh_to_requested_interface(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + fake_bin = root / "bin" + fake_bin.mkdir() + fake_ssh = fake_bin / "ssh" + fake_ssh.write_text( + "#!/bin/sh\n" + "printf '%s\\n' \"$*\" >> \"$SSH_ARGS_LOG\"\n" + "case \"$*\" in *mt@*) exit 1 ;; esac\n" + "printf 'Debian GNU/Linux 12|x86_64'\n" + ) + fake_ssh.chmod(0o755) + private_key = root / "id_ed25519" + private_key.write_text("test-only-placeholder\n") + ssh_args_log = root / "ssh-args.log" + env = os.environ.copy() + env["PATH"] = f"{fake_bin}:{env['PATH']}" + env["SSH_ARGS_LOG"] = str(ssh_args_log) + result = subprocess.run( + [ + "bash", + str(PROJECT_ROOT / "deploy-vps.sh"), + "--profile", + "cstone-next", + "--host", + "198.51.100.10", + "--ssh-key", + str(private_key), + "--ssh-interface", + "en0", + "--check-only", + ], + env=env, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + calls = ssh_args_log.read_text().splitlines() + self.assertGreaterEqual(len(calls), 2) + self.assertTrue(all("BindInterface=en0" in call for call in calls)) + def test_check_only_rejects_interactive_key_install(self): result = subprocess.run( [ @@ -210,10 +254,12 @@ def test_vps_provider_copies_non_secret_config_with_isolated_client_prefix(self) VPS_HOST=203.0.113.10 mkdir -p "$PROJECT_DIR/core" "$PROJECT_DIR/providers" "$PROJECT_DIR/config" cp {shlex.quote(str(PROJECT_ROOT / 'core' / 'common.sh'))} "$PROJECT_DIR/core/common.sh" + cp {shlex.quote(str(PROJECT_ROOT / 'core' / 'settings.py'))} "$PROJECT_DIR/core/settings.py" cp {shlex.quote(str(PROJECT_ROOT / 'providers' / 'vps.sh'))} "$PROJECT_DIR/providers/vps.sh" cp {shlex.quote(str(PROJECT_ROOT / 'config' / 'deploy.conf.example'))} "$PROJECT_DIR/config/deploy.conf.example" . "$PROJECT_DIR/core/common.sh" . "$PROJECT_DIR/providers/vps.sh" + VPS_SSH_PORT=22 VPS_SSH_KEY=/test/key VPS_ADMIN_USER=mt VPS_BOOTSTRAP_USER=root VPS_SSH_INTERFACE= provider_configure >/dev/null grep -Fx 'PROJECT_ID=vps' "$CONF_FILE" >/dev/null grep -Fx 'DEVICES=mac' "$CONF_FILE" >/dev/null @@ -355,6 +401,13 @@ def test_cdn_setup_runs_with_active_profile_before_host_changes(self): "printf 'CF_TUNNEL_TOKEN=test-tunnel\\n' >> \"$NETWORK_NODE_STATE_DIR/.secrets.env\"\n" ) fake_cf.chmod(0o755) + (root / 'core/cdn_probe.py').write_text( + 'import pathlib, sys\n' + 'state = pathlib.Path(sys.argv[1])\n' + 'assert (state / "cf-profile").exists()\n' + 'assert "REALITY_PUBLIC=" in (state / ".secrets.env").read_text()\n' + '(state / "cdn-probed").touch()\n' + ) command = textwrap.dedent( f""" set -euo pipefail @@ -389,6 +442,7 @@ def test_cdn_setup_runs_with_active_profile_before_host_changes(self): provider_print_summary() {{ :; }} 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" """ ) diff --git a/tests/test_gcp_account.py b/tests/test_gcp_account.py new file mode 100644 index 0000000..ba4e6ca --- /dev/null +++ b/tests/test_gcp_account.py @@ -0,0 +1,98 @@ +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class GcpAccountTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.state = self.root / 'profile' + self.state.mkdir() + self.conf = self.state / 'deploy.conf' + self.conf.write_text('GCP_ACCOUNT=saved@example.com\nPROJECT_ID=test-project\nREGION=us-west1\n') + self.log = self.root / 'calls.jsonl' + executable = self.root / 'gcloud' + executable.write_text('''#!/usr/bin/env python3 +import json, os, sys +with open(os.environ['GCLOUD_TEST_LOG'], 'a') as handle: + handle.write(json.dumps(sys.argv[1:]) + '\\n') +if 'get-value' in sys.argv: + print('global@example.com') +elif 'print-access-token' in sys.argv: + print('test-token-must-not-appear') + sys.exit(int(os.environ.get('GCLOUD_AUTH_FAIL', '0'))) +elif 'mktemp' in ' '.join(sys.argv): + print('/tmp/network-node.test1234') +''') + executable.chmod(0o755) + self.env = os.environ | { + 'PATH': str(self.root) + os.pathsep + os.environ['PATH'], + 'NETWORK_NODE_STATE_DIR': str(self.state), + 'GCLOUD_TEST_LOG': str(self.log), + 'PROJECT_DIR': str(ROOT), + } + self.env.pop('GCP_ACCOUNT', None) + + def calls(self): + return [json.loads(line) for line in self.log.read_text().splitlines()] + + def test_local_management_proxy_is_loaded_by_entrypoint(self): + with self.conf.open('a') as handle: + handle.write('GCP_HTTP_PROXY=http://127.0.0.1:7890\n') + command = ''' + . "$PROJECT_DIR/core/common.sh" + . "$PROJECT_DIR/providers/gcp.sh" + provider_init + test "$HTTPS_PROXY" = http://127.0.0.1:7890 + test "$HTTP_PROXY" = http://127.0.0.1:7890 + ''' + subprocess.run(['bash', '-euc', command], env=self.env, capture_output=True, check=True) + + def test_preflight_checks_saved_account_without_showing_token(self): + for failed in (False, True): + with self.subTest(expired=failed): + result = subprocess.run(['bash', str(ROOT / 'providers/gcp-preflight.sh')], + env=self.env | {'GCLOUD_AUTH_FAIL': str(int(failed))}, capture_output=True, text=True) + self.assertEqual(result.returncode, int(failed)) + self.assertNotIn('test-token-must-not-appear', result.stdout + result.stderr) + self.assertEqual(self.calls()[-1], ['--account', 'saved@example.com', 'auth', 'print-access-token']) + + def test_configure_pins_legacy_account_and_preserves_saved_account(self): + self.conf.write_text('PROJECT_ID=test-project\nREGION=us-west1\nGCP_ACCOUNT=\n') + command = '. "$PROJECT_DIR/core/common.sh"; . "$PROJECT_DIR/providers/gcp.sh"; provider_configure' + subprocess.run(['bash', '-euc', command], env=self.env, capture_output=True, check=True) + self.assertIn('GCP_ACCOUNT=global@example.com', self.conf.read_text()) + self.conf.write_text(self.conf.read_text().replace('global@example.com', 'saved@example.com')) + self.log.write_text('') + subprocess.run(['bash', '-euc', command], env=self.env, capture_output=True, check=True) + self.assertEqual(self.calls(), []) + self.assertIn('GCP_ACCOUNT=saved@example.com', self.conf.read_text()) + self.assertEqual(self.conf.stat().st_mode & 0o777, 0o600) + + def test_install_uses_saved_account_and_profile_ssh_key_for_every_call(self): + command = ''' + . "$PROJECT_DIR/core/common.sh" + . "$PROJECT_DIR/providers/gcp.sh" + load_conf + ZONE=us-west1-a INSTANCE_NAME=test-node + provider_install setup-server.sh download.sh server-env.sh + ''' + subprocess.run(['bash', '-euc', command], env=self.env, capture_output=True, check=True) + calls = self.calls() + self.assertEqual(len(calls), 3) + for args in calls: + self.assertEqual(args[:2], ['--account', 'saved@example.com']) + self.assertEqual(args[args.index('--ssh-key-file') + 1], str(self.state / 'ssh/google_compute_engine')) + self.assertEqual((self.state / 'ssh').stat().st_mode & 0o777, 0o700) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_gcp_ip.py b/tests/test_gcp_ip.py new file mode 100644 index 0000000..ae4b5df --- /dev/null +++ b/tests/test_gcp_ip.py @@ -0,0 +1,192 @@ +"""Exercise address drift and interruption recovery without contacting GCP.""" +import copy +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'providers')) +from gcp_ip import CloudError, Gcp, Rotation, check +from settings import load_settings +from profile_lock import profile_lock + + +class FakeGcp: + def __init__(self, settings): + self.s = settings + self.machine = {'id': '123', 'name': 'vm', 'selfLink': 'test/vm', 'status': 'RUNNING', + 'networkInterfaces': [{'name': 'nic0', 'accessConfigs': [ + {'name': 'External NAT', 'natIP': '203.0.113.10', 'networkTier': 'PREMIUM'}]}]} + self.addresses = {'old': {'address': '203.0.113.10', 'status': 'IN_USE', + 'users': ['test/vm'], 'networkTier': 'PREMIUM'}} + self.calls = [] + self.fail_add = False + self.pending = False + self.interrupt_add = False + + def vm(self, optional=False): + return copy.deepcopy(self.machine) + + def address(self, name): + return copy.deepcopy(self.addresses.get(name)) + + def idle(self, vm): + if self.pending: + raise ValueError('operation pending') + + def call(self, *args): + self.calls.append(args) + resource, action = args[1:3] + if resource == 'addresses': + name = args[3] + if action == 'create': + self.addresses[name] = {'address': '203.0.113.20', 'status': 'RESERVED', + 'users': [], 'networkTier': 'PREMIUM'} + elif action == 'delete': + del self.addresses[name] + return + nic = self.machine['networkInterfaces'][0] + if action == 'delete-access-config': + old_ip = nic['accessConfigs'][0]['natIP'] + nic['accessConfigs'] = [] + for address in self.addresses.values(): + if address['address'] == old_ip: + address.update(users=[], status='RESERVED') + elif action == 'add-access-config': + ip = args[args.index('--address') + 1] + if ip == '203.0.113.20' and self.fail_add: + raise CloudError('simulated failure') + if ip == '203.0.113.20' and self.interrupt_add: + self.pending = True + raise CloudError('uncertain result') + nic['accessConfigs'] = [{'name': 'External NAT', 'natIP': ip, 'networkTier': 'PREMIUM'}] + for address in self.addresses.values(): + if address['address'] == ip: + address.update(users=['test/vm'], status='IN_USE') + + +class GcpIpTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.state = Path(self.temp.name) + (self.state / 'deploy.conf').write_text( + '# keep comment\nGCP_ACCOUNT=test@example.com\nPROJECT_ID=test\nZONE=us-west1-a\n' + 'REGION=us-west1\nINSTANCE_NAME=vm\nIP_NAME=old\nREALITY_PORT=443\nDEVICES=mac\n') + (self.state / '.secrets.env').write_text('STATIC_IP=203.0.113.10\nDO_NOT_CHANGE=test-secret\n') + self.client = FakeGcp(load_settings(self.state)) + self.rendered = [] + + def rotation(self, **kwargs): + return Rotation(self.state, client=self.client, probe=kwargs.get('probe', lambda *_: None), + renderer=kwargs.get('renderer', lambda state: self.rendered.append(load_settings(state)['STATIC_IP']))) + + def ip(self): + return load_settings(self.state)['STATIC_IP'] + + def test_existing_vm_drift_never_overwrites_local_ip(self): + self.client.addresses['old']['address'] = '203.0.113.20' + with patch('gcp_ip.Gcp', return_value=self.client): + for mode in ({'optional': True}, {'persist': True}): + with self.assertRaisesRegex(ValueError, '不一致'): + check(self.state, **mode) + self.assertEqual(self.ip(), '203.0.113.10') + self.assertEqual(self.client.calls, []) + + def test_plan_has_no_cloud_or_client_writes(self): + self.rotation().rotate() + self.assertEqual(self.client.calls, []) + self.assertEqual(self.rendered, []) + self.assertFalse((self.state / 'ip-rotation.json').exists()) + + def test_success_commits_binding_and_preserves_old_address(self): + observed = [] + def probe(ip, port): + observed.append((ip, port, self.ip())) + self.rotation(probe=probe).rotate(apply=True) + self.assertEqual(observed, [('203.0.113.20', 443, '203.0.113.10')]) + self.assertEqual(self.ip(), '203.0.113.20') + self.assertNotEqual(load_settings(self.state)['IP_NAME'], 'old') + self.assertEqual(self.client.addresses['old']['users'], []) + self.assertEqual(load_settings(self.state)['DO_NOT_CHANGE'], 'test-secret') + self.assertIn('# keep comment', (self.state / 'deploy.conf').read_text()) + self.assertEqual((self.state / 'ip-rotation.json').stat().st_mode & 0o777, 0o600) + self.rotation().rollback(apply=True) + self.assertEqual(self.ip(), '203.0.113.10') + self.rotation().finalize(apply=True) + self.assertEqual(list(self.client.addresses), ['old']) + + def test_add_failure_rebinds_old_ip(self): + self.client.fail_add = True + with self.assertRaisesRegex(ValueError, '已恢复'): + self.rotation().rotate(apply=True) + self.assertEqual(self.ip(), '203.0.113.10') + self.assertEqual(self.client.vm()['networkInterfaces'][0]['accessConfigs'][0]['natIP'], self.ip()) + + def test_unreachable_new_ip_is_not_published(self): + def failed(*_): + raise ValueError('TCP unreachable') + with self.assertRaisesRegex(ValueError, '已恢复'): + self.rotation(probe=failed).rotate(apply=True) + self.assertNotIn('203.0.113.20', self.rendered) + self.assertEqual(self.ip(), '203.0.113.10') + + def test_renderer_failure_restores_old_binding_and_outputs(self): + def renderer(state): + if load_settings(state)['STATIC_IP'] == '203.0.113.20': + raise ValueError('disk write failed') + self.rendered.append(self.ip()) + with self.assertRaisesRegex(ValueError, '已恢复'): + self.rotation(renderer=renderer).rotate(apply=True) + self.assertEqual(self.ip(), '203.0.113.10') + self.assertEqual(load_settings(self.state)['IP_NAME'], 'old') + + def test_uncertain_pending_operation_waits_for_explicit_recovery(self): + self.client.interrupt_add = True + with self.assertRaisesRegex(ValueError, '自动恢复也未完成'): + self.rotation().rotate(apply=True) + self.assertTrue((self.state / 'ip-rotation.json').exists()) + with self.assertRaisesRegex(ValueError, 'operation pending'): + self.rotation().rollback(apply=True) + self.client.pending = False + self.rotation().rollback(apply=True) + self.assertEqual(self.ip(), '203.0.113.10') + + def test_external_reassignment_and_rebuilt_vm_are_not_overwritten(self): + self.rotation().rotate(apply=True) + self.client.machine['id'] = 'recreated' + with self.assertRaisesRegex(ValueError, '重建'): + self.rotation().rollback(apply=True) + self.client.machine['id'] = '123' + self.client.machine['networkInterfaces'][0]['accessConfigs'][0]['natIP'] = '203.0.113.99' + with self.assertRaisesRegex(ValueError, '第三方'): + self.rotation().rollback(apply=True) + + def test_second_rotation_requires_finalizing_first(self): + self.rotation().rotate(apply=True) + with self.assertRaisesRegex(ValueError, '已有'): + self.rotation().rotate(apply=True) + self.rotation().finalize(apply=True) + self.assertNotIn('old', self.client.addresses) + self.assertFalse((self.state / 'ip-rotation.json').exists()) + + def test_cloud_failure_is_not_treated_as_resource_absence(self): + with patch('gcp_ip.subprocess.run', return_value=subprocess.CompletedProcess([], 1, '', 'secret-value')), patch('gcp_ip.time.sleep'): + with self.assertRaises(CloudError) as failure: + Gcp(load_settings(self.state)).vm(optional=True) + self.assertNotIn('secret-value', str(failure.exception)) + + def test_profile_lock_excludes_concurrent_operations(self): + with profile_lock(self.state): + with self.assertRaisesRegex(ValueError, '正在'): + with profile_lock(self.state): + self.fail('lock should reject concurrent operation') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_gen_clash.py b/tests/test_gen_clash.py index 79379d4..fe2b7b6 100644 --- a/tests/test_gen_clash.py +++ b/tests/test_gen_clash.py @@ -23,6 +23,9 @@ def test_generates_one_config_per_device_from_shared_core(self): "REALITY_SNI=", "DEVICES=mac phone", "CDN_ENABLE=false", + "AI_STRICT_MODE=false", + "PRIVACY_MODE=true", + "CLIENT_TARGET=mihomo", ] ) + "\n" @@ -79,7 +82,7 @@ def test_generates_one_config_per_device_from_shared_core(self): self.assertIn(' - "tcp://any:53"', mac) self.assertIn(" ipv6: false\n enhanced-mode: fake-ip", mac) self.assertIn(" respect-rules: true", mac) - self.assertIn(" follow-rule: true", mac) + self.assertNotIn(" follow-rule: true", mac) self.assertIn(" proxy-server-nameserver:", mac) overseas_dns = mac.split(" nameserver:\n", 1)[1].split( " nameserver-policy:", 1 @@ -269,7 +272,7 @@ def test_cdn_only_config_omits_direct_ip_nodes(self): self.assertNotIn('name: "US-AnyTLS"', config) self.assertNotIn("203.0.113.10", config) ai_group = config.split('name: "🤖 AI 隐私出口"', 1)[1].split( - 'name: "🛟 自动故障切换"', 1 + '\n - name:', 1 )[0] self.assertIn(" type: fallback", ai_group) self.assertIn(' - "US-CDN"', ai_group) @@ -307,7 +310,7 @@ def test_ai_privacy_fallback_prefers_reality_then_cdn(self): self.assertEqual(result.returncode, 0, result.stderr) config = (root / "clash-configs" / "test-mac.yaml").read_text() ai_group = config.split('name: "🤖 AI 隐私出口"', 1)[1].split( - 'name: "🛟 自动故障切换"', 1 + '\n - name:', 1 )[0] self.assertIn(" type: fallback", ai_group) reality_index = ai_group.index(' - "US-Reality"') @@ -322,7 +325,7 @@ def test_warp_reality_node_is_manual_only(self): root = pathlib.Path(tmp) (root / "deploy.conf").write_text( "REALITY_PORT=443\nREALITY_TARGET=1.1.1.1:443\nREALITY_SNI=\n" - "DEVICES=mac\nCDN_ENABLE=false\nWARP_ENABLE=true\n" + "DEVICES=mac\nCDN_ENABLE=false\nWARP_ENABLE=true\nAI_STRICT_MODE=false\n" ) (root / ".secrets.env").write_text( "STATIC_IP=203.0.113.10\nREALITY_PUBLIC=test-public-key\n" @@ -400,7 +403,7 @@ def test_hysteria_optional_obfuscation_and_port_hopping_are_rendered(self): (root / "deploy.conf").write_text( "REALITY_PORT=443\nREALITY_TARGET=1.1.1.1:443\nREALITY_SNI=\n" "DEVICES=mac\nCDN_ENABLE=false\nHY2_PORT_RANGE=30000-30010\n" - "HY2_HOP_INTERVAL=15-30\nHY2_OBFS_ENABLE=true\n" + "HY2_HOP_INTERVAL=15-30\nHY2_OBFS_ENABLE=true\nCLIENT_TARGET=mihomo\n" "HY2_ACME_ENABLE=true\nHY2_ACME_DOMAIN=hy2.example.com\n" ) (root / ".secrets.env").write_text( @@ -460,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.assertFalse(stale.exists()) + self.assertTrue(stale.exists(), "Unowned legacy outputs must be preserved") self.assertTrue((clients / "dmit-mac.yaml").exists()) def test_custom_client_file_prefix_does_not_change_profile_state(self): @@ -492,7 +495,7 @@ def test_custom_client_file_prefix_does_not_change_profile_state(self): capture_output=True, check=False, ) self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse(stale.exists()) + self.assertTrue(stale.exists(), "Unowned legacy outputs must be preserved") self.assertTrue(unrelated.exists()) self.assertTrue((clients / "cstonecloud-mac.yaml").exists()) diff --git a/tests/test_mihomo_config.py b/tests/test_mihomo_config.py index 35f20ac..571f5f6 100644 --- a/tests/test_mihomo_config.py +++ b/tests/test_mihomo_config.py @@ -11,6 +11,28 @@ class MihomoConfigIntegrationTest(unittest.TestCase): def test_current_mihomo_accepts_generated_config(self): + self.check_config() + + def test_current_mihomo_accepts_explicit_strict_mode(self): + self.check_config("AI_STRICT_MODE=true\n") + + def test_current_mihomo_accepts_pin_hopping_cdn_and_warp(self): + self.check_config( + "AI_STRICT_MODE=true\nCDN_ENABLE=true\nCDN_HOSTNAME=cdn.example.com\nCDN_WS_PATH=ws-test\n" + "WARP_ENABLE=true\nWARP_REALITY_PORT=41000\nHY2_PORT_RANGE=31000-31010\n" + "HY2_HOP_INTERVAL=15-30\nHY2_UP=30 mbps\nHY2_DOWN=200 mbps\nHY2_OBFS_ENABLE=true\n", + "HY2_CERT_SHA256=" + "ab" * 32 + "\nHY2_OBFS_PASSWORD=test-obfs\n" + "CDN_UUID_mac=00000000-0000-4000-8000-000000000002\n" + "WARP_REALITY_UUID_mac=00000000-0000-4000-8000-000000000003\n", + ) + + def test_current_mihomo_accepts_cdn_only(self): + self.check_config( + "AI_STRICT_MODE=true\nCDN_ENABLE=true\nCDN_ONLY=true\nCDN_HOSTNAME=cdn.example.com\nCDN_WS_PATH=ws-test\n", + "CDN_UUID_mac=00000000-0000-4000-8000-000000000002\n", + ) + + def check_config(self, conf="", secrets=""): mihomo = os.environ.get("MIHOMO_BIN") if not mihomo or not pathlib.Path(mihomo).is_file(): self.skipTest("MIHOMO_BIN is not available") @@ -19,7 +41,7 @@ def test_current_mihomo_accepts_generated_config(self): root = pathlib.Path(tmp) (root / "deploy.conf").write_text( "REALITY_PORT=443\nREALITY_TARGET=1.1.1.1:443\nREALITY_SNI=\n" - "DEVICES=mac\nCDN_ENABLE=false\n" + "DEVICES=mac\nCDN_ENABLE=false\nCLIENT_TARGET=mihomo\n" + conf ) (root / ".secrets.env").write_text( "STATIC_IP=203.0.113.10\n" @@ -27,7 +49,7 @@ def test_current_mihomo_accepts_generated_config(self): "REALITY_SHORTID=0123456789abcdef\nHY2_PORT=31000\n" "ANYTLS_PORT=21000\nANYTLS_PASS=test-anytls-pass\n" "REALITY_UUID_mac=00000000-0000-4000-8000-000000000001\n" - "HY2_PASS_mac=test-hy2-mac\n" + "HY2_PASS_mac=test-hy2-mac\n" + secrets ) env = os.environ.copy() env["NETWORK_NODE_ROOT"] = str(root) @@ -48,6 +70,7 @@ def test_current_mihomo_accepts_generated_config(self): text=True, capture_output=True, check=False, + timeout=180, ) self.assertEqual(parsed.returncode, 0, parsed.stdout + parsed.stderr) diff --git a/tests/test_net_probe.py b/tests/test_net_probe.py new file mode 100644 index 0000000..86a9f79 --- /dev/null +++ b/tests/test_net_probe.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path +import subprocess +import sys +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'core')) +from net_probe import connect, resolve_ipv4 + + +class PhysicalProbeTests(unittest.TestCase): + def test_doh_bypasses_proxy_environment_and_system_fake_ip(self): + result = subprocess.CompletedProcess([], 0, json.dumps({'Status': 0, 'Answer': [ + {'type': 1, 'data': '1.1.1.1'}]}), '') + with patch('net_probe.subprocess.run', return_value=result) as run: + self.assertEqual(resolve_ipv4('cdn.example.com', 'en0', 10), ['1.1.1.1']) + args = run.call_args.args[0] + self.assertEqual(args[args.index('--interface') + 1], 'en0') + self.assertEqual(args[args.index('--noproxy') + 1], '*') + self.assertIn('--resolve', args) + self.assertNotIn('--insecure', args) + + def test_fake_or_private_dns_answers_are_rejected(self): + for address in ('198.18.0.1', '127.0.0.1', '10.0.0.1'): + result = subprocess.CompletedProcess([], 0, json.dumps({'Status': 0, 'Answer': [ + {'type': 1, 'data': address}]}), '') + with patch('net_probe.subprocess.run', return_value=result): + with self.assertRaisesRegex(ValueError, '未使用系统'): + resolve_ipv4('cdn.example.com', 'en0', 10) + + def test_darwin_socket_is_bound_before_connection(self): + sock = Mock() + with patch('net_probe.sys.platform', 'darwin'), patch('net_probe.socket.if_nametoindex', return_value=4), \ + patch('net_probe.socket.socket', return_value=sock), patch('net_probe.resolve_ipv4', return_value=['1.1.1.1']): + self.assertIs(connect('cdn.example.com', 443, 20, 'en0'), sock) + self.assertEqual(sock.method_calls[0].args[1:], (25, 4)) + sock.connect.assert_called_once_with(('1.1.1.1', 443)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_provider_scripts.py b/tests/test_provider_scripts.py index 6b5b82f..6cd2ae0 100644 --- a/tests/test_provider_scripts.py +++ b/tests/test_provider_scripts.py @@ -12,7 +12,7 @@ def test_optional_warp_reality_port_is_opened_by_both_providers(self): gcp = (PROJECT_ROOT / "providers" / "gcp-provision.sh").read_text() vps = (PROJECT_ROOT / "providers" / "vps.sh").read_text() self.assertIn('FW_RULES="${FW_RULES},tcp:${WARP_REALITY_PORT}"', gcp) - self.assertIn('sudo ufw allow "${WARP_REALITY_PORT}/tcp"', vps) + self.assertIn('printf \'%s/tcp\\n\' "$WARP_REALITY_PORT" >> "$expected"', vps) def test_xray_direct_outbounds_prefer_ipv4(self): setup = (PROJECT_ROOT / "core" / "setup-server.sh").read_text() @@ -22,15 +22,15 @@ def test_xray_direct_outbounds_prefer_ipv4(self): ) self.assertNotIn('"domainStrategy": "UseIPv6v4"', setup) - def test_server_setup_removes_cross_user_installer_cache(self): + def test_server_setup_uses_isolated_installer_directory(self): setup = (PROJECT_ROOT / "core" / "setup-server.sh").read_text() - cleanup = "sudo rm -f /tmp/xray.zip /tmp/hysteria /tmp/anytls.zip" - self.assertIn(cleanup, setup) - self.assertLess(setup.index(cleanup), setup.index("download_file /tmp/xray.zip")) + self.assertIn('INSTALL_TMP="$(mktemp -d)"', setup) + self.assertNotIn('download_file /tmp/', setup) + self.assertIn('flock -n 9', setup) def test_gcloud_retry_propagates_final_failure(self): script = (PROJECT_ROOT / "providers" / "gcp-provision.sh").read_text() - retry_function = "\n".join(script.splitlines()[17:34]) + retry_function = script[script.index('gcloud_retry() {'):script.index('\n}', script.index('gcloud_retry() {')) + 2] command = f""" set -euo pipefail PROJECT_DIR={str(PROJECT_ROOT)!r} diff --git a/tests/test_safety_regressions.py b/tests/test_safety_regressions.py new file mode 100644 index 0000000..cc981cf --- /dev/null +++ b/tests/test_safety_regressions.py @@ -0,0 +1,293 @@ +import hashlib +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "core")) +from settings import load_kv, load_settings, validate +from client_output import write_outputs + + +class SafetyRegressions(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.state = self.root / "state" + self.state.mkdir() + self.output = self.root / "clients" + self.conf = self.state / "deploy.conf" + self.conf.write_text("DEVICES=mac phone\nREALITY_PORT=443\nREALITY_SNI=\n") + self.secrets = self.state / ".secrets.env" + self.secrets.write_text( + "STATIC_IP=203.0.113.10\nREALITY_PUBLIC=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n" + "REALITY_SHORTID=0123456789abcdef\nHY2_PORT=31000\nANYTLS_PORT=21000\n" + "ANYTLS_PASS=test-only-password\n" + "REALITY_UUID_mac=00000000-0000-4000-8000-000000000001\nHY2_PASS_mac=test-mac\n" + "REALITY_UUID_phone=00000000-0000-4000-8000-000000000002\nHY2_PASS_phone=test-phone\n" + ) + self.env = os.environ | {"NETWORK_NODE_ROOT": str(ROOT), "NETWORK_NODE_STATE_DIR": str(self.state), + "NETWORK_NODE_CLIENTS_DIR": str(self.output), "NETWORK_NODE_PROFILE": "alpha", + "PROFILE_NAME": "alpha"} + + def generate(self, *args, success=True): + result = subprocess.run([sys.executable, str(ROOT / "core/gen-clash.py"), *args], + env=self.env, capture_output=True, text=True) + if success: + self.assertEqual(result.returncode, 0, result.stderr) + return result + + def add_conf(self, text): + with self.conf.open("a") as handle: + handle.write(text) + + def run_secrets(self): + result = subprocess.run(["bash", str(ROOT / "core/secrets.sh")], env=self.env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return result + + 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() + groups = text.split("\nproxy-groups:\n")[1].split("\nrule-providers:\n")[0] + self.assertEqual(groups.count(" - name:"), 2) + self.assertNotIn("- DIRECT", groups) + self.assertNotIn("US-HY2", groups) + self.assertNotIn("US-AnyTLS", groups) + self.assertNotIn("US-Reality-WARP", groups) + rules = text.split("\nrules:\n")[1] + self.assertTrue(rules.rstrip().endswith("MATCH,🤖 AI 隐私出口")) + self.assertLess(rules.index("192.168.0.0/16,DIRECT"), rules.index("DST-PORT,123,")) + self.assertIn("IP-CIDR,1.1.1.1/32,🤖 AI 隐私出口,no-resolve", rules) + for rule in rules.splitlines(): + if ",DIRECT" in rule: + self.assertTrue(rule.startswith((" - IP-CIDR", " - DOMAIN,localhost,", " - DOMAIN-SUFFIX,local,", " - DOMAIN-SUFFIX,lan,"))) + self.assertNotIn("nameserver-policy:", text) + providers = text.split("\nrule-providers:\n")[1].split("\nrules:\n")[0] + self.assertIn(" ai:", providers) + self.assertIn(" ads-lite:", providers) + self.assertNotIn(" cn:", providers) + self.assertNotIn("GEOSITE,", rules) + self.assertNotIn("IP-ASN,", rules) + + def test_fallback_checks_are_active_and_stash_benchmarks_are_proxy_scoped(self): + for client in ('stash', 'mihomo'): + for strict in ('false', 'true'): + 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() + groups = text.split('\nproxy-groups:\n', 1)[1].split('\nrule-providers:\n', 1)[0] + blocks = groups.split(' - name:') + for block in blocks: + if 'type: fallback' in block: + self.assertIn('interval: 60', block) + self.assertIn('lazy: false', block) + self.assertEqual('benchmark-timeout: 5' in text, client == 'stash') + + def test_cdn_internal_ports_cannot_collide_with_public_services(self): + for port in ('8080', '20241'): + with self.assertRaisesRegex(ValueError, '重复'): + validate({'CDN_ENABLE': 'true', 'ANYTLS_PORT': port}) + + 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() + 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] + for boundary in ("RULE-SET,ads-lite,", "DOMAIN-SUFFIX,cn,", "RULE-SET,cn,", "RULE-SET,cn-ip,"): + for dependency in ("DOMAIN-SUFFIX,openai.com,", "DOMAIN,anthropic.auth0.com,", + "DOMAIN,cdn.workos.com,", "DOMAIN-KEYWORD,datadog,", "RULE-SET,ai,"): + self.assertLess(rules.index(dependency), rules.index(boundary)) + for resolver in ("223.5.5.5", "120.53.53.53"): + route = f"IP-CIDR,{resolver}/32,🇨🇳 国内流量,no-resolve" + self.assertLess(rules.index(route), rules.index("RULE-SET,cn-ip,")) + for resolver in ("1.1.1.1", "8.8.8.8"): + self.assertIn(f"IP-CIDR,{resolver}/32,🤖 AI 隐私出口,no-resolve", rules) + self.assertIn(" nameserver-policy:", config) + self.assertTrue(rules.rstrip().endswith("MATCH,🎯 兜底策略")) + + 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() + 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() + 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] + ads = rules.index("RULE-SET,ads-lite,") + for dependency in ("DOMAIN-KEYWORD,datadog,", "DOMAIN-KEYWORD,sentry,", "DOMAIN-KEYWORD,sift,", + "DOMAIN,cdn.workos.com,", "DOMAIN,humb.apple.com,", "DOMAIN,js.stripe.com,"): + self.assertLess(rules.index(dependency), ads) + + 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() + self.assertIn("up-speed: 30", stash) + self.assertIn("down-speed: 200", stash) + self.assertIn(" follow-rule: true", stash) + self.assertNotIn("respect-rules:", stash) + self.assertNotIn("\ntun:", stash) + self.assertIn("PROTOCOL,STUN,", stash) + anytls = stash.split('name: "US-AnyTLS"')[1].split("\nproxy-groups:")[0] + 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() + self.assertIn('up: "30 mbps"', mihomo) + self.assertIn("respect-rules: true", mihomo) + self.assertNotIn("follow-rule:", mihomo) + self.assertNotIn("PROTOCOL,STUN,", mihomo) + self.assertIn("\ntun:", mihomo) + + 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.generate("--client", "mihomo") + text = (self.output / "alpha-mac.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" + 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()) + + def test_collision_rejected_before_overwriting_existing_owner(self): + self.generate() + before = (self.output / "alpha-mac.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) + + def test_only_owned_unchanged_stale_files_are_deleted(self): + self.generate() + legacy = self.output / "alpha-old.yaml" + legacy.write_text("unowned\n") + self.add_conf("DEVICES=mac\n") + self.assertNotEqual(self.generate("--check", success=False).returncode, 0) + self.assertTrue((self.output / "alpha-phone.yaml").exists()) + self.generate() + self.assertFalse((self.output / "alpha-phone.yaml").exists()) + self.assertEqual(legacy.read_text(), "unowned\n") + + def test_check_detects_drift_without_writing(self): + result = self.generate("--check", success=False) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.output.exists()) + self.generate() + self.generate("--check") + snapshot = {p.name: p.read_bytes() for p in self.output.iterdir()} + 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()}) + + def test_disabled_profile_does_not_recreate_or_change_outputs(self): + self.add_conf("CLIENT_CONFIG_ENABLE=false\n") + self.generate() + self.generate("--check") + 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()} + self.add_conf("CLIENT_CONFIG_ENABLE=false\n") + self.generate() + self.assertEqual(snapshot, {p.name: p.read_bytes() for p in self.output.iterdir()}) + + def test_explicit_port_overrides_state_and_empty_reuses_state(self): + self.add_conf("HY2_PORT=32000\n") + self.run_secrets() + self.assertEqual(load_kv(self.secrets)["HY2_PORT"], "32000") + self.add_conf("HY2_PORT=\n") + self.run_secrets() + self.assertEqual(load_settings(self.state)["HY2_PORT"], "32000") + + def test_anytls_rotation_is_automatic_stable_and_never_logged(self): + first = self.run_secrets() + before = load_kv(self.secrets)["ANYTLS_PASS"] + self.run_secrets() + self.assertTrue(load_kv(self.secrets)["ANYTLS_PASS"] == before) + self.add_conf("DEVICES=mac\n") + result = self.run_secrets() + after = load_kv(self.secrets)["ANYTLS_PASS"] + self.assertTrue(after != before) + self.assertNotIn(after, result.stdout + result.stderr) + self.assertNotIn(before, first.stdout + first.stderr) + self.run_secrets() + self.assertTrue(load_kv(self.secrets)["ANYTLS_PASS"] == after) + saved = load_kv(self.secrets) + self.assertNotIn("REALITY_UUID_phone", saved) + self.assertNotIn("HY2_PASS_phone", saved) + self.add_conf("DEVICES=mac phone\n") + self.run_secrets() + self.assertNotEqual(load_kv(self.secrets)["HY2_PASS_phone"], "test-phone") + + def test_kv_values_are_literal_and_errors_do_not_expose_values(self): + self.conf.write_text("DEVICES='mac phone' # devices\nHY2_PORT=32000 # configured\nX='$(touch /tmp/never-run-network-node)'\n") + settings = load_settings(self.state) + self.assertEqual(settings["DEVICES"], "mac phone") + self.assertEqual(settings["HY2_PORT"], "32000") + self.assertEqual(settings["X"], "$(touch /tmp/never-run-network-node)") + self.conf.write_text("HY2_PASS_mac='DO-NOT-PRINT\n") + with self.assertRaises(ValueError) as caught: + load_kv(self.conf) + self.assertNotIn("DO-NOT-PRINT", str(caught.exception)) + + def test_preflight_rejects_invalid_config_before_secret_mutation(self): + before = self.secrets.read_bytes() + self.add_conf("HY2_ACME_ENABLE=true\n") + result = subprocess.run(["bash", str(ROOT / "core/secrets.sh")], env=self.env, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.secrets.read_bytes(), before) + for bad in ({"DEVICES": "phone-2"}, {"PRIVACY_MODE": "flase"}, {"HY2_PORT": "70000"}, + {"HY2_PORT_RANGE": "40000-30000"}, {"HY2_HOP_INTERVAL": "15-30"}): + with self.subTest(bad=bad), self.assertRaises(ValueError): + validate(bad) + + def test_saved_connection_does_not_override_cli_and_does_not_execute_values(self): + marker = self.root / "should-not-exist" + value = f"$(touch {marker})" + (self.state / "connection.conf").write_text(f"VPS_SSH_PORT=2222\nVPS_ADMIN_USER=admin\nVPS_SSH_KEY='{value}'\n") + script = f'''set -eu +VPS_SSH_PORT=3333 +eval "$(python3 {ROOT / 'core/settings.py'} connection-shell {self.state})" +test "$VPS_SSH_PORT" = 3333 +test "$VPS_ADMIN_USER" = admin +''' + # Quote the source path because the real checkout may contain spaces. + import shlex + script = script.replace(str(ROOT / "core/settings.py"), shlex.quote(str(ROOT / "core/settings.py"))) + result = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(marker.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_server_safety.py b/tests/test_server_safety.py new file mode 100644 index 0000000..fa92008 --- /dev/null +++ b/tests/test_server_safety.py @@ -0,0 +1,150 @@ +"""Exercise installer failure paths with fake commands and a temporary filesystem.""" +import hashlib +import os +from pathlib import Path +import shlex +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent + + +class ServerSafetyTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + + def run_shell(self, script, **env): + return subprocess.run(["bash", "-c", "set -euo pipefail\n" + script], cwd=self.root, + env=os.environ | env, capture_output=True, text=True) + + def test_download_checksum_cache_and_atomic_replacement(self): + digest = hashlib.sha256(b"release").hexdigest() + script = f""" +. {shlex.quote(str(ROOT / 'core/download.sh'))} +release_sha256() {{ printf '%s' {digest}; }} +download_file() {{ printf release > "$1"; printf download >> calls; }} +NETWORK_NODE_DOWNLOAD_CACHE="$PWD/cache" +download_release "$PWD/binary" ignored +download_release "$PWD/cached" ignored +test "$(cat calls)" = download +printf corrupt > "cache/{digest}" +download_release "$PWD/cached" ignored +test "$(cat calls)" = downloaddownload +printf old > installed +install_binary binary installed +test "$(cat installed)" = release +test "$(cat installed.previous)" = old +install_binary binary installed +test "$(cat installed.previous)" = old +unset NETWORK_NODE_DOWNLOAD_CACHE +download_file() {{ printf corrupt > "$1"; }} +if download_release "$PWD/rejected" ignored; then exit 90; fi +test ! -e rejected +test "$(cat installed)" = release +""" + result = self.run_shell(script) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_running_cloudflared_without_connections_fails_readiness(self): + source = (ROOT / 'core/setup-server.sh').read_text() + start = source.index('if [ "${CDN_ENABLE:-false}" = "true" ]; then\n echo "=== Waiting') + fragment = source[start:source.index('\necho "=== [8/8]', start)] + for connections in ('0', '2'): + result = self.run_shell(''' +sleep() { :; } +curl() { printf 'cloudflared_tunnel_ha_connections %s\n' "$CONNECTIONS"; } +''' + fragment, CDN_ENABLE='true', CONNECTIONS=connections) + self.assertEqual(result.returncode == 0, connections == '2', result.stderr) + + def test_xray_staging_retains_json_extension_and_validation_precedes_replace(self): + source = (ROOT / 'core/setup-server.sh').read_text() + start = source.index('sudo install -o root -g xray -m 640') + fragment = source[start:source.index('\nsudo mkdir -p /etc/hysteria', start)] + fragment = fragment.replace('/usr/local/etc/xray', str(self.root / 'xray')).replace('/usr/local/bin/xray', 'mock_xray') + (self.root / 'xray').mkdir() + (self.root / 'install').mkdir() + (self.root / 'install/xray-config.json').write_text('{"new": true}') + target = self.root / 'xray/config.json' + for valid in ('false', 'true'): + target.write_text('{"old": true}') + result = self.run_shell(''' +sudo() { if [ "$1" = -u ]; then shift 2; fi; "$@"; } +install() { cp "$7" "$8"; } +mock_xray() { + case "$4" in *.json) ;; *) return 2 ;; esac + [ "$VALID" = true ] +} +''' + fragment, INSTALL_TMP=str(self.root / 'install'), VALID=valid) + self.assertEqual(result.returncode == 0, valid == 'true', result.stderr) + self.assertEqual(target.read_text(), '{"new": true}' if valid == 'true' else '{"old": true}') + + def firewall(self, **env): + script = (ROOT / "providers/vps.sh").read_text().split("<<'REMOTE_FIREWALL'\n", 1)[1].split("\nREMOTE_FIREWALL", 1)[0] + script = script.replace("/var/lib/network-node", str(self.root / "managed")) + defaults = dict(SSH_PORT="2222", REALITY_PORT="443", HY2_PORT="31000", HY2_PORT_RANGE="32000-32010", + ANYTLS_PORT="21000", WARP_ENABLE="true", WARP_REALITY_PORT="41000", CDN_ONLY="false") + return self.run_shell('sudo() { "$@"; }\nufw() { printf "%s\\n" "$*" >> "$PWD/ufw.log"; }\n' + script, + **(defaults | env)) + + def test_firewall_opens_ssh_first_and_only_removes_managed_stale_ports(self): + result = self.firewall() + self.assertEqual(result.returncode, 0, result.stderr) + previous = self.root / "managed/ufw-ports" + self.assertIn("32000:32010/udp", previous.read_text()) + self.assertIn("41000/tcp", previous.read_text()) + (self.root / "ufw.log").write_text("") + result = self.firewall(WARP_ENABLE="false", HY2_PORT_RANGE="", HY2_PORT="33000") + self.assertEqual(result.returncode, 0, result.stderr) + log = (self.root / "ufw.log").read_text() + self.assertLess(log.index("allow 2222/tcp"), log.index("delete allow")) + self.assertIn("delete allow 32000:32010/udp", log) + self.assertIn("delete allow 41000/tcp", log) + self.assertNotIn("reset", log) + self.assertNotIn("delete allow 2222/tcp", log) + + def test_cdn_only_never_closes_ssh_on_a_former_proxy_port(self): + result = self.firewall(SSH_PORT="443", CDN_ONLY="true") + self.assertEqual(result.returncode, 0, result.stderr) + log = (self.root / "ufw.log").read_text() + self.assertNotIn("delete allow 443/tcp", log) + self.assertEqual((self.root / "managed/ufw-ports").read_text(), "443/tcp\n") + + def ssh_hardening(self, mode): + source = (ROOT / "core/setup-server.sh").read_text() + script = source[source.index("sudo mkdir -p /etc/ssh/sshd_config.d"):source.index('\necho "=== Listening sockets ==="')] + script = script.replace("/etc/ssh", str(self.root / "ssh")).replace("/usr/sbin/sshd", "mock_sshd") + return self.run_shell(''' +sudo() { "$@"; } +mock_sshd() { + if [ "$1" = -t ]; then [ "$TEST_MODE" != syntax ]; return; fi + [ "$TEST_MODE" != effective_error ] || return 1 + [ "$TEST_MODE" != ineffective ] || { printf 'passwordauthentication yes\n'; return; } + printf 'passwordauthentication no\npermitrootlogin no\nkbdinteractiveauthentication no\npubkeyauthentication yes\n' +} +systemctl() { printf reload >> reload.log; [ "$TEST_MODE" != reload_failure ]; } +''' + script, INSTALL_TMP=str(self.root / "temp"), TEST_MODE=mode) + + def test_ssh_failure_restores_config_and_does_not_reload_invalid_config(self): + (self.root / "ssh").mkdir() + (self.root / "temp").mkdir() + original = "PasswordAuthentication yes\n" + config = self.root / "ssh/sshd_config" + for mode in ("syntax", "ineffective", "effective_error", "reload_failure"): + with self.subTest(mode=mode): + config.write_text(original) + (self.root / "reload.log").unlink(missing_ok=True) + result = self.ssh_hardening(mode) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(config.read_text(), original) + self.assertFalse((self.root / "ssh/sshd_config.d/00-network-node.conf").exists()) + self.assertEqual((self.root / "reload.log").exists(), mode == "reload_failure") + result = self.ssh_hardening("success") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(config.read_text().startswith("Include ")) + + +if __name__ == "__main__": + unittest.main()