Files
eli 4889ac36f2 docs: 删除「rules.v6 由另一个 agent 管」这条过期约束
它写在 2026-08 OCI IPv6 迁移期(当时另一个会话在写 v4/v6 对称规则),
现在已经不成立——本项目只有一个维护者。留着它会把"要不要收敛两套防火墙"
这个正当决策挡死。

替换成当前的真实状态与收敛方向,并顺带记录今天实测到的冲突:
6 台(dmit + 5 台 Oracle)同时跑着 iptables-persistent 的 ip filter / ip6 filter
和本仓库的 inet dsh_firewall,两者都挂 input hook、必须都放行才通,
所以新表只能收紧不能放开(实测:新表放行的 tcp/111 仍被旧表 REJECT)。

同步改掉 init-firewall.sh 头部和 README 里同样的措辞。
2026-11-10 15:46:47 +08:00

92 lines
19 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/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://<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 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=<main>` 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]:<port>`; 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-<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 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=<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 (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/`)。