# 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/cloud/vpsetup`(从「dockerno1 上的 Claude Code」迁入)。 - 旧 Claude 会话原始记录(**含真实凭据,勿入库、勿外传**):`~/.dsh/archive/vpsetup-claude-2026-09-28/`。 - 部署拓扑、各机差异、ECH 联调结论、未决项:`docs/HANDOFF.md`(若未入库或本地维护,参见 `/home/eli/cloud/docs/`)。 ## 机队访问与节点指引(脱敏约定) - **节点真实地址绝不入库**:真实域名、公网 IPv4/IPv6、登录用户、内网映射及端口等详细信息统一在**本地未入库的清单文件**中维护: 👉 `/home/eli/cloud/docs/vps-fleet.md`(本地只读查阅,勿提交到版本库) - **代码与文档内统一使用语义代号**: - `dockerno1` / `home-srv`:家用主控与 Docker 宿主机(非代理节点) - `oracle-jp-1` / `oracle-jp-2`:Oracle 日本节点 1 & 2(脚本管理) - `gcp-oregon`:GCP 俄勒冈节点(cdn-bypass 透明代理) - `aliyun-us`:阿里云美西节点(cdn-bypass 上游) - `git-server`:私有 Git 服务器 - `euserv-de`:EUserv 德国 IPv6-only 节点 - **Agent 操作准则**:当需要 SSH 直连或排查具体节点时,动态读取 `/home/eli/cloud/docs/vps-fleet.md` 获取目标主机的连接凭据(用户、地址、端口),严禁向任何提交跟踪的文件中写入真实域名或公网 IP。 ## Standing constraints(用户下达,务必遵守) 1. **主机防火墙的归属(2026-11-10 更新)**:~~不要碰 `rules.v6` / ip6 filter,IPv6 规则由另一个 agent 负责~~ —— 这条约定来自 2026-08 的 OCI IPv6 迁移期(当时另一个会话在写 v4/v6 对称规则), **现已失效**:本项目只有我们在维护。 当前真实状态:**6 台机器(dmit + 5 台 Oracle)上同时跑着两套防火墙**—— `iptables-persistent` 载入的 `ip filter` / `ip6 filter`,以及本仓库 `init-firewall.sh` 的 `inet dsh_firewall`。两者都挂 input hook,**必须都放行才通**,于是新表只能收紧、 永远无法放开(实测:新表放行的 tcp/111 仍被旧表 REJECT)。 收敛方向:把 `rules.v4`/`rules.v6` 收窄到只保留 **Oracle 强制的 InstanceServices** 与容器转发,INPUT 策略统一交给 `inet dsh_firewall`。改动前先在单台验证并留存回滚。 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 one or more pasted `VLESS_LINK`/`VLESS_LINKS` share links and (a) runs the bypass as its **own standalone xray instance**: renders a complete `/etc/xray-cdn-bypass/cdn-bypass.json` (mode 600, tproxy dokodemo inbounds `cdn-bypass-in` on 127.0.0.1 + `cdn-bypass-in6` on ::1 + one VLESS outbound per upstream tag `oracle-N` + a `cdn-pool` balancer (`random`, `selector` = `UPSTREAM_TAG_PREFIX`, non-empty `fallbackTag` — required, Xray only loads the `observatory` health probe when `FallbackTag` is non-empty) + `observatory` block) and a dedicated unit `xray-cdn-bypass.service` (`BYPASS_SERVICE`; `xray run -config …`, User/Group copied from the main unit, `CAP_NET_ADMIN` for tproxy+SO_MARK, `PartOf=
` so install-release.sh upgrades restart it too — one-way, restarting the bypass never touches the main service); the user's config.json and the main unit are never modified. **Never go back to `-confdir` merging** — xray's confdir merge prepends a fragment's `outbounds` (making `cdn-bypass-out` the default outbound for all unmatched client traffic; only a `tail` filename avoids that) and `Config.Override` replaces the entire main `routing` (all rules + `domainStrategy`) with the fragment's; a guard in `tests/run_tests.sh` greps the script for `ExecStart=…-confdir`. Legacy migration: installs from the old drop-in era (`{10,20}-cdn-bypass-confdir.conf`) are cleaned up first — drop-ins removed, daemon-reload, main service restarted ONCE (regains full routing, frees the TPROXY port) — before the new unit starts; uninstall stops the bypass unit before tearing down nft; (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 bypass xray config 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), plus `/etc/systemd/networkd.conf.d/90-cdn-bypass.conf` (`ManageForeignRoutingPolicyRules=no`) so a networkd restart/reconfigure (e.g. an unattended systemd upgrade) can't purge that "foreign" fwmark rule while the oneshot unit stays active — the 2026-10-16 silent-outage root cause on gcoregon; (d) the bypass 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); (e) dual-stack: `table ip` **and** `table ip6` (v6 tproxy targets `[::1]:`; the v6 set lives in `cdn-cidrs-v6.txt`, official ranges only, Akamai v6 not yet collected); (f) `setup_traffic_guard.sh` installs `traffic-guard.sh` + timer as a monthly egress-budget watchdog (200 GiB Standard Tier free band; default stop at 185 GiB, calendar-month reset in the billing timezone, stops only the proxy services, never the VM). 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 init-firewall.sh` - Lint: `shellcheck setup_warp_zerotrust.sh setup_caddy_xray_hysteria.sh setup_cdn_bypass_tproxy.sh migrate_existing_stack.sh init-firewall.sh` - Test (all scripts, DRY_RUN only): `bash tests/run_tests.sh && bash tests/run_firewall_tests.sh` - 主机防火墙(**全机队共用**,不分 GCP/OCI/阿里云):`sudo bash init-firewall.sh --local --apply --profile proxy` - profile: `proxy`(默认) / `hy2` / `web` / `minimal`;端口开关 `--ssh-port --hy2-port --hy2-hop --no-hy2-hop --no-http3 --extra-tcp --extra-udp --enable-frps --enable-xmpp` - 默认 dry-run;`dsh-firewall.nft` 是默认 profile 的黄金样本,测试断言生成结果与它逐条一致(老机器重跑是 no-op) - 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 (credentials live in the local git remote config — never paste the remote URL with its token anywhere). - 改动脚本行为时,同步更新相关文档(`AGENTS.md`、`README.md`,本地运维手册见 `/home/eli/cloud/docs/`)。