一直用 Authenlia 作为反代认证,给各种私有服务添加了统一登录。架构如下:

Authelia 架构

当用户希望访问「内网服务#1」时:

  1. 访问反代服务器(Nginx)。
  2. 反代服务器向认证服务器查询是否已登录,若认证成功则继续。否则显示登录页面。
  3. 反代服务器访问内网服务取得资源。
  4. 反代服务器把结果返回给用户。

其中反代服务器与内网服务器处于同一局域网内,可以使用 http 明文通信。但与认证服务器的交互需要 https 加密,这就是出问题的地方。

按照 Anthelia 的文档,在反代服务器这里有这么一段 Nginx 配置:

set $upstream_authelia https://authelia.chenhe.me/api/verify;

## Virtual endpoint created by nginx to forward auth requests.
location /authelia {
    internal;
    proxy_pass $upstream_authelia;

    proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
        # others ...
}

auth_request /authelia;

显然是利用 proxy_pass 来请求认证服务器。

故障表现

一开始认证服务器是用 lnmp 一键安装包搭建的,一切正常。直到我换成了 1Panel 面板,通过 Docker 安装的 OpenResty,所有反代服务器向认证服务器的请求都失败了。

从用户视角来说,访问任何反代服务器均得到 500 错误。查看 Nginx 的错误日志如下:

2024/01/06 02:36:39 [error] 14970#14970: *2168 auth request unexpected status: 502 while sending to client, client: 192.168.32.1, server: router.nas-xz.chenhe.me, request: "GET / HTTP/2.0", host: "router.nas-xz.chenhe.me:xxxx"

2024/01/06 02:37:22 [error] 14970#14970: *2168 SSL_do_handshake() failed (SSL: error:14094438:SSL routines:ssl3_read_bytes:tlsv1 alert internal error:SSL alert number 80) while SSL handshaking to upstream, client: 192.168.32.1, server: router.nas-xz.chenhe.me, request: "GET / HTTP/2.0", subrequest: "/authelia", upstream: "https://43.xx.xx.xx:443/api/verify", host: "router.nas-xz.chenhe.me:xxxx"

显然是证书有问题导致上游请求返回 502,无法判断用户是否已登录进而触发 500。

折腾了很久,检查证书有效性,检查允许的算法套件,甚至尝试把 ED 的证书换回 RSA 都无济于事。原来故障原因是 Nginx 请求上游时默认不发送 SNI,它是个啥?

初识 SNI

在 http 时代,可以在一个物理主机(IP 地址上)部署多个网站,称为虚拟主机。服务器程序依靠 http 的 Host 头判断用户究竟想访问哪一个。接着 https 流行了,它把一切都封装在 tls 层里,当然也包括 http header 报文。于是出现了鸡🐔和蛋🥚的问题:

  • 要想建立 tls 连接就需要服务器发送证书。
  • 服务器需要知道用户访问哪个网站才能发送匹配的证书。
  • Host 被加密了(其实在 tls 建立前压根不存在),无法读取。

于是 SNI 出现了,它就像 http 的 host,客户端以明文的形式把目标主机名发送给服务器 —— 在建立 tls 之前。

虽然这个 SNI 这个词可能有点陌生,但它早就处在互联网的方方面面。因为大部分服务器都会部署超过一个网站(域名),我们的浏览器(或其他 https 客户端)一直按照 SNI 规范发送主机名,从而顺利建立连接。

等等,SNI 是明文的,那不是很危险吗?是的... 这就是大名鼎鼎的 SNI 阻断了💩。
不过真正的数据还是安全的,只是访问的主机名(域名)泄露了而已。

测试 SNI

来对比看看前后两个认证服务器在建立 tls 连接时如何选择并发送证书。使用 openssl 命令可以指定如何发送 SNI:

openssl s_client -connect 43.xx.xx.xx:443 -servername authelia.chenhe.me/

servername 参数就是要发送的 SNI,不指定则不发送。

首先尝试连接新的基于 OpenResty 镜像的认证服务器:

> openssl s_client -connect 43.xx.xx.xx:443 -servername authelia.chenhe.me
CONNECTED(00000003)
depth=2 C = US, ST = New Jersey, L = Jersey City, O = The USERTRUST Network, CN = USERTrust RSA Certification Authority
verify return:1
depth=1 C = AT, O = ZeroSSL, CN = ZeroSSL RSA Domain Secure Site CA
verify return:1
depth=0 CN = chenhe.me
verify return:1
...

没问题,返回了我配置的证书,如果不发送 SNI 呢?

# 不发送 SNI
> openssl s_client -connect 43.xx.xx.xx:443

CONNECTED(00000003)
00873F4FF87F0000:error:0A000438:SSL routines:ssl3_read_bytes:tlsv1 alert internal error:ssl/record/rec_layer_s3.c:1586:SSL alert number 80
---
no peer certificate available
---
No client certificate CA names sent
---
SSL handshake has read 7 bytes and written 297 bytes
Verification: OK
...

果然,服务器没返回任何证书,自然 tls 握手失败。

再看看旧的 lnmp 搭建的 Nginx,发送 SNI 时表现一样,关键是不发送 SNI:

# 不发送 SNI
> openssl s_client -connect 43.xx.xx.xx:443
CONNECTED(00000003)
depth=2 C = US, ST = New Jersey, L = Jersey City, O = The USERTRUST Network, CN = USERTrust RSA Certification Authority
verify return:1
depth=1 C = AT, O = ZeroSSL, CN = ZeroSSL RSA Domain Secure Site CA
verify return:1
depth=0 CN = chenhe.me
verify return:1
...

竟然也返回了证书。看来应该是在某处配置了默认证书,当 SNI 不存在或不匹配时使用。事实上如果什么证书都不配 Nginx 默认会使用一个自签发的证书。

修复

知道原因修复就很简单啦,只需添加 proxy_ssl_server_name on;,如下:

location /authelia {
    internal;
    proxy_pass $upstream_authelia;
    proxy_ssl_server_name on;
    # others ...
}

如有需要也可以通过 proxy_ssl_name www.example.com; 手动指定 SNI 的主机名。

后记

事情结束了,有个问题依然困扰我:https 已经如此流行,在反代中使用 https 作为上游应该非常常见。既然默认不传递 SNI 那么大概率应该无法匹配合法的证书,为什么很少见报错呢?

原来默认情况下 Nginx 不校验上游证书的合法性,只用来加密但挡不住中间人攻击。恰好对应地,上游 Nginx 若 SNI 不匹配则返回自签发证书,一来一回程序就跑起来了。

通过 proxy_ssl_verify on; 可以打开证书校验,但要手动配置公钥才行。若使用的证书是正规 CA 签发的则可以配置系统默认的 CA 证书,具体位置根据系统的不同而不同。

Last modification:January 24, 2024