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

17 KiB
Raw Permalink Blame History

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.
  1. 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 -fs 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/.

  2. 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.

  3. 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)。