它写在 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 里同样的措辞。
19 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/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(用户下达,务必遵守)
- 主机防火墙的归属(2026-11-10 更新):
不要碰—— 这条约定来自 2026-08 的 OCI IPv6 迁移期(当时另一个会话在写 v4/v6 对称规则), 现已失效:本项目只有我们在维护。 当前真实状态:6 台机器(dmit + 5 台 Oracle)上同时跑着两套防火墙——rules.v6/ ip6 filter,IPv6 规则由另一个 agent 负责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。改动前先在单台验证并留存回滚。 - 凭据纪律:绝不把 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 one or more pastedVLESS_LINK/VLESS_LINKSshare 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 inboundscdn-bypass-inon 127.0.0.1 +cdn-bypass-in6on ::1 + one VLESS outbound per upstream tagoracle-N+ acdn-poolbalancer (random,selector=UPSTREAM_TAG_PREFIX, non-emptyfallbackTag— required, Xray only loads theobservatoryhealth probe whenFallbackTagis non-empty) +observatoryblock) and a dedicated unitxray-cdn-bypass.service(BYPASS_SERVICE;xray run -config …, User/Group copied from the main unit,CAP_NET_ADMINfor 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-confdirmerging — xray's confdir merge prepends a fragment'soutbounds(makingcdn-bypass-outthe default outbound for all unmatched client traffic; only atailfilename avoids that) andConfig.Overridereplaces the entire mainrouting(all rules +domainStrategy) with the fragment's; a guard intests/run_tests.shgreps the script forExecStart=…-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 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 bypass xray config 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), 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 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); (e) dual-stack:table ipandtable ip6(v6 tproxy targets[::1]:<port>; the v6 set lives incdn-cidrs-v6.txt, official ranges only, Akamai v6 not yet collected); (f)setup_traffic_guard.shinstallstraffic-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. -
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 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)
- profile:
- 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 (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/)。