一条 DNS 记录就把整台机器覆盖了:*.example.com → 203.0.113.10。设置一次之后,发布服务这件事就再也没碰过 DNS。我随便起一个名字,这个名字已经能解析。
剩下要做的,就是在 Nginx Proxy Manager(NPM)里填一张表,四个字段真正重要:域名、容器名、容器端口,还有一个申请证书的勾选框。发布一个服务,全部工作量就是这些。填完保存,这个域名就在跑 HTTPS 了——而背后那个容器,从来没对外暴露过任何东西。
一旦发布变成填表,它就可以被自动化。我早就不自己动手填了——我描述一下要上什么服务,Claude Code 就把整套流程走完(compose 文件、网络、反代配置、证书),最后把 URL 丢回给我。
这就是我手上多出十九个反代主机的原因。十九个公开域名,背后全是开箱即用的自建软件,每一个对"认证"这件事的理解都不一样:有的有真正的用户账号系统,有的只有一个共享密码,有的初始化向导压根没问过,有的什么都没有。
问题就出在这份"容易"上。那张表里没有任何一个字段,逼你去想清楚:谁应该能碰到你刚发布的这个东西。
我真正想要的,从来不是把这些服务藏到公网之外——泛域名一解析成功,这个选项就已经不存在了。我想要的是:这十九个服务只能从一个地方进得去——一个完全用公网基础设施搭出来的内网,在这里,"在公网上"和"能被访问到"是两件不同的事。
泛域名买到了什么,又买不到什么
泛域名是一条 DNS 记录,不是一张证书。这两者经常被混为一谈,而这个区别,决定了防火墙要长期保持什么样子。
这条记录省下的是实打实的工作量。*.example.com 指向这台 VPS,所以以后随便想出什么域名都已经能解析——从"决定用这个名字"到"能用这个名字"之间,没有传播等待。
证书不是泛域名的。NPM 是按域名逐个走 HTTP-01 签发的,所以这十九个域名,每一个都要 Let's Encrypt 主动发一个请求到 http://name.example.com/.well-known/acme-challenge/...,还得指望 nginx 在 80 端口上应答。十九张证书,十九次验证——而且证书大概三个月一到期,续期定时器会对每一个域名永远重复这套验证。
所以 80 端口永远关不掉。不是"最好一直开着",是这台机器的两层防火墙,哪一层都不能把它关掉。而且失效方式是无声的:关掉 80 端口,不会有任何你能观察到的东西坏掉。网站照样在 443 上用手头已有的证书提供服务,续期请求悄悄失败进一个没人看的日志里,直到三个月后,所有域名同一时间失去信任,故障才会显形。
换成 DNS-01 验证,能拿到一张真正的泛域名证书,也能把 80 端口关掉,代价是要把 DNS 区域的写入权限交给反代。我没有这么做。
一张网络,以及反代为什么靠名字找到服务
这张网络只手动创建一次——docker network create proxy——之后每个 compose 文件都只是声明"这个网络不是我创建的":
services:
trilium:
networks: [proxy] # 真正让容器挂上这张网络的是这一行
networks:
proxy:
external: true # 这一行只是声明"用已存在的那张,别新建"
这两块看起来像是同一个设置写了两遍。不是。少写 service 下面那一块,文件照样合法、网络照样能解析,只是没有任何容器挂在上面。
两块都写对之后,反代配置里填的就是容器名:转发主机名 trilium,转发端口 8080。Docker 内置的 DNS 会在这张共享网络里把这个名字解析出来,8080 只在这张网络内部可达,别处都不行。nginx 反代的这些容器,没有一个把端口发布到宿主机上。
这一步最容易做一半。加入共享网络,并不会自动收回已经发布出去的宿主机端口。这是两个独立的动作,只做第一个,等于多开了一条路,没有关掉原来那条。
nginx 自己的管理面板也是用同样的办法——反代 127.0.0.1:81,所以 81 端口同样从不对外发布:入口不该有第二道门。会有人告诉你这行不通——说容器里的 127.0.0.1 指的是容器自己,所以这样配会死循环、报 502。前提是对的:容器里的 127.0.0.1 确实是容器自己。但结论不成立。一个 nginx 进程服务所有虚拟主机,把请求从 443 监听器转手给 81 监听器,是一跳,不是递归。
docker exec npm curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:81/
# 200
真正会死循环的写法,是把一个主机转发到它自己的域名,或者 443 转发到 443。
网关,以及它依赖的那个巧合
这张网络上还坐着另一个容器:一个自建的代理网关,和其他所有东西一样,跑在同一台 VPS 上。
跟这篇文章有关的,只有一件事:它的流量从哪里出来。设备一连上这个网关,它发出的请求就会经由 VPS 的公网接口离开,再打回同一台机器的 :443 端口。在 nginx 眼里,这跟普通的公网 HTTPS 请求没有任何区别,它往日志里写的客户端地址是 203.0.113.10——VPS 自己的地址。
这个地址我在这一列里见过,而且当时我理解错了。那会儿我正在追一个间歇性断连的问题,失败的请求记录的客户端 IP,跟服务器自己的公网地址一模一样。看上去像是路由出了故障:机器上有什么东西在跟自己说话,流量咬住了自己的尾巴。我当时把它归档成一个待解的环路,打算以后再查。
那不是故障。是网关在做它该做的事。日志从头到尾都在说真话,只是这个真话的样子,我当时没认出来。
我当时想绕开的那个环路,正是这整套设计的立足点。
这条规则到底说了什么
每一个被这条规则保护的域名,NPM 都会往对应的反代配置文件里写同一块内容:
location / {
auth_basic "Authorization required";
auth_basic_user_file /data/access/2;
allow 172.19.0.2/32; # 网关容器,在共享的 docker 网络上
allow 203.0.113.10; # VPS 自己的公网地址
deny all;
satisfy all;
}
两条 allow 是"或"的关系。请求只要命中其中一条,就通过地址检查;其余的一律落到 deny all,直接拿到 403——nginx 连上游的连接都还没建立。
两条都必须留着,因为同一个请求可能从这台机器的两侧之一进来。共享网络里的容器,走这张网络直接连到 nginx,nginx 记下的客户端地址,是那个容器的 Docker 地址。不在这张网络上的一切,都要走宿主机的公网接口进来,nginx 记下的就是 VPS 的公网地址——网关的流量也包括在内,因为它离开过这台机器,又绕回来了。两条路径,两个地址,一条规则要同时盖住两边。哪一条都不是多余的。
satisfy all 不属于上面这套逻辑,它是另一个维度:让地址检查和密码验证同时变成必须项。(很多人会不小心选成 satisfy any——那样的话,两者中任意一个成立就够了。)所以一个被放行的地址,换来的只是一个密码输入框,不是服务本身。
整套设计,一句话就能说完:十九个公开域名,全都能解析,全都会向任何连上来的人出示一张有效证书——而这些都不负责决定谁能进来。真正说了算的,是那两条 allow 加上它们后面的密码。一个陌生人拿着一条公开的 DNS 记录和一张公开的证书,最多也就走到一个自己过不去的登录框前面。这才是泛域名真正买到的东西:买到的不是 DNS 的隐私,也不是地址的保密,而是一张完全用公网基础设施搭起来的内网——每一道门都是一个公网 IP,背后站着一行 nginx 配置。
有一条路径能逃开这一切。这条规则的作用域是 location /,而 NPM 另外单独写了一个刻意豁免的 location:
location ^~ /.well-known/acme-challenge/ {
auth_basic off;
auth_request off;
allow all;
# ...
}
Let's Encrypt 不公布它验证基础设施的 IP 段,所以根本没有东西可以放进白名单——验证路径要么对全网开放,要么续期就会停摆。这跟 80 端口那件事其实是同一个道理,只是换了身衣服:一个刻意留下、写在文档里的漏洞,而且留对了。
两个没法被这条规则保护的主机
十九个反代主机里,十七个带着这段配置。剩下两个没有:网关自己的管理面板,和那个负责下发客户端配置的端点。
它们没法带着这段配置。规则的前提是网关已经把你带进来了——正是这一步,才让日志里出现一个被放行的地址。而进管理面板,恰恰是要在这个前提成立之前就进去;给一台新设备做初始化,恰恰是要从一台还没配置好的设备去拉配置。你不能把钥匙锁进它自己要开的那把锁里。
所以这两个只能靠自己的认证撑着,别无其他:一个摆在公网上的登录框,密码是我自己设的那个,限流也就是软件自带的那一点。这台机器上其他所有域名都站在两道独立检查后面。这两个只有一道,我宁愿把这件事说清楚,也不想假装泛域名能把这个缺口也一并盖住。
反代弄坏了什么:会猜自己地址的服务
把所有东西都收进同一个 nginx 之后,弄坏了一个已经安安稳稳跑了一年、没人动过的服务。不是网关,也不是那两个没被保护的主机——这一个现在带着完整的那段配置;出事那会儿它还没有,原因下面就会讲到。它的工作,是给每个用户下发一份连接配置,里面写死了一个服务器地址。
它是分阶段坏掉的:先是一个人,然后两个人,最后是所有来问的人。
这台机器能告诉我的一切都是绿灯:容器在跑,密钥对得上,没有任何东西被封,日志干干净净。从服务器上 curl 这个服务的域名,返回 200。
于是我形成了一个判断,而且是个说得通的判断:某条防火墙规则被删掉了。那天下午我刚好在云控制台里改过安全组规则。内部一切正常、外部彻底不通,正是包过滤在流量落地之前就把它丢掉的典型样子。
我没有继续往下推理,而是找了一个连不上的人,让他把客户端实际报的错发给我。
connect: connection refused,对方地址是 198.51.100.20。不是我的服务器。这一下把刚才那个判断从两个方向同时打死了——包过滤丢包给你的是超时,不是拒绝连接;而且客户端压根就没在尝试连我的服务器。反查这个 IP,落在我自己的 ISP 出口上:那天早上,我家里的网络。
nginx 做的事情没有错:它把 X-Forwarded-For 设成了真实的客户端地址。下游那个服务,把这个头当成了自己的公网地址——就是它写进每一份配置里的那个值。于是它对外宣称的地址,变成了"最后一个来问的是谁"。那天早上我的笔记本刚好从家里拉过一次配置;从那之后,这个服务就告诉所有人:来连我家。那个本该用来钉死对外地址的字段,一直就在设置里,只是空着。
旁边还藏着一个容易犯的错:容器内部监听的端口,和它对外宣称的地址,在设置表里紧挨着,第一反应很容易改错那一个。这条代码路径从装上那天起就没被真正触发过——客户端一直是直连这台机器,没有任何请求头需要它去解读,也就没机会读错。
它藏了好几天,原因就在于我站在哪个位置去测试。我自己的客户端是经过网关出去的,所以每次我自己拉配置,拿回来的都是 203.0.113.10——恰好是它本该带的那个值。每一次看起来都是对的。让这条访问规则成立的那个地址,正是把这个 bug 藏起来的那个地址:同一个事实,干了两份活,而我两次都是从错的那一侧去读它。
加密到哪里为止
证书只在 nginx 这一层,别处没有。TLS 在这里终止;从 nginx 到容器这一段,请求是以明文 HTTP 的形式走 Docker 网络的。这就是为什么每个反代主机的 Scheme 字段都填 http——填成 https 会立刻拿到 502,而不是卡住,因为后端端口上根本没有任何东西在监听 TLS。
这本身没问题,因为那张网络本来就没有对外发布。但它决定了访问检查必须放在哪里。过了 nginx 这一关,客户端就不存在了:容器看到的是一个来自自己网络内 Docker 地址的连接,关于调用方的其他一切信息,都只能靠一个请求头来传递。nginx 是最后一个还能从 socket 本身、而不是从一个字符串里拿到客户端地址的地方。这条规则要么放在这里,要么就无处可放。
这套设计不是什么
两个被放行的地址,不等于零信任。任何能从这两个地址之一发出流量的东西,都在这道门里面。其他所有用这个网关的人,到达时都是 203.0.113.10,跟我一样;这台机器上的每一个进程也是。这条规则不识别调用方是谁——它只确认这个请求是从一条预期中的路走进来的。
地址后面站着的是 basic auth,本质就是一个密码。一串共享的字符串,没有第二因素,也没有任何东西限制暴力尝试的速度。两道检查比一道强,靠的是它们会独立失效,不是因为哪一道本身够硬。
这些服务依然无条件相信 nginx 给的请求头——就是那份无条件的信任,花了好几天把我家地址到处宣传出去。这条规则没有修好这一点。它只是把能触发这个问题的人群缩小了。
80 端口永久开着,它下面那条验证路径,对所有人都开着。
网关一旦挂了,我对十七个域名的访问也跟着没了。回来的路是 SSH——这也是为什么这条规则是 nginx 返回 403,而不是防火墙直接丢包。403 活在宿主机上的一份配置文件里;出故障时我需要的那扇门,不能是我自己刚刚关上的那一扇。
同一个地址,出现在两栏里
十九个域名,靠一行配置守住,而撑住这一切的,是拓扑结构上的一个巧合:离开这台机器又绕回来的流量,和压根没来过这台机器的流量,看起来毫无分别。我能只放行一个地址,是因为这台机器在替我向它自己作保。
代价是,我调试问题的位置,正是这条规则奖励的那个位置。不管我检查什么,我检查的时候手上已经握着通行的凭证,所以查出来的结果永远是"没问题"。要看到我自己产生不出来的那个视角,得靠别人发给我的一条报错信息。
这台机器上最便宜的那道访问控制,和这台机器上藏得最久的那个 bug,是同一个字符串,出现在同一份日志的同一栏里。这笔交易我还会再做一次。只是下次,我不会再站在白名单里面测试了。