配合 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。
17 KiB
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(用户下达,务必遵守)
- 不要碰任何主机的
rules.v6/ ip6 filter —— IPv6 规则由另一个 agent 负责,改它等于制造冲突。 - 凭据纪律:绝不把 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)。 - 改动闸门:提交前
bash -n+shellcheck+bash tests/run_tests.sh全绿;提交保持小而单一。 - nft 不变式:运行时只
nft -f自己的文件,永不执行整份/etc/nftables.conf(其flush ruleset会清掉并存的 iptables-persistent / Docker 表)。 - CDN 清单只收官方公布的网段,不要自己发明(上游流量有限,凭空加段只增延迟)。
What this repo is
Four self-contained bash scripts:
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, orWARP_JWT): enrolls as a Cloudflare One (Zero Trust) organization device via service-token → JWT exchange athttps://<team>.cloudflareaccess.com/warp, then registers with headerCF-Access-Jwt-Assertion.
-
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 on127.0.0.1:8003(PROXY protocol); Caddy serves the camouflage reverse-proxy and routesWS_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 viaHY2_CERT_MODE(defaulthttp): built-in certmagic ACME HTTP-01 listening only on127.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-wildcardDOMAINcert, auto-renewed and hot-applied (TLS-ALPN is unusable: the CA only connects to TCP 443, which Xray owns). Fallbackdns: Cloudflare DNS-01 for a*.BASE_DOMAINwildcard (needsCF_API_TOKEN). nftables redirects UDP 20000-30000 → 8443 (runtime onlynft -fs its ownhysteria.nft— never executes the full/etc/nftables.conf, whoseflush rulesetwould 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 withimport $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-only00-example.caddy; it never overwrites or deletes user*.caddyfiles (uninstallleaves them alone too). Glob import, so an empty/missing dir is a warning, not an error. Blocks placed there MUST carrybind 127.0.0.1 [::1](otherwise they land on an unwrapped:8003listener 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=1renders everything to./dry-run-output/without system changes — this is howtests/run_tests.shvalidates the script on a dev machine. The apt step is tolerant in exactly one narrow case (apt_update_tolerant()): whenapt-get updatefails only for the Caddy/Cloudsmith repo andcaddyis 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 abortedACTION=upgradeat 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 withoutHY2_CERT_MODEinferdnswhenCF_API_TOKENis 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 indocs/templates-original/. -
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 pastedVLESS_LINKshare 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 named20-cdn-bypass-confdir.conf: the official xray install script ships10-donot_touch_single_conf.confwhich re-sets ExecStart to the plain single-config form and sorts after any10-*name, silently dropping the confdir); (b) writes/etc/nftables/cdn-bypass.nft(mark tablecdn_bypass_mark+ tproxy tablecdn_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 viath dport), the xray fragment is a catch-all dokodemo→proxy with zero filtering; (c) installs oneshotcdn-bypass-route.servicepersistingip 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 carriessockopt.mark=255and 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. -
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: writessetup.env(same%qformat 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, asmigrated-extra-sites.caddy) so the new Caddyfile'simportpicks 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 customdns <provider>plugin directives (the stock apt caddy can't parse them, and installing them would makecaddy validatefail and thereby block upgrades), or a failedcaddy adaptpre-check when caddy is present; it also warns about blocks lackingbind 127.0.0.1, and backs up an existing target file before overwriting; and only inspects nft — a dirty/customizedhysteria_nattable yields a reference file, never an include insertion. Refuses to run if setup.env already exists.DRY_RUN=1reads real configs but writes only to./dry-run-output/; config paths overridable viaCADDYFILE_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, sowget|bashpiping 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(default0.0.0.0/0),MTU(default1280)
- With no env vars it interactively prompts for team name + service token (reads from
- It requires root,
apt-installs packages, overwrites/etc/resolv.conf, writes/etc/wireguard/wg0.conf+/etc/wireguard/warp-account.json, and enables thewg-quick@wg0systemd 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:
- Temporary DNS64 for bootstrap only, with two takeover modes chosen by resolv.conf's actual disposition. If
/etc/resolv.confresolves (viareadlink -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.confdrop-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-filecp) so the inode and any symlink survive and systemd-resolved's inotify watch keeps working (acp-style inode replacement leaves resolved blind to the file while its contents look correct). Atrap cleanup EXIT INT TERMguarantees restoration even on failure — any new early-exit path must preserve this guarantee, or the VPS is left with broken/wrong DNS. - 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), butAllowedIPs = 0.0.0.0/0by default — IPv6 traffic must stay on the native connection. Dashboard split-tunnel settings do NOT apply to a plain WireGuard client;ALLOWED_IPSis the only routing control. - The reg API returns a FLAT JSON object (
.id,.token,.config.interface.addresses.{v4,v6},.config.peers[0]) — there is no.resultwrapper (that's the v4 dashboard API convention, not this one). ACF-Client-Versionheader is mandatory or registration is rejected; known-good pairings (as of 2026-08):v0a4471+a-6.35-4471(Aether) andv0a1922+a-6.3-1922(wgcf v2.2.32). The script tries both. Registration curls pin--tlsv1.2 --tls-max 1.2 --http1.1because Cloudflare rejects non-app TLS fingerprints with 403 error 1020. HTTP 403 otherwise means the VPS IP is flagged; 429 means rate-limited. - 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 currentCF_TEAM(switching free ↔ team always re-registers). Fall back to fresh registration on 401/404/410. - MTU 1280 survives the double overhead of NAT64/DNS64 plus the WireGuard tunnel; don't raise it without testing on the target network.
- Kernel-module probe with wireguard-go fallback. The script probes
ip link add wgprobe type wireguard(aftermodprobe wireguard); on failure it installswireguard-go(noble universe, ~1MB) and relies on wg-quick's built-in userspace fallback (add_if()falls back towireguard-gowhen 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. - 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)。