Loading...

文章背景图

一次网站「时好时坏」的排查记:Gitea 协议警告、SSO 转圈与 WireGuard 的 MSS 黑洞

2026-10-14
2
- 字
- 分钟
|

最近一段时间,家里自建站的登录功能开始变得「概率可行」:博客和 Git 的 SSO 登录十次里能成功七八次,剩下的要么一直转圈,要么直接甩一个 HTTP ERROR 524。Gitea 后台还一直挂着一句警告:

The detected web site URL is "http://git.archzero.top/", it's unlikely matching the site config. Mismatched app.ini ROOT_URL or reverse proxy "Host/X-Forwarded-Proto" config might cause wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.

把这件事查完,一共挖出五个互相独立的问题,最后一个是真正的元凶。过程记下来,算是给同样「CDN + 内网自建」架构的一个排错样本。

结论先行(TL;DR)

# 问题 表现 修法
1 CDN 边缘缓存了坏响应与旧前端 同一 URL 忽 302 忽 404、页面永远转圈 刷边缘缓存;源站加 Cache-Control: no-store;HTML 与 4xx 不缓存
2 反向代理「信任上游」,而上游什么都没说 后端认为自己在 http 上 反向代理按域名强制声明 X-Forwarded-Proto
3 nginx 的 absolute_redirect 把相对跳转补成 http:// https 用户被弹到 http absolute_redirect off;
4 网页后台改了配置但没有 reload 加了 location 却一直不生效 nginx -t && nginx -s reload,并写进数据库
5 容器请求自家公网域名绕公网 + 隧道 MTU 不匹配 登录偶发 524、卡满 60 秒 容器内 hosts 指向内网代理;隧道两端做 MSS 钳制

环境与拓扑

家里没有公网 IPv4(宽带在 CGNAT 后面),所有对外服务都靠「云端中转 + WireGuard 隧道」把流量送回内网:

                    手机 / 浏览器
                         │  https://www.archzero.top
                         ▼
           CDN 边缘节点(阿里云 ESA,负责 TLS 与缓存)
                         │  回源(明文 80)
                         ▼
        云端 hub(一台小 VPS:WireGuard + nftables DNAT)
                         │  WireGuard 隧道(MTU 1420)
                         ▼
     家里宽带(CGNAT)→ 内网 Debian
                         └── nginx-proxy-manager
                              ├── Halo         :8090   博客
                              ├── Gitea        :3000   Git
                              └── Casdoor      :8000   统一登录 SSO

这个架构本身没问题,但它把「一次请求」拉得很长:任何一个环节的协议、缓存、MTU 假设不一致,都会变成玄学故障。

排查方法论

这次真正有用的不是某个命令,而是三条纪律:

  1. 先分层,再逐层证伪。 客户端 → 边缘 → hub → 隧道 → 反向代理 → 后端,每一段单独测,别在整条链上瞎猜。
  2. 不看状态码,看在网线上到底说了什么。 200 OK 也可能携带错误的协议假设;真正的证据是 tcpdump 抓到的请求头、以及应用日志里的耗时与错误码。
  3. A/B 验证。 回滚 → 抓包 → 打补丁 → 再抓包。差异写进结论,而不是「应该好了」。

一、边缘缓存:同一个 URL 忽 302 忽 404

第一波症状是博客的登录入口 /login 在浏览器里时通时不通。实测几十次发现同一个 URL 的返回在飘:

https://www.archzero.top/login   →  有时 302(正常跳转 SSO)
                                    有时 404(后端根本没有这个路由)

原因有两个叠加:CDN 边缘在故障期间缓存了 404(各节点状态不一致),以及某次 SSO 组件升级后边缘还留着旧版前端,而旧前端早已在源站 404。

处理方式:

  • 在边缘刷新(清除)受影响路径的缓存,重点是入口页与静态资源;
  • 源站的入口跳转加 Cache-Control: no-store,别让边缘再记住它;
  • 给边缘配缓存规则:HTML 与 4xx/5xx 一律不缓存(或极短 TTL),静态资源才长缓存。

二、协议错配:反向代理「信任上游」,而上游什么都不说

Gitea 的那句警告指的就是这件事。Nginx Proxy Manager 的代理模板里,协议不是取本机看到的,而是取上游声明的:

proxy_set_header X-Forwarded-Proto $x_forwarded_proto;

而 nginx.conf 里的映射是:

map $http_x_forwarded_proto $x_forwarded_proto {
    "http" "http";
    "https" "https";
    default $scheme;          # 上游没说 → 用 TCP 层看到的协议
}

我的 CDN 边缘是用 80 端口明文回源的,而且不带 X-Forwarded-Proto 头。于是 $scheme = http,后端(Gitea / Halo)就真诚地认为自己跑在 http 上:Gitea 报警告、Halo 生成的 sitemap 里全是 http:// 链接。

修法是让反向代理按域名如实声明(对外只有 https 的域名答 https,内部 http 服务保持原样):

map $host $x_forced_proto {
    default $scheme;
    www.archzero.top  https;
    git.archzero.top  https;
    sso.archzero.top  https;
}

map $http_x_forwarded_proto $x_forwarded_proto {
    "https" "https";
    default $x_forced_proto;
}

验证方式很直接——在去往后端的网线上抓一个请求,读后端实际收到的头:

timeout 14 tcpdump -i any -A -s0 -c 80 'tcp port 3000' > /tmp/hdr.txt &
sleep 2
curl -s -o /dev/null -H 'Host: git.archzero.top' http://127.0.0.1/
sleep 2; kill %1
grep -a -iE 'X-Forwarded-Proto:' /tmp/hdr.txt | sort -u

改动前抓到 X-Forwarded-Proto: http,改动后是 https。顺带一个坑:nginx.conf 是镜像自带文件,重启不会重建;而 conf.d/include/proxy.conf 每次启动都会被重写——要改就得改前者。

三、absolute_redirect:看不见的 http 跳转

第二波症状是「登录第一步就掉协议」。用 curl -I 看 Git 的登录入口:

https://git.archzero.top/user/login
   → 302 location: http://git.archzero.top/user/oauth2/...

我写的是相对跳转 return 302 /user/oauth2/...;,但 nginx 默认 absolute_redirect on,会自动补成绝对地址——用的是 $scheme,也就是 http。用户从 https 页面被弹到 http,再经边缘绕一圈,链路就变得更脆。

修法一行:

absolute_redirect off;

(写注释只能用 #。我第一次手一抖写了 //,nginx -t 直接拒绝——这反倒救了我:配置没生效,线上毫发无伤。)

四、改了配置却没生效:网页后台保存 ≠ reload

这一条最阴。反代的网页后台里明明加了一条「/login 一律跳到 SSO」的 location,文件里也看得见,但实测就是不生效——因为后台保存只重写配置文件,不会 reload nginx。配置文件的时间戳很新,运行中的配置却是两天前的。中间还夹着「带查询参数时会退化到后端 404」的旧逻辑。

修法两件事:

  1. 改完必须 nginx -t && nginx -s reload;
  2. 入口改成无条件跳转,并把这条配置写进 NPM 的 SQLite(proxy_host.advanced_config),这样容器重启也不会丢:
location = /login {
    add_header Cache-Control "no-store" always;
    return 302 /oauth2/authorization/sso;
}

另外提醒一句:在同一段配置里堆 set + 多个 if 很容易出现「看起来对、实际不执行」的情况,能用一条无条件 return 就别绕。

五、发夹弯:容器请求自己的公网域名

到这一步,边缘干净了、协议对了、跳转也对了,登录还是会偶发卡住。翻 Gitea 的日志,拿到了最直接的证据:

Failed OAuth callback: (internal) Non-200 response from UserInfo: 524, ...   elapsed 60491.0ms
HTTPRequest [W] router: slow  GET /user/oauth2/<name>/callback               elapsed 3472.1ms

原因很朴素:Gitea 处理 OAuth 回调时,服务端要自己去请求统一的登录域名(换 token、取用户信息)。而这个域名解析到 CDN 边缘,于是请求从家里出发 → 到边缘 → 回到 hub → 穿过隧道 → 再回到家里。一次「自己访问自己」绕了整个公网,那条链路只要抖一下,就是几十秒的超时。

修法:让容器把自家域名直接解析到内网反向代理(TLS 证书和 Host 都还是公网名字,验证不受影响):

# 在容器内把域名指到同一 docker 网络上的反代容器
docker exec <app> sh -c "grep -v 'example.com' /etc/hosts > /tmp/h; \
  printf '%s\n' '<反向代理容器IP> sso.example.com www.example.com git.example.com' >> /tmp/h; \
  cat /tmp/h > /etc/hosts"

两个细节:容器重建会重置 /etc/hosts,所以配了个 systemd timer(每分钟一次)做幂等补齐;另外有些容器以非 root 运行,写不了自己的 /etc/hosts,那就从宿主机直接改它的 HostsPath。改完实测:容器到 SSO 的耗时从 0.3~1.0s 降到 0.06~0.12s,而且不再依赖公网链路。

六、真正的元凶:MSS 1460 穿过 MTU 1420 的隧道

上面都修完了,还是偶发 524。这时候才想到去量「包本身」。

抓隧道里回给边缘的 SYN-ACK:

timeout 22 tcpdump -i wg0 -n -c 40 -v \
  'tcp[tcpflags] & (tcp-syn|tcp-ack) == (tcp-syn|tcp-ack)' > /tmp/mss.txt &
curl -s -o /dev/null https://www.archzero.top/api/health   # 从外部触发一次
grep -aoiE 'mss [0-9]+' /tmp/mss.txt | sort | uniq -c

结果是:

mss 1460

问题清楚了:家里容器在 1500 字节的 docker 网桥上,所以后端回给边缘的 SYN-ACK 声明 MSS 1460;可这条连接实际要穿过 MTU 只有 1420 的 WireGuard 隧道,最大只允许 MSS 1380。于是边缘按 1500 字节发包,进隧道塞不下;带 DF 位时不会分片,直接丢;而 CDN 边缘通常不理 ICMP 的 PMTU 通知——一个标准的 PMTU 黑洞。小请求能挤过去,稍大的响应就卡,看起来当然「概率可行」。

修法是在隧道出口做 MSS 钳制(nftables,带计数器便于自证):

table inet mssclamp {
  chain forward {
    type filter hook forward priority mangle; policy accept;
    iifname "wg0"  tcp flags syn tcp option maxseg size set 1380 counter
    oifname "wg0" tcp flags syn tcp option maxseg size set 1380 counter
  }
}

做成 systemd 服务持久化,然后在出向网卡上再抓一次包:SYN-ACK 里的 MSS 从 1460 变成了 1380,规则计数器同时在涨——这才是真的修好了。之后连打 20 次授权页、10 次首页,全部一次通过。

验证与长期看护

修完不等于结束。为了不再靠「感觉」,补了三件事:

  1. 探针脚本:关键路径(登录入口、SSO 接口、首页)连续打 20~100 次,统计状态码与耗时分布;
  2. 哨兵任务:每 2 分钟检查一次,包含「登录入口应为 302」「响应时间应小于阈值」「容器内访问自家域名必须走内网」这几项,连续两次异常才告警,正常时完全静默;
  3. 备份与一键重放:所有改动都留了带时间戳的备份,并写成幂等脚本。反代镜像升级或容器重建后,重跑一次脚本就能恢复全部补丁。

经验清单

  1. 前面站着 CDN 时,源站不要相信 TCP 层看到的协议,协议要靠上游明确声明或自己按域名判定。
  2. 改了配置必须 reload,而且要验证 reload 之后的运行态,不要只看文件内容。
  3. 相对跳转不要让它被补成绝对地址——尤其是源站看到的是 http 的时候。
  4. 自建服务不要绕公网访问自己:能走内网就走内网,否则一条公网链路的不稳定会直接变成登录失败。
  5. WireGuard(或任何隧道)两端 MTU 不一致时,一定要做 MSS 钳制,否则表现就是随机超时。
  6. 抓包胜过猜测:所有结论最好都能落到「网线上看到的字节」。
  7. 每一个改动都要有备份和可重放脚本,回滚能力决定你敢不敢改。
  8. 给关键路径配哨兵,把「偶发」变成「有日志、有告警」。

后记

这次排查由我日常在用的 AI Agent(Hermes Agent)与我配合完成:它负责逐层探测、抓包取证、写补丁和验证脚本,我负责判断、决策与授权。整件事最大的感受是——偶发故障不可怕,可怕的是没有分段证据;一旦能在网线上看到「谁在用什么协议、什么 MTU 说话」,玄学就变成了工程。

原创

一次网站「时好时坏」的排查记:Gitea 协议警告、SSO 转圈与 WireGuard 的 MSS 黑洞

评论交流

文章目录