Files

31 KiB
Raw Permalink Blame History

HANDOFF — 部署形态与运维事实

本文承接仓库外的历史上下文,供 DSH 会话直接使用。

  • 来源:2026-08-29 → 09-28 的旧 Claude Code 会话(原件归档在 ~/.dsh/archive/vpsetup-claude-2026-09-28/,含明文凭据,勿外传)。
  • 主机状态:以下带 ✅/⚠️ 的状态为 2026-10-09 实测,不是历史记录。
  • 原则:本文只写事实与结论,不写任何凭据(uuid / 私钥 / token / 分享链接一律不出现在仓库里)。

1. 机队清单(2026-10-09 实测)

主机 登录 角色 实测状态
dockerno1 = 192.168.254.50 eli@ 家用服务器(国内),非代理节点。仓库 + 旧 Claude 工作台在这里;另跑大量 docker 服务(nextcloud / collabora / firecrawl / open-webui / portainer / uptime-kuma …)。 ✅ 正常。没有 caddy/xray/hysteria systemd 栈;443/8080/8443 的监听来自 docker 容器,排查端口占用时别搞混。
oraclejp.henhaoji.site ubuntu@ Oracle 日本 1 号节点 ✅ caddy/xray/hysteria-server 全 active,Xray 26.3.27,/root/setup_caddy_xray_hysteria.sh 常驻
oraclejp-2.henhaoji.site ubuntu@ Oracle 日本 2 号节点 ✅ 同上
gcoregon.henhaoji.site xu_co@ GCP Oregon:cdn-bypass 透明代理节点(GCP 对 CDN 流量收费/受限) ✅ 三件套 active + cdn-bypass-route active,dokodemo 54443 在听,nft 里有 cdn_bypass_mark + cdn_bypass_tproxy
alius.henhaoji.site root@ 阿里云 US:cdn-bypass 的上游节点 ✅ 三件套 active;无 cdn-bypass,无 XHTTP(8080)
git.henhaoji.site ubuntu@ 私有 git 服务器(本仓库 origin) ✅ 正常
euserv.henhaoji.site = 2a02:180:6:1::7ca — EUserv 德国 VPS,IPv6-only,IPv4 出口靠 setup_warp_zerotrust.sh 走 WARP ⚠️ 不可达(详见 §7)
Northflank 容器 无 SSH,纯 API 兜底节点(template 驱动) 见 §5

从本机(WSL 笔记本)的连通性:私钥已在位,上表有登录用户的主机全部可直连,不需要拷贝私钥、不需要跳板。 本机经 WSL NAT(172.25.2.12 → 网关 172.25.0.1)路由进用户内网,因此 192.168.254.50 可达。 bwg / UK / gpu 等未列入的主机:gpu 本机直连(eli@gpu),其余可经 ssh eli@dockerno1 跳板。


2. 各机差异与注意点

  • Oracle 双机:两机凭据完全相同(同一套 REALITY / WS / hy2 / CF token),WS_PATH=/vlessws,HY2_CERT_MODE=dns(*.henhaoji.site 通配证书)。橙云域名 oraclejp-1-cf / oraclejp-2-cf.henhaoji.site 已配置并端到端验证。Oracle1 主 Caddyfile 里原有一批死配置(php_fastcgi / portainer 反代,服务早已 inactive),迁移时已随旧块去除;oraclejp.duckdns.org 别名两机均已不再服务。
  • gcoregon:xu_co@ + 免密 sudo,docker inactive。部署脚本不在机器上常驻(部署时 scp 到 /tmp/cdn.sh 跑)。2026-09-28 已从手工栈迁入脚本管理(migrate_existing_stack.sh → ACTION=upgrade),随后装上 cdn-bypass。级联验证闭环:脚本 DRY_RUN 文本断言(tests/run_tests.sh)→ gcoregon 上跑的就是这份生成配置 → 线上实测出口正确。
  • dockerno1:用户的开发/控制台机器,不是节点。旧 Claude Code 会话在这里运行,Session 历史与归档也在这里。
  • oraclejp:2026-10-10 起主 Caddyfile 末尾带 import /etc/caddy/sites/*.caddy(额外站点扩展点,见 README 同名章节),Caddy 仍停在 2.11.4(cloudsmith 402 挡住了二进制升级,见 §7.6)。OPNsense 的 ACME 自动化里新增两条指向本机的(2026-10-11 定型):send to oraclejp-caddy(SFTP,user ubuntu,直传 /etc/caddy/certs/netbird)+ reload caddy oraclejp(SSH,命令只有 sudo systemctl reload caddy),host 写 IPv4 140.83.87.53;交付契约与三个坑见 §7.5。
  • euserv:除了是本仓库 WARP 脚本的典型目标(IPv6-only 需要 v4 出口),也是用户「真实部署」的节点之一,客户端 passwall 曾用 euserv-cf.henhaoji.site。
  • 额外站点(所有脚本管理的 VPS,2026-10-10 起):主 Caddyfile 由脚本整体重写,因此手工加的站点块必丢。现在统一改为放进 CADDY_SITES_DIR(默认 /etc/caddy/sites):脚本只在 Caddyfile 末尾渲染一行 import /etc/caddy/sites/*.caddy,目录内容归用户所有(只建目录;目录全空时补纯注释的 00-example.caddy),install/upgrade/uninstall 都不动用户文件。硬性要求两条:站点块必须 bind 127.0.0.1 [::1](否则落到未包装的 :8003 监听器,443 回落流量到不了它)、通配符必须自己喂证书(apt 版 caddy 无 DNS 插件,签不了通配符;换证书后要 systemctl reload caddy,因为 caddy 不 watch 证书文件)。细节见 README.md「额外站点」一节。

3. cdn-bypass 架构(两层,gcoregon 已上线)

数据流(本机原生出站):

本机出站包
  → nft mangle OUTPUT:目的地址 ∈ 188 官方 CDN 段 且 th dport ∈ {53,80,443} → meta mark set 1
  → ip rule fwmark 1 lookup 100  →  table 100: local default dev lo   (纯内核运行时状态)
  → nft tproxy prerouting:mark 1 → TPROXY 到 127.0.0.1:54443
  → xray dokodemo-door(54443) ──routing inboundTag=cdn-bypass-in──→ cdn-bypass-out(VLESS 上游, sockopt.mark=255)
  → nft 首条规则豁免 mark 255(防环:上游 IP 自己也落在 CDN 段内时)

关键设计(用户拍板):

  1. 所有筛选都在 nft 层(CIDR + 端口 th dport),xray 片段是零筛选的 catch-all dokodemo→proxy。
  2. 只有 proxy 出站带 mark 255;主配置的 direct 出站绝不能带 mark 255 —— 否则「客户端经本机访问 CF」的流量会被豁免、直连出去,在 GCP 上表现为 「本机 curl 正常、客户端不通」。这是 2026-09-27 那个「客户端连不上 cloudflare.com」的根因。
  3. ip rule / ip route 不会被任何东西持久化,故由 oneshot cdn-bypass-route.service 负责恢复。
  4. drop-in 必须叫 20-cdn-bypass-confdir.conf:官方 xray 安装脚本自带 10-donot_touch_single_conf.conf(把 ExecStart 重置回单 -config 形式),同前缀下 c < d 会后加载并静默盖掉 -confdir。
  5. CDN 段只收官方来源:CF ips-v4(15) + Fastly public-ip-list(19) + Akamai/Linode WHOIS(154) = 188 条(2026-09-28 审计)。早期 401 条里有 213 条其实是 Let's Encrypt 验证源 IP 被误收(NTT/Level3/Verizon/GTT/IBM Cloud… ),已剔除;测试断言锁在 [180,200] 防止再膨胀。用户明确要求:只按官方公布的段转发,不要自己发明。

4. ECH 联调结论(客户端 passwall / Xray)

  • Xray ECH 动态串里的 DoH 必须是 /dns-query(RFC 8484 wireformat,POST + h2)。用户最初填 /resolve(那是 JSON 调试接口)→ 查询超时。
  • Xray 的 ECH 查询只支持 udp:// / https:// / h2c:// 三个分支,没有 tcp://。
  • 用户实测采用:域名+udp://9.9.9.9(明文 UDP53)。理由:8.8.8.8 UDP 偶发被断、其 443 DoH 不稳(疑似被 passwall 本机代理劫入代理通道);Quad9 延迟略高但不被屏蔽。脚本默认值即此(commit 9ca581c)。
  • 客户端所在墙内网络:type65/HTTPS 普通 DNS 查询被过滤(dig 走 127.0.0.53 和 @1.1.1.1 均失败);cloudflare-dns.com 与 1.1.1.1:443 被封;dns.google、dns.alidns.com 可达。
  • CF 轮换 ECH 密钥很勤,静态 base64 快照很快失效 → 脚本默认用动态 ech=(三态:默认动态 / off / 原样自定义)。

5. Northflank(兜底节点)

  • team stayawakes-team(不是 stayawakes,后者 403)、project scilad、service erepr-docker-for-ladder、template scilad。
  • autorun: false:改 template 不会影响运行中的服务,需要手动 run/sync。
  • 配置以 template 的 runtimeFiles base64 内嵌(/usr/local/etc/xray/config.json + /etc/hysteria.yaml),不是 secret group。
  • 已完成的优化:删掉 private 443 上的死 REALITY inbound 与 p01 端口;XHTTP :8443 加 xPadding extra(与分享链接的 extra= 逐字节一致);修 routing 里指向不存在出站的 outboundTag: "block" → "blocked";healthChecks 增加 TCP livenessProbe :8443(template 与 live service 都写了)。
  • hy2 配置原样保留,但UDP 公网不可达(需要 UDP L4 LB 才能用)。
  • 教训:Northflank token 是 JWT,绝不能 source .env 加载(含 shell 特殊字符会被吃空 → 401),必须 grep + cut 原样取;Token ID(dockerno1-vpsetup)只是轮换管理句柄,认证只用整串 JWT 作 Bearer。egress 计费 $0.06/GB。

6. 凭据在哪(都不在仓库里)

位置 内容
各 VPS /etc/caddy-xray-hy2/setup.env(mode 600) DOMAIN / EMAIL / WS_PATH / XRAY_UUID / 私钥 / shortId / CF_API_TOKEN / hy2 密码
本仓库 .env(gitignored,永不提交) northflank_api_token、northflank_xhttp_link(含真实 uuid 与 xPadding)
~/.dsh/archive/vpsetup-claude-2026-09-28/ 旧会话原始记录(含明文凭据,仅本机留档)
各 VPS /etc/caddy-xray-hy2/migration-backup-<ts>/ 迁移前的旧 Caddyfile / xray config / hysteria config

7. 未决项 / 待办

  1. ⚠️ EUserv VPS 当前不可达(2026-10-09 实测)。从本机(无 IPv6 路由)与从 dockerno1(IPv6 出网正常,ping6 2606:4700:4700::1111 通)双双 100% 丢包 + SSH 超时;橙云域名 euserv-cf.henhaoji.site 也超时。
    • 注意与 2026-09-26 那次整机宕机区分:那次是 EUserv 侧排队执行电源操作,用户等到恢复;本次现象类似但尚未确认。
    • 排查方向:① EUserv 面板看机器状态;② 2a02:180:6:1::7ca 这条 AAAA 是否因重装/换网卡而失效(地址后缀像 MAC 派生,重装可能变);③ 可用任一节点 setup.env 里的 CF_API_TOKEN 只读查 Cloudflare DNS 记录核对当前地址。
  2. gcorhy.henhaoji.site —— 已了结(用户 2026-10-10:该域名本身就废弃了,不用管)。它原是旧 hysteria 测试残留的 Caddy 站点块(reverse_proxy / → 127.0.0.1:8998,后端早已不存在),迁移时原样抽到 /etc/caddy-xray-hy2/migration/caddy-extra-sites.conf;不要并进新 Caddyfile,CF 上那条 DNS 记录留着也无所谓。

    ℹ️ migrate_existing_stack.sh 里「额外站点块如何抽取/合并」的逻辑已改造完成(2026-10-10):现在直接安装到额外站点目录,见第 4 条。

  3. 后续新增主机时,记得同步更新本文件与 AGENTS.md 的访问地图。
  4. 迁移脚本接入站点扩展点 —— 已完成(2026-10-10):migrate_existing_stack.sh 抽出的非主域站点块现在直接安装到 CADDY_SITES_DIR(默认 /etc/caddy/sites;干跑落 DRY_RUN_DIR/sites/)的 migrated-extra-sites.caddy,升级后由 import 自动加载,不再提示人工合并;参考件仍留一份在迁移输出目录。两道保护刻意保留:含 dns <provider> 定制插件指令、或(装了 caddy 时)caddy adapt 预检不通过 → 只留参考文件,因为装进去会让 caddy validate 失败、反而挡住升级。另外缺 bind 127.0.0.1 的块会告警,目标文件已存在时先备份 .bak-<时间戳>。
  5. netbird 公网入口(oraclejp,2026-10-11 定型):VPS 侧承担两块能力——① 管理面:netbird.savorcare.com → Caddy 站点块 → 公司 qhxxg.savorcare.com:10443(客户端不必再记端口);② 访客面:*.nb-pub.savorcare.com → Caddy → 本机 netbird proxy https://127.0.0.1:8444(tls_insecure_skip_verify)。
    • ✅ VPS 侧扩展点已上线并实测:/etc/caddy/Caddyfile 末尾有 import /etc/caddy/sites/*.caddy(第 101 行)、/etc/caddy/sites/00-example.caddy 就位。跑过一次完整 ACTION=upgrade 验证:升级前放进去的用户站点块在重渲染后原样保留且继续被服务(SNI 与 HTTP 均通),xray/hysteria 配置与凭据指纹不变(客户端链接不受影响)。该次升级同时验证了新的 apt 容错(见第 6 条)。
    • ✅ DNS:用户已把 *.netbird.savorcare.com 改为 CNAME → oraclejp.henhaoji.site(=140.83.87.53)。注意 savorcare.com 的 NS 在 Namecheap(不是 Cloudflare),且 apex 通配 * A 记录仍指公司固定 IP。
    • ✅ OPNsense 自动化(最终形态,2026-10-11 定型):
      • send to oraclejp-caddy(SFTP / configd_upload_sftp):user ubuntu,直传最终目录 /etc/caddy/certs/netbird,文件名 fullchain.pem/key.pem/cert.pem/ca.pem,sftp_chmod=0644、sftp_chmod_key=0640、sftp_chgrp 留空。
      • reload caddy oraclejp(configd_remote_ssh):user ubuntu,命令只有 sudo systemctl reload caddy(不再有 install/chown/chmod 步骤)。
      • 两条 host 都写 IPv4 字面量 140.83.87.53(原因见下面的坑 3)。
    • ✅ 交付契约(最终形态):目录 /etc/caddy/certs/netbird = ubuntu:caddy 2770(setgid ⇒ SFTP 新传的文件自动继承 caddy 组,所以自动化里不需要任何 chown/chgrp/install);文件 fullchain.pem/cert.pem/ca.pem = 644、key.pem = 640,owner = SFTP 用户 ubuntu。
      • Caddy(uid 996 / group 996)走 group caddy 读 ✓
      • netbird proxy 容器(镜像固定 netbird:netbird = uid/gid 1000)走 compose 里的 group_add: ["996"] 读同一个组 ✓
      • 其他账号读不到私钥 ✓(opc 1000、ubuntu 以外) 实跑验证:重跑自动化后 4 个文件直落该目录、组=caddy;用 setpriv --reuid=1000 --regid=1000 --groups=996 -- test -r(正确模拟容器身份)确认可读;Caddy reload 正常。 ℹ️ 中转目录 /home/ubuntu/caddy-certs-incoming 已删除(不再需要)。
    • ⚠️ 坑 1:sftp_filename_cert/sftp_filename_ca 留空 ≠ 不上传,而是回落到插件默认模板 {{name}}/cert.pem、{{name}}/ca.pem({{name}} = 证书名)→ 目标目录里会多出一个以证书名命名的子目录。要么显式写文件名(现在这样),要么接受子目录。
    • ⚠️ 坑 2:站点块域名必须被所配证书覆盖。证书重签后 SAN 从 *.netbird.savorcare.com 变为 *.nb-pub.savorcare.com,旧站点块仍在服务旧名字 → Caddy 判定证书不匹配,自行发起 ACME 申请(泛域名只能 DNS-01,官方 apt caddy 无插件 → 必然失败),3 分钟重试 23 次、并会撞 CA 速率限制、把负载拉到 4+。处置:旧块改名 netbird.caddy.disabled、新增 nb-pub.caddy 绑定 *.nb-pub.savorcare.com,reload 后重试降为 0(实测)。
    • ⚠️ 坑 3(可复用到别的主机):ACME 插件生成的 SSH 身份限制 from="<它按 IPv4 算出的源地址>",目标若是双栈域名,sshd 可能经 IPv6 连入 → 日志报 Authentication tried … with correct key but not from a permitted host。解法:automation 的 host 写目标 IPv4 字面量。
    • ✅ 站点块现状:
      • netbird-mgmt.caddy:netbird.savorcare.com → https://qhxxg.savorcare.com:10443(header_up Host + tls_server_name + versions 2,实测 HTTP/2 200,管理面走这里)。
      • nb-pub.caddy:*.nb-pub.savorcare.com → https://127.0.0.1:8444(tls_insecure_skip_verify),SNI 实测出示 CN=netbird.savorcare.com ✓。
      • netbird.caddy.disabled:旧访客块,停用保留以便回滚(要用回 *.netbird.savorcare.com 必须先把该名字重新加进证书 SAN,否则又会触发坑 2)。
      • ⏳ 待办:*.nb-pub.savorcare.com 的 DNS 目前仍指向公司 IP(183.238.250.166),需要指到 oraclejp(140.83.87.53)访客入口才真正可用。
    • ✅ 证书现状:netbird 证书 statusCode=200,主体 CN=netbird.savorcare.com,SAN = *.nb-pub.savorcare.com + netbird.savorcare.com,到期 2027-01-09(2026-10-11 重签)。 ⚠️ 那批"失败"提示多来自插件已知 bug:GET /certificates/automation/<uuid> 即使成功也返回 {"result":"failed"},别拿返回体判断成败;判断依据看目标机文件与 Caddy 日志。
    • 依赖:netbird 公网 proxy 容器已部署(netbird-public-proxy,127.0.0.1:8444);访客路径当前返回 404 是因为管理面映射尚未完全就绪 / 子域 DNS 未切换。
  6. ⚠️ Caddy 官方 apt 源 402(2026-10-09 起,影响全机队):cloudsmith 给 caddy 项目的免费包投递配额被 CI 滥用打满,/public/caddy/stable/deb/debian 下所有下载一律 402 Payment Required(上游 caddyserver/dist#142,维护者称在等 Cloudsmith 提额;2023、2024 各发生过一次,2024 那次约 8 天)。影响:apt-get update 必失败 → 在此之前 ACTION=upgrade 在第一步就中止(与本次站点扩展点改动无关)。
    • 已按用户决定实现窄容错 apt_update_tolerant():仅当报错全部来自 caddy/cloudsmith 源且本机已装 caddy → WARN 后继续(配置照常渲染 / validate / reload);报错牵涉其他仓库、或本机没有 caddy → 依旧硬失败。oraclejp 实测:ACTION=upgrade 现在能跑完,caddy 停 2.11.4、Xray 停 26.3.27。
    • 若长期不恢复,可改用 Caddy 官方 GitHub Release 的 .deb(同一份产物,附带 Sigstore 签名与校验和);但那是供应链口径变更,需用户拍板,脚本默认不做。

8. 常用运维命令

# 巡检一台(把 <user>@<host> 换掉即可)
ssh <user>@<host> 'systemctl is-active caddy xray hysteria-server; \
  sudo ss -lntup | grep -E ":(443|8443|54442|8080|54443)"; \
  sudo nft list tables; \
  curl -s --max-time 8 https://www.cloudflare.com/cdn-cgi/trace | grep -E "^(ip|loc|warp)="'

# 打客户端分享链接(Oracle 两机脚本常驻)
ssh ubuntu@oraclejp.henhaoji.site 'sudo ACTION=links bash /root/setup_caddy_xray_hysteria.sh'

# 升级到最新脚本(幂等,不提示)
ssh <user>@<host> 'sudo ACTION=upgrade bash /root/setup_caddy_xray_hysteria.sh'

# 迁移手工栈 → 脚本管理(先 DRY_RUN 看生成物)
DRY_RUN=1 bash migrate_existing_stack.sh     # 本地只写 ./dry-run-output/
sudo bash migrate_existing_stack.sh          # 目标机上实跑

# 本地验证(DRY_RUN 断言,不碰系统)
bash -n setup_*.sh migrate_existing_stack.sh && shellcheck setup_*.sh migrate_existing_stack.sh && bash tests/run_tests.sh

判断 gcoregon 的 cdn-bypass 是否在正常工作:curl https://www.cloudflare.com/cdn-cgi/trace 的 ip= 应为 alius 的 43.110.45.237(而不是 GCP 自己的出口);非 CDN 目标(如 AWS checkip)应仍是 GCP 出口。


9. OPNsense ACME 自动化:可复用配方(2026-10-10 实操记录)

API 基址 https://192.168.254.1/api/acmeclient(只能从内网调,例如 dockerno1;基本认证,凭据在 ~/cc-499/.env 的 opn_key/opn_secret)。

目的 调用
读全部配置 GET /settings/get(嵌套结构:certificates.certificate.<uuid>、actions.action.<uuid>、validations.validation.<uuid>、accounts.account.<uuid>)
新建自动化 POST /actions/add,体 {"action":{…}} → {"result":"saved","uuid":"…"}
改自动化 POST /actions/update/<uuid>(体同上,字段级合并)
新建证书 POST /certificates/add,体 {"certificate":{…}}
改证书 / 挂自动化 POST /certificates/update/<uuid>;restartActions 是逗号分隔的 uuid 串(不是数组)
立刻签发某证书 POST /certificates/sign/<uuid> —— 异步(daemon -f),接口立即返回 {"response":"OK"},结果要看证书的 statusCode/lastUpdate
重跑某证书的自动化 GET /certificates/automation/<uuid> —— 成功也返回 {"result":"failed"}(插件已知 bug,别看返回体,看目标机文件)
取身份公钥 POST /actions/sshGetIdentity(remote_ssh_identity_type=ecdsa&remote_ssh_host=…)或 POST /actions/sftpGetIdentity(sftp_identity_type=rsa&sftp_host=…)→ {"status":"ok","identity":"…"};必须 POST,GET 只会得到 {"status":"unavailable"}
连接测试 POST /actions/sshTestConnection / POST /actions/sftpTestConnection(字段同对应 automation)→ {"actions":[…],"success":true}

要点与坑:

  • 身份按类型共享(rsa 给 SFTP、ecdsa 给 remote SSH,实际存 /var/etc/acme-client/sftp-config/id.<type>),公钥行自带 restrict(SFTP 另加 command="internal-sftp")与 from= 限制,整行贴进目标机 authorized_keys 即可。
  • 证书 name = CN / 主标识符,必须是域名(填标签会 400,见 §7.5 坑 1);altNames 才是附加 SAN,逗号分隔。
  • 自动化与证书记录直接写 config.xml,不需要 service/reconfigure;只有改 settings(启用、challenge 端口等)才需要。
  • 插件 cron 每天 00:00 跑 acmeclient cron-auto-renew;restartActions 在签发/续期成功后按顺序执行。
  • ⚠️ 凭据纪律(本会话踩过):settings/get 的返回里明文含 Namecheap API key、ACME 账户私钥等;解析打印时必须按字段名脱敏,但只匹配 key/user/pass/token 是不够的——dns_namecheap_api 就是这样漏出去的。宁可白名单(只打印已知非敏感字段)。

10. REALITY 回落 / 按域名分流:源码级结论(2026-10-10)

排查"能不能让特定域名先转发到别处、其余兜底 Caddy"时做的核对(Xray-core v26.10.10 源码 + xtls.github.io 文档 + oraclejp 实机验证)。三层不要混:

层 字段 何时触发 能否按域名
REALITY realitySettings.dest / target(+xver) 外层握手不是合法 REALITY 请求时(浏览器/扫描器/任意 SNI) 不能:dest 在读到 ClientHello 之前就被 dial,单一无条件目标
VLESS settings.fallbacks[].dest(+name/alpn/path/xver) 外层握手成功且 VLESS 头解析失败(UUID 不对、非 VLESS、明文 HTTP) 能,见下
自建 dest → 本机 SNI 预读路由器(HAProxy req_ssl_sni / nginx ssl_preread) 上述"非 REALITY"流量先过路由器 能,且是官方文档推荐的思路
  • serverNames 不是路由表,只是 REALITY 认证白名单:SNI 不在名单里 → 判为非 REALITY → 同样进 dest。实机验证:SNI=netbird.savorcare.com(不在名单)照样到 Caddy 并出示 netbird 证书;未知/无 SNI 由 Caddy 回 TLS alert 80。
  • 线上 oraclejp 的 settings.fallbacks 是空的,所以现在所有回落都由 Caddy 按 SNI 选站点 —— 这就是"按域名分流"在跑的形态(官方 reality.html 也建议"在前面放 Nginx 之类过滤 SNI")。
  • fallbacks 匹配细则(proxy/vless/inbound/inbound.go):先 name(精确,未命中则最长子串匹配——所以写 netbird.savorcare.com 能覆盖 foo.netbird.savorcare.com,但不支持通配符)→ 再 alpn(精确→默认项)→ 最后 path(只对 HTTP/1.1 首包有意义,≤64 字节探测,看不到 Host,域名只能靠 name)。相同 alpn+path 的多个子项以最后一个为准。
  • 版本坑(都会踩):
    • v25.7.26 起:只写端口的 dest(如 "8003")被补成 localhost,可能先解析到 ::1。本仓库 2026-10-10 已改成显式 "127.0.0.1:8003"(此前能用只因 Caddy 同时听 127.0.0.1:8003 和 [::1]:8003;换 IPv4-only 后端就静默失联)。
    • 2026-09-08 起(~v26.9.9):REALITY 拒绝不带 X25519MLKEM768 的 TLS1.3 ClientHello → 老客户端静默降级为"伪装流量"(表现为连不上但不报错,流量被转发到 dest)。
    • fallbacks 与 VLESS Encryption(decryption ≠ none)是硬配置错误。
    • REALITY 下 NextProtos 为 nil → fallbacks.alpn 匹配的是目标站协商出的 ALPN,不是我们 tlsSettings.alpn 的。
    • 一个 443 只能"伪装"一个目标站;要多前台域名就得多监听,或用 SNI 路由器。
    • PROXY protocol 在同一端口上全有或全无;用 SNI 路由器时把 Xray 侧 xver 设为 0、由路由器 emit PROXY,避免两个头。

11. Caddy 会不会掐断 gRPC?——实测结论(2026-10-11,oraclejp)

起因:观察到"经 Caddy 的 gRPC 连接约 0.2 秒就断,直连公司则 300 秒才被 traefik 断",怀疑 Caddy→traefik 参数有问题,并考虑把 realitySettings.dest 改成 SNI 路由器做裸 TLS 透传。结论:两个现象都不是 Caddy 造成的,透传方案不必要(已记录、未实施)。

方法:给 netbird-mgmt.caddy 加了一行 log(默认写 journal,含 duration/status/error/host),再把 Caddy 访问日志、proxy 容器日志、容器网络命名空间连接表按 UTC 时间对齐。诊断完成后该 log 行可留作审计(删掉即恢复无访问日志)。

证据 1:长流被 Caddy 完整转发,超时来自上游

01:47:01  dur=131.263s  status=0    /management.ManagementService/Job   host=netbird.savorcare.com
01:47:03  dur=300.074s  status=504  /management.ManagementService/Job   error=None
          resp_headers: Content-Length: 15, Date: …, Via: 2.0 Caddy

Job 流经 Caddy 存活 300.074 秒;504 带 error: None + 上游响应头 ⇒ 是上游 traefik 自己返回的 504,Caddy 原样透传(Caddy 出超时会记 error 字段)。这与"直连公司 300 秒被 traefik 断"是同一个 300 秒 ⇒ Caddy 没有缩短任何东西。

证据 2:0.2 秒那类条目是 netbird 自己的短流,例如 SignalExchange/Send 的 0.075–2.6 s、ConnectStream 0.000 s,全部 status=200;signal 客户端本就频繁重建(proxy 日志写明 "will retry silently")。

证据 3:当时的 1 秒重试风暴也不经过 Caddy:proxy 以约 1 次/秒报 mapping stream: … code = Unimplemented desc = unexpected HTTP status code received(grpc-go 把 HTTP 404 映射成 Unimplemented)。同期 Caddy 访问日志里该站点一条 404 都没有(5 分钟 18 条,全是 200/0/504),而容器连接表采样显示全部指向 183.238.250.166:**10443**、到本机 443 的连接数为 0。

证据 4:404 的机制(同一方法 /management.ProxyService/SyncMappings 打公司 10443)

请求写法 结果
域名(SNI + Host = netbird.savorcare.com) 200
直接用 IP(无 SNI,Host=IP:10443) 404 404 page not found(traefik 未匹配路由 → Go 默认 404)
用 IP 但补 Host: netbird.savorcare.com 200

⇒ proxy 用 IP:10443 直连公司,缺 SNI/Host,traefik 无法路由 → 404 → gRPC Unimplemented → 1 秒重试。这是 netbird 侧端点配置问题,与 VPS/Caddy 无关(proxy 的 env 是 NB_PROXY_MANAGEMENT_ADDRESS=https://netbird.savorcare.com,所以那个 IP 端点来自服务端下发/配置档,不是我们机上的东西)。

待办(用户侧):① 让 netbird 管理面/配置档里的端点用域名 https://netbird.savorcare.com(443,经 VPS→Caddy→公司,实测 200),别用 IP:10443;② proxy 仍报 static certificate (SANs [*.nb-pub.savorcare.com netbird.savorcare.com]) does not cover domain "te…"——有 netbird 服务仍在旧 *.netbird.savorcare.com 域下,需与新证书对齐(或把该名字加回 SAN)。

已记录、未实施的备选(仅当将来证明 Caddy 这一跳确实有害时再做):把 realitySettings.dest 指向本机 SNI 路由器(HAProxy req_ssl_sni / nginx ssl_preread),netbird.savorcare.com 裸 TLS 透传公司 10443、默认回落 Caddy 8003;注意 REALITY xver:1 已插 PROXY 头故路由器需 accept-proxy,且需先把 dest 参数化(脚本渲染,手改会被 ACTION=upgrade 覆盖)。

11.1 源码级补充(Caddy v2.11.4 逐行核对,2026-10-11)

  • Caddy 的 reverse_proxy 没有任何 gRPC 专用代码(git grep grpc 只命中 protocol grpc 匹配器);flush_interval -1 对服务端流式 gRPC 是 no-op(ContentLength == -1 时本就每次写都 flush)。⇒ 我块里没有需要"为 gRPC 补参数"的地方。
  • ⚠️ keepalive_interval 是 TCP keepalive,不是 HTTP/2 PING:Caddy 从不设置 Go http2 的 ReadIdleTimeout/PingTimeout(git grep 零命中)。所以现在块里的 keepalive_interval 15s 并不能让上游 h2 连接保活、也探测不到半开连接;traefik 若先 GOAWAY 掉池中空闲连接,Caddy 侧只会在下一个请求上撞上"陈旧池连接"。keepalive <dur> 才是池内空闲超时(默认 2m)。
  • 陈旧池连接会怎样(Go 1.25.1 h2_bundle.go):普通已关闭/空闲连接会被透明重试;但 GOAWAY / RST(REFUSED_STREAM) 打在进行中的 gRPC POST 上会立即失败(Caddy 从不设置 GetBody,无法重放)→ 表现为极快的 502;若失败发生在响应头之后则 panic(http.ErrAbortHandler) → 客户端看到 RST_STREAM。这解释了"502 与 RST_STREAM 交替"=同一个上游竞争,而非两个 bug。本次实测没有出现这类 502(该站点日志里只有 200/0/504),所以它不是当前症状的原因。
  • ⛔ 不要为了这个去升级 Caddy:v2.11.6 起默认给上游加了 1 分钟 idle 读/写超时,反而打破 h2/流式(#8103 / #8118 / #8101);而 v2.11.4 自身有一个未修复的挂起 bug #8185(PR #7719 引入):上游在请求体读取途中 RST_STREAM 时,被阻塞的 Read 永不返回 → 请求挂住(复现条件正是"上游等约 300ms 后 RST",v2.11.3 不复现)。⇒ 若将来看到挂住而不是断开,先怀疑这条。
  • versions 2 不是"只用 h2":Go 的 http2.ConfigureTransport 会把 h2 与 http/1.1 都写进 NextProtos,Caddy 的 versions 只影响非 TLS(h2c)。⇒ 想确认 Caddy→traefik 这一跳真的是 h2,要看 traefik 访问日志的 proto 字段;若被降级成 HTTP/1.1,gRPC 会以诡异方式失败而普通 HTTP/2 请求看不出来。
  • traefik 默认值(供对照):readTimeout 60s、idleTimeout 180s、http2.maxConcurrentStreams 250;ServersTransport idleConnTimeout 90s、readIdleTimeout 0s、pingTimeout 15s。实测那个 300s 不等于 180s 默认值 ⇒ 用户侧有显式配置(未核实)。
  • 若将来真出现"经 Caddy 极快 502":可低风险加固 reverse_proxy 里加 keepalive 60s(在上游 GOAWAY 之前主动丢弃池连接);排查用全局 DEBUG(站点 log 只出 INFO/ERROR 访问日志,http.handlers.reverse_proxy 的 DEBUG 行才带 upstream roundtrip 的错误原因)或 systemctl edit caddy 加 Environment=GODEBUG=http2debug=2(唯一会打印 GOAWAY/RST_STREAM/重试的地方)。⛔ 别给 gRPC 开 lb_try_duration/lb_retries(默认只重试 GET,重放已消费的体会造成空体 POST → #8166)。