在不持有证书的前提下转发加密字节,正是 TLS 直通中继所做的事,这种安排既方便又略有迷惑性。由于没有任何解密,也就不存在 HTTP 形态的内容可供过滤,因此常规的 location 块及其访问规则在这里毫无用武之地。NGINX 能做的决定针对连接本身:哪个网络对端敲了门,以及是否应当放行该对端。

决定属于 stream,而不是 HTTP

在 stream 上下文中,NGINX 处理的是 TCP 会话。它能看到的连接的对端地址,并可以在任何字节到达后端之前,对其施加有序的 allow 与 deny 规则。这是一个完整的准入决定,不需要私钥、证书,也不需要解析 HTTP。

MechanismWhat it decidesWhere it lives
stream allow/denyWhether this TCP peer is relayedstream server block
ssl_preread mapWhich backend a named connection goes tostream, with the preread module
HTTP location ruleWhether an HTTP request is servedhttp server block

这三行永远不会互相替代。把 HTTP 访问指令复制到任何提到 443 端口的文件里都不会有任何改变,因为 stream 服务器从不读取 http 上下文。

先准入连接,再转发

假设已有安装且 stream 与 stream-access 模块可用,VPN 客户端把源地址保持在 10.90.0.0/24 内,而 192.168.50.20:443 上的后端已经提供目标 HTTPS 身份。

stream {
    server {
        listen 443;

        allow 10.90.0.0/24;
        deny all;

        proxy_pass 192.168.50.20:443;
    }
}

请把它当作新 listener 的片段,而不是对现有可用配置的直接替换。先检查已有的 listener 与 include,因为在一个已被占用的端口上再加一个 listener,会在 reload 时失败,而不是在请求时失败。

注意缺了什么:没有 listen ... ssl,也没有证书指令。NGINX 转发加密流,后端终止 TLS 并出示客户端所校验的证书。未通过 allowlist 的客户端会被拒绝 TCP 会话,而不是收到 HTTP 403 页面,任何期待状态码的工具都会报出毫无用处的结果。

allowlist 覆盖什么、不覆盖什么

如果中继已经按服务器名选择后端,那么在模块可用的前提下,ssl_preread 与 map 可以继续承担这项工作。名字仍然由客户端提供,而仅仅出示一个名字并不能证明客户端加入了 VPN。

示例规则覆盖整个 stream listener。这使它不适合同时服务公开站点与仅限 VPN 站点的单一入口,那种场景需要按名字设置例外;分离的 listener 通常比无人能读懂的名字相关策略更清晰。

还有两个缺口值得点明。前缀匹配无法说明数据包从哪个接口进来,而别处的 HTTP 规则也无法约束这个 listener。这两点都说明应当把主机层面的 ingress 策略一并纳入视野,而不是把一行 allow 当作全部边界。把边界写成两个必须一致的控制项,这样改动其中任何一个都会进入评审。

NGINX 实际看到的是谁的地址?

负载均衡器、另一层代理或源 NAT 都可能在 NGINX 前面插入中间设备,因此它看到的对端并不是 VPN 客户端的内部地址。在选择前缀之前先检查真实路径,因为按错误地址写出的 allowlist 要么放行转换设备后面的所有人,要么谁都不放行。

PROXY 协议与真实 IP 处理 是正当的答案,但只能针对兼容且被明确信任的发送方进行配置。两种机制都相信发送方所说的话,这正是发送方名单必须很短的原因。信任来自任意互联网客户端的身份头并不能让源信息重新可信,只会把正确的拒绝变成伪造的放行。

reload 之前把两种结果都测一遍

校验配置、reload,然后测两次:一次是必须成功的新放行会话,一次是必须失败的被拒会话。在放行测试中保留常规的后端证书校验,因为能连上却无法校验后端的中继,等于修好了一个问题又制造了另一个。

nginx -t
ss -tnp | grep ':443'

还要分别检查到后端的路由与响应。通过 allowlist 只能证明 NGINX 接受了会话,它无法让不可达的后端作出响应,也无法修复在你开始之前就缺失的返回路径。

相关文章