1. 触发场景

给一台跑在容器里的机器新增域名。本文是 LXD 容器(内网 192.168.52.172):nginx vhost 写好了、证书装好了、DNS A 记录也加了,公网就是不通。

2. 症状

  • 公网访问:403 Forbidden,响应头 Server: openresty,证书是 CN=127.0.0.1 的自签证书。
  • 容器内部:openssl s_client -connect 127.0.0.1:443 -servername 新域名 返回正确的证书。
  • 同一台机器换成已知能用的老域名走公网:一切正常。
  • 容器的 nginx 访问日志里完全没有这条请求。

这三条同时出现时,配置本身没问题,是更前面断了。

3. 决定性判据

三个一起看,30 秒定型。

① 对比回环 vs 公网,用同一个 SNI

openssl s_client -connect 127.0.0.1:443 -servername <新域名> 2>/dev/null | openssl x509 -noout -subject
openssl s_client -connect <公网IP>:443  -servername <新域名> 2>/dev/null | openssl x509 -noout -subject

回环给对证书、公网给自签证书 → 中间有一层。

② 看 Server: 响应头

容器自己的 nginx 配了 server_tokens off,头是 Server: nginx;挡在前面那层返回 Server: openresty。两个不同的 server 才会给出不同的头——这一条最快。

③ 看容器的 access log

请求根本没到 → 问题不在 nginx 配置。配合日志来源 IP 一起看:如果日志里的客户端 IP 是公网 IP 而不是真实客户端,说明中间那层做了 NAT。

4. 根因

容器前面还有一层按域名转发的反向代理。本文里是同一宿主机上的另一个容器 192.168.52.2,跑 openresty。

它的转发规则是白名单式的:只把已注册的域名转进容器,没注册的一律返回它自己的 403 页和自签证书。DNS 指对了也没用,因为请求压根没进容器。

这类层通常和 NAT / 共享 IP / LXD 容器一起出现——机器自己只有内网地址,对外全靠别人转发。

5. 修法

  1. 找到那层:从容器里扫同网段,谁开着 443 就是谁。

    for i in $(seq 1 20); do timeout 2 bash -c "echo > /dev/tcp/192.168.52.$i/443" 2>/dev/null && echo "192.168.52.$i:443 open"; done

    它往往开着一个面板端口(本文是网关 192.168.52.1:8443 的 LXD API,和转发机 192.168.52.2)。注意这类机器常常没有对外 SSH 端口,只能从容器内部访问。

  2. 在它的面板/配置里加一条转发:新域名 → 容器内网IP:443。
  3. 证书两层都要装——前面那层要做 TLS 终结,容器自己也要。用泛域名证书最省事。
  4. 加完实测,不要只看「保存成功」:

    • 订阅/文件类路径:curl 拿到内容和正确的 Content-Type
    • WebSocket 类路径:手动发升级请求,要拿到 101 Switching Protocols

      curl -s -i -N --max-time 10 \
      -H "Connection: Upgrade" -H "Upgrade: websocket" \
      -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
      https://<新域名>/<ws路径> | head -3

5.1 转发层配好之后的第二关:后端自己的 vhost

修好第 5 节之后还有一半:转发层只是把请求送到后端,后端得自己认领这个域名。

同一个后端上,「A 域名通、B 域名不通」而两者在转发面板里的配置一模一样时,去数后端的 vhost:

# la(宝塔)的例子
ls /www/server/panel/vhost/nginx/*.conf
# gpt.6767.chat.conf   ✓
# la.6767.chat.conf    ✓
# ai.6767.chat.conf    ✗  ← 就是它

后端没有对应 vhost 时,请求会落到默认站点(server_name _),表现是:

  • 返回默认站点的页面。宝塔的默认页是「域名未绑定」,很容易被误读成「转发层没生效」。
  • API 路径返回 146 字节的 nginx 404,Server: nginx。
  • 补齐 vhost 后同样的路径返回 401 + JSON(Invalid token)—— 401 才是「找对人了,只是没带 key」。

排查顺序反过来更快:先看后端有没有这个域名的 vhost,再看转发层。默认站点的错页长得像转发失败,很容易让人在前端来回查。

为什么日志不能当证据:宝塔的 nginx.conf 里 http 段有 access_log off;,没自带 access_log 的 vhost 什么都不记。于是「访问日志里没有这条请求」既可能表示请求没到,也可能表示到了但没记 —— 不能用来下结论。看默认站点的页面内容是不是后端的,比看日志可靠。

6. 下次如何识别

  • 「本机测通、外面 403」= 先怀疑中间层,不要回去翻 nginx 配置。
  • Server: 头跟预期不符是最快的指纹,比翻日志快。
  • 换老域名能通、新域名不通 → 白名单问题,不是 DNS 问题。
  • NAT / 共享 IP / 容器化的机器,默认假设前面有转发层。新域名第一件事就是问「这层认不认这个名字」。
  • 加域名是两步:DNS 一次,转发层一次。只做 DNS 会得到「解析对了但访问 403」这种最容易误判的状态。

7. 相关

来源:2026-09-22 在 LA 小鸡(内网 192.168.52.172)新增 la.6767.chat。DNS、证书、vhost 全部就位后公网仍 403;靠「回环 vs 公网 SNI 对比 + Server: 头」定位到宿主机上的转发容器 192.168.52.2。当时还用了「access log 里没记录」这条证据,但它是错的 —— 宝塔 http 段有 access_log off,默认 vhost 本来就不记日志。后来给 ai.6767.chat 排查时被这条假证据带偏了一次:真实原因是后端 la 没有 ai.6767.chat.conf,请求落到了默认站点(那张「域名未绑定」的页面)。补上 vhost 后同一路径从 404 变成 401。见 5.1 节。

本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Network