Files
eli 7550a65c61 feat(migrate): 额外站点块直接装进站点目录——迁移后不再需要人工合并
配合 setup_caddy_xray_hysteria.sh 的 /etc/caddy/sites 扩展点:迁移抽出的非主域站点块
现在直接安装为 CADDY_SITES_DIR/migrated-extra-sites.caddy(干跑落 DRY_RUN_DIR/sites/),
升级后由新 Caddyfile 末尾的 import 自动加载,install/upgrade 永不覆盖该目录;
参考件仍留一份在迁移输出目录。

刻意保留两道「只留参考文件、不自动安装」的保护(装进去会让 caddy validate 失败、
反而挡住升级):① 块里含 dns <provider> 等定制插件指令(官方 apt caddy 不含该插件);
② 装了 caddy 时 `caddy adapt` 预检不通过(只解析适配,不做 TLS 签发)。
另外:缺 bind 127.0.0.1 的块会告警(443 回落流量到不了它)、目标文件已存在时先备份
.bak-<时间戳>、结束语按是否真装分别提示。

测试 +6(共 194):dns 插件块不安装、干净块安装且原文保留、重复迁移先备份、缺 bind 告警。
文档:README 迁移细则 + AGENTS 脚本 4 描述 + HANDOFF §7.2/§7.4。
2026-10-10 11:05:37 +08:00

84 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md
本文件是本仓库的项目指引,**DSH 与 Claude Code 都读它**(`AGENTS.md` 是唯一真源;`CLAUDE.md` 只是指向本文件的短指针)。
This file provides guidance to coding agents working in this repository.
## 主控状态与访问(2026-10 迁移后)
- 项目主控:本机 DSH,workspace `/home/eli/vpsetup`(从「dockerno1 上的 Claude Code」迁入)。
- 旧 Claude 会话原始记录(**含真实凭据,勿入库、勿外传**):`~/.dsh/archive/vpsetup-claude-2026-09-28/`。
- 部署拓扑、各机差异、ECH 联调结论、未决项:`docs/HANDOFF.md`。
从本机可直连(私钥已在位,不需要复制私钥、也不需要跳板;下表为 2026-10-09 实测):
| 目标 | 登录用户 | 角色 | 实测状态 |
|---|---|---|---|
| `dockerno1` = 192.168.254.50 | `eli@` | **用户自建的家用服务器(国内)**,非代理节点:仓库与归档在这里,另跑一大堆 docker 服务(nextcloud/firecrawl/open-webui/portainer…)。旧 Claude Code 就跑在这台上。 | 正常 |
| `oraclejp.henhaoji.site` | `ubuntu@` | Oracle 日本 1 号节点,脚本管理 | caddy/xray/hy2 active,Xray 26.3.27 |
| `oraclejp-2.henhaoji.site` | `ubuntu@` | Oracle 日本 2 号节点,脚本管理 | 同上 |
| `gcoregon.henhaoji.site` | `xu_co@` | GCP Oregon:cdn-bypass 透明代理节点 | active + `cdn-bypass-route` active,dokodemo 54443 在听 |
| `alius.henhaoji.site` | `root@` | 阿里云 US:cdn-bypass 的**上游**节点(出口 43.110.45.237) | caddy/xray/hy2 active |
| `git.henhaoji.site` | `ubuntu@` | 私有 git 服务器(本仓库 origin) | 正常 |
| `euserv.henhaoji.site` = `2a02:180:6:1::7ca` | — | EUserv 德国 VPS,**IPv6-only**(IPv4 出口靠 `setup_warp_zerotrust.sh` 走 WARP) | ⚠️ **2026-10-09 实测不可达**:从本机(无 v6 路由)与从 dockerno1(v6 正常,CF DNS 可 ping 通)均 100% 丢包 / SSH 超时,橙云域名 `euserv-cf.henhaoji.site` 也超时 |
- `dockerno1.savorcare.com` 是家用服务器的内网/内网 DNS 名;本机经 WSL NAT(172.25.2.12 → 网关 172.25.0.1)路由到用户内网,所以能直接 ssh 它。
- 家用服务器本身**没有**跑 caddy/xray/hysteria systemd 栈(那是各 VPS 上的),它的 443/8080/8443 监听来自 docker 容器 —— 排查端口占用时别搞混。
- 其余主机(bwg、UK、gpu 等)经跳板:`ssh eli@dockerno1`(`eli@gpu` 本机也直连)。
## Standing constraints(用户下达,务必遵守)
1. **不要碰任何主机的 `rules.v6` / ip6 filter** —— IPv6 规则由另一个 agent 负责,改它等于制造冲突。
2. **凭据纪律**:绝不把 uuid / 分享链接 / token / 私钥打印进对话(会触发凭据拦截);**绝不 `source .env`**(Northflank JWT 含 shell 特殊字符,会被吃空 → 401),改用 `t=$(grep '^key=' .env | head -1 | cut -d= -f2- | tr -d '\r\n')`,或直接把 token 管道喂给消费方。仓库内一律脱敏;`.env` 已 gitignore、永不提交;真实凭据在各机 `/etc/caddy-xray-hy2/setup.env`(mode 600)。
3. **改动闸门**:提交前 `bash -n` + `shellcheck` + `bash tests/run_tests.sh` 全绿;提交保持小而单一。
4. **nft 不变式**:运行时只 `nft -f` 自己的文件,永不执行整份 `/etc/nftables.conf`(其 `flush ruleset` 会清掉并存的 iptables-persistent / Docker 表)。
5. **CDN 清单只收官方公布的网段**,不要自己发明(上游流量有限,凭空加段只增延迟)。
## What this repo is
Four self-contained bash scripts:
1. `setup_warp_zerotrust.sh` — gives an **IPv6-only Debian/Ubuntu VPS an IPv4 egress** via Cloudflare WARP as a plain WireGuard interface (`wg0`) — no official client. Two modes, selected by environment variables:
- **Consumer mode** (default): anonymous free-WARP registration.
- **Team mode** (`CF_TEAM` + `CF_ACCESS_CLIENT_ID`/`CF_ACCESS_CLIENT_SECRET`, or `WARP_JWT`): enrolls as a Cloudflare One (Zero Trust) organization device via service-token → JWT exchange at `https://<team>.cloudflareaccess.com/warp`, then registers with header `CF-Access-Jwt-Assertion`.
2. `setup_caddy_xray_hysteria.sh` — deploys **Caddy + Xray (VLESS+REALITY/WS/XHTTP) + Hysteria2 + nftables port-hopping** on a Debian/Ubuntu VPS. Xray:443 takes REALITY traffic and falls back to Caddy on `127.0.0.1:8003` (PROXY protocol); Caddy serves the camouflage reverse-proxy and routes `WS_PATH` (default `/scilad`; WS:54442 — parameterized so migrations can keep an old path like `/vlessws`) and a random path (XHTTP:8080). Hysteria2:8443/UDP gets its cert via `HY2_CERT_MODE` (default `http`): built-in certmagic ACME HTTP-01 listening only on `127.0.0.1:9180` (altPort), with Caddy's port-80 block reverse-proxying `/.well-known/acme-challenge/` to it — no public port opened, no DNS token needed, non-wildcard `DOMAIN` cert, auto-renewed and hot-applied (TLS-ALPN is unusable: the CA only connects to TCP 443, which Xray owns). Fallback `dns`: Cloudflare DNS-01 for a `*.BASE_DOMAIN` wildcard (needs `CF_API_TOKEN`). nftables redirects UDP 20000-30000 → 8443 (runtime only `nft -f`s its own `hysteria.nft` — never executes the full `/etc/nftables.conf`, whose `flush ruleset` would wipe coexisting iptables-persistent/Docker tables; boot-time ordering nftables@sysinit → netfilter-persistent@multi-user makes the flush harmless there). Config templates are embedded as quoted heredocs with `__VAR__` placeholders (rendered via bash parameter expansion, never sed); credentials auto-generate and persist to `/etc/caddy-xray-hy2/setup.env` (mode 600) which is sourced on re-run. The rendered Caddyfile ends with `import $CADDY_SITES_DIR/*.caddy` (default `/etc/caddy/sites`) — an **upgrade-safe extension point** for user site blocks: the script only creates the dir and, when it is completely empty, a comment-only `00-example.caddy`; it never overwrites or deletes user `*.caddy` files (`uninstall` leaves them alone too). Glob import, so an empty/missing dir is a warning, not an error. Blocks placed there MUST carry `bind 127.0.0.1 [::1]` (otherwise they land on an unwrapped `:8003` listener that Xray's PROXY-protocol fallback can never reach — the script warns but does not block), and a wildcard host needs a supplied cert file because the stock binary ships no DNS plugin. `DRY_RUN=1` renders everything to `./dry-run-output/` without system changes — this is how `tests/run_tests.sh` validates the script on a dev machine. The apt step is tolerant in exactly one narrow case (`apt_update_tolerant()`): when `apt-get update` fails **only** for the Caddy/Cloudsmith repo *and* `caddy` is already installed, it warns and continues (configs still re-rendered, validated and reloaded); any other repo in the errors, or a missing caddy, still hard-fails. Reason: from 2026-10-09 Cloudsmith served 402 for the whole `/deb/debian/` path (quota exhausted by CI abuse — caddyserver/dist#142), which otherwise aborted `ACTION=upgrade` at its first apt call on every host. Actions: `ACTION=install` (default) / `upgrade` (same idempotent pipeline — apt/script installs already pull latest — but requires an existing setup.env and never prompts) / `uninstall`. Legacy setup.env files without `HY2_CERT_MODE` infer `dns` when `CF_API_TOKEN` is present, so upgrades don't silently switch wildcard certs to single-domain. Unlike the WARP script, this one intentionally uses the official cloudsmith apt repo (Caddy) and the official Xray/Hysteria2 install scripts; original hand-written templates are archived in `docs/templates-original/`.
3. `setup_cdn_bypass_tproxy.sh` — for VPSs whose egress to Cloudflare/Fastly/Akamai is blocked or metered (e.g. Google Cloud): transparently proxies **CDN-destined local outbound** through an upstream xray node. Requires an already-installed xray/v2ray service; takes a pasted `VLESS_LINK` share link and (a) injects inbound/outbound/routing via a systemd drop-in adding `-confdir /etc/xray-cdn-bypass/` — the user's own config.json is never modified (drop-in MUST be named `20-cdn-bypass-confdir.conf`: the official xray install script ships `10-donot_touch_single_conf.conf` which re-sets ExecStart to the plain single-config form and sorts after any `10-*` name, silently dropping the confdir); (b) writes `/etc/nftables/cdn-bypass.nft` (mark table `cdn_bypass_mark` + tproxy table `cdn_bypass_tproxy`, embedded 188-CIDR official-source-only list — CF ips-v4 + Fastly public-ip-list + Akamai/Linode WHOIS, audited 2026-09 — since blocked hosts can't fetch lists online) with the same never-run-the-full-nftables.conf convention; all filtering lives in nft (CIDR set + TCP/UDP ports 53/80/443 via `th dport`), the xray fragment is a catch-all dokodemo→proxy with zero filtering; (c) installs oneshot `cdn-bypass-route.service` persisting `ip rule fwmark 1 lookup 100` + `ip route local default dev lo table 100` (ip commands are pure runtime kernel state — nothing else persists them); (d) the injected outbound carries `sockopt.mark=255` and nft exempts mark 255, so an upstream IP inside the CDN ranges can't loop; the main config's direct outbound must NOT carry mark 255 — if it does, client-proxied CDN traffic gets exempted and egresses raw (the classic "local curl works, clients don't" symptom on GCP). IPv4 only. Same conventions: `DRY_RUN=1`, `ACTION=uninstall`, idempotent.
4. `migrate_existing_stack.sh` — one-shot migration bringing a **manually-deployed** caddy/xray/hysteria2 stack under script 2's management. Read-only parses the three old configs (python3 brace-matching for Caddyfile blocks, comment-stripping + json for xray, awk for hysteria YAML), interactively confirms DOMAIN/EMAIL/WS_PATH (defaults from parsed values; DOMAIN ≠ old REALITY serverName warns loudly), then: writes `setup.env` (same `%q` format as save_env) preserving old credentials so same-protocol clients survive, backs up old configs to `/etc/caddy-xray-hy2/migration-backup-<ts>/`, extracts non-main-domain Caddy site blocks verbatim **and installs them into the extra-sites dir** (`CADDY_SITES_DIR`, default `/etc/caddy/sites`, as `migrated-extra-sites.caddy`) so the new Caddyfile's `import` picks them up and upgrades stop clobbering them — no manual merge. It deliberately falls back to reference-file-only (with a loud warning) in two cases: blocks using custom `dns <provider>` plugin directives (the stock apt caddy can't parse them, and installing them would make `caddy validate` fail and thereby block upgrades), or a failed `caddy adapt` pre-check when caddy is present; it also warns about blocks lacking `bind 127.0.0.1`, and backs up an existing target file before overwriting; and only *inspects* nft — a dirty/customized `hysteria_nat` table yields a reference file, never an include insertion. Refuses to run if setup.env already exists. `DRY_RUN=1` reads real configs but writes only to `./dry-run-output/`; config paths overridable via `CADDYFILE_PATH`/`XRAY_CONFIG_PATH`/`HYSTERIA_CONFIG_PATH`/`NFT_RULESET_FILE` (this is how the fixtures tests work).
There is no build system or package manifest; tests are `tests/run_tests.sh` (DRY_RUN + grep/json.tool assertions) covering the caddy/xray/hysteria script and the migration script (fixtures under `tests/fixtures/migrate/`).
## Commands
- Syntax check: `bash -n setup_warp_zerotrust.sh setup_caddy_xray_hysteria.sh setup_cdn_bypass_tproxy.sh migrate_existing_stack.sh`
- Lint: `shellcheck setup_warp_zerotrust.sh setup_caddy_xray_hysteria.sh setup_cdn_bypass_tproxy.sh migrate_existing_stack.sh`
- Test (all scripts, DRY_RUN only): `bash tests/run_tests.sh`
- Run (only on a disposable target VPS, never on a dev machine): `sudo bash setup_warp_zerotrust.sh`
- With no env vars it interactively prompts for team name + service token (reads from `/dev/tty`, so `wget|bash` piping still works); empty team name or incomplete credentials silently fall back to consumer mode. Env vars fully set → no prompts. No controlling tty → prompts skipped, env-only.
- Team mode: `sudo CF_TEAM=<team> CF_ACCESS_CLIENT_ID=<id> CF_ACCESS_CLIENT_SECRET=<secret> bash setup_warp_zerotrust.sh`
- Uninstall + free the device seat: `sudo ACTION=delete bash setup_warp_zerotrust.sh`
- Optional overrides: `ALLOWED_IPS` (default `0.0.0.0/0`), `MTU` (default `1280`)
- It requires root, `apt`-installs packages, overwrites `/etc/resolv.conf`, writes `/etc/wireguard/wg0.conf` + `/etc/wireguard/warp-account.json`, and enables the `wg-quick@wg0` systemd unit.
- Verification is built into the script's final output: IPv4 exit IP, unchanged IPv6 exit, and `warp=` from cloudflare.com/cdn-cgi/trace.
## Architecture and invariants
The script is a one-shot bootstrap sequence; its correctness depends on a few deliberate constraints:
1. **Temporary DNS64 for bootstrap only, with two takeover modes chosen by resolv.conf's actual disposition.** If `/etc/resolv.conf` resolves (via `readlink -f`) into `/run/systemd/resolve/*` and systemd-resolved is active, the script never touches resolv.conf — it injects global DNS64 via an `/etc/systemd/resolved.conf.d/zz-warp-dns64.conf` drop-in (`DNS=` + `Domains=~.`) and restarts resolved; restore just deletes the drop-in (writing through the Ubuntu-default stub symlink would clobber a resolved-managed file in /run that resolved never reads, silently breaking the bootstrap). Otherwise it backs up the file's *content* and overwrites resolv.conf; restore writes the backup back **in place** (`cat > file`, never whole-file `cp`) so the inode and any symlink survive and systemd-resolved's inotify watch keeps working (a `cp`-style inode replacement leaves resolved blind to the file while its contents look correct). A `trap cleanup EXIT INT TERM` guarantees restoration even on failure — any new early-exit path must preserve this guarantee, or the VPS is left with broken/wrong DNS.
2. **IPv6 transport, IPv4 payload.** Registration uses `curl -6` (the API has native AAAA) and the WireGuard endpoint is Cloudflare's IPv6 anycast (`[2606:4700:d0::a29f:c001]:2408`), but `AllowedIPs = 0.0.0.0/0` by default — IPv6 traffic must stay on the native connection. Dashboard split-tunnel settings do NOT apply to a plain WireGuard client; `ALLOWED_IPS` is the only routing control.
3. **The reg API returns a FLAT JSON object** (`.id`, `.token`, `.config.interface.addresses.{v4,v6}`, `.config.peers[0]`) — there is no `.result` wrapper (that's the v4 dashboard API convention, not this one). A `CF-Client-Version` header is mandatory or registration is rejected; known-good pairings (as of 2026-08): `v0a4471`+`a-6.35-4471` (Aether) and `v0a1922`+`a-6.3-1922` (wgcf v2.2.32). The script tries both. Registration curls pin `--tlsv1.2 --tls-max 1.2 --http1.1` because Cloudflare rejects non-app TLS fingerprints with 403 error 1020. HTTP 403 otherwise means the VPS IP is flagged; 429 means rate-limited.
4. **Idempotency via `/etc/wireguard/warp-account.json`.** Re-runs PATCH the existing device's key (`PATCH /reg/<id>` with its Bearer token) instead of registering anew — Zero Trust free plans cap at 50 devices, so repeated fresh registrations would burn seats. The PATCH path is only taken when the saved team matches the current `CF_TEAM` (switching free ↔ team always re-registers). Fall back to fresh registration on 401/404/410.
5. **MTU 1280** survives the double overhead of NAT64/DNS64 plus the WireGuard tunnel; don't raise it without testing on the target network.
6. **Kernel-module probe with wireguard-go fallback.** The script probes `ip link add wgprobe type wireguard` (after `modprobe wireguard`); on failure it installs `wireguard-go` (noble universe, ~1MB) and relies on wg-quick's built-in userspace fallback (`add_if()` falls back to `wireguard-go` when the kernel module is absent — covers LXC containers and custom old kernels, works for boot autostart too). Requires `/dev/net/tun`; if absent (some LXC), the script errors out with a hint.
7. **Supply-chain constraint (user requirement).** Install only via the distro's signed apt repos; never add third-party apt sources, download binaries, or pipe remote code. Outbound calls go to Cloudflare-owned domains only (registration API, team Access endpoint, cdn-cgi/trace for the connectivity check — ip.sb kept only as fallback). The bootstrap DNS64 resolvers are overridable via `DNS64_SERVERS`. Keys/credentials stay local under `/etc/wireguard/` (mode 600); only the WireGuard public key leaves the machine.
## Conventions
- Script comments are written in Chinese; match that when editing.
- `README.md` (Chinese) documents the 2026 dashboard paths for Zero Trust service-token enrollment — keep its UI paths in sync with the script header comment when Cloudflare reorganizes navigation.
- The repo has no CI; commits are small single-file updates. Remote is the private git server `git.henhaoji.site/eli/vpsetup` (credentials live in the local git remote config — never paste the remote URL with its token anywhere).
- 改动脚本行为时,同步更新三处文档:本文件、`README.md`、`docs/HANDOFF.md`(部署事实变了也要更新 HANDOFF)。