# 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://.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-/`, 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 ` 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= CF_ACCESS_CLIENT_ID= CF_ACCESS_CLIENT_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/` 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)。