赛斯叔叔 - SAIS.IO

赛斯叔叔

使用 Nginx 进行 SNI 应用分流

26
2024-03-21

NGINX-Part-of-F5.svg

前言

Nginx,一款轻量级且功能强大的Web服务器,以其低资源占用而广受赞誉。然而,Nginx的功能远不止于此。它不仅可以作为Web服务器,还可以执行反向代理和集群负载均衡等常见操作。通过编写Lua脚本并嵌入Nginx,我们可以完成更复杂的任务。那么,Nginx的应用是否仅限于HTTP层面呢?

答案显然是否定的。Nginx不仅可以在OSI协议的第七层应用层工作,还可以直接在第四层传输层上运行,将第四层的TCP流量进行转发。本文将探讨如何使用Nginx进行SNI分流,以实现与网站的完美共存。

传输层安全协议(TLS)是一种在传输层工作的重要安全协议,能够为互联网通信提供安全和数据完整性保证。HTTPS等安全传输都是基于TLS进行的。服务器名称指示(SNI)是TLS的一个扩展协议,允许客户端在握手过程开始时告知服务器要连接的主机名称。Nginx可以利用其stream模块,基于SNI,对进入同一端口但主机名不同的TLS流量进行分流。如果你有一个基于TLS的应用,需要运行在443端口,而443端口已经被Nginx监听并用于运行网站,那么你可以使用Nginx的SNI分流功能,复用443端口,将使用不同域名(主机名)的TLS流量分开,实现互不干扰,完美共存。即使你的应用对主动嗅探非常敏感,也无需担心,因为Nginx会挡在最前面,你还可以配置你的应用进行回落,以规避主动嗅探。主要应用场景包括基于TLS,直接获取TCP流的应用,如XTLS等。

目标:在443端口上,使用Nginx进行SNI分流,实现TCP应用与网站的完美共存。示例中的网站后端是PHP,但其他后端的原理也是类似的。

准备工作

由于我们正在转发TCP流,因此需要在nginx中安装ngx_stream_core_module模块(简称stream模块)。此外,我们还需要进行SSL证书前置,这需要ngx_stream_ssl_preread_module模块。要确认这些模块是否已经被编译进Nginx,可以使用Nginx -V命令进行检查。

如果返回的结果中包含--with-stream和--with-stream_ssl_preread_module,那么这两个模块已经成功编译进Nginx。如果没有,那么你需要手动重新编译Nginx。

Nginx 配置解惑

SNI 分流

为了清晰地阐述我们最终配置文件的工作原理,本小节前半部分的配置文件并非最优选择。经过逐步的思考和优化,我们在本小节末尾提供了最终的优化配置文件。如果你对工作原理不感兴趣,可以直接查看末尾的优化版本。

由于Nginx需要对通过443端口的TLS流量进行SNI分流,因此其stream模块需要监听服务器公网IP的443端口。这也意味着Nginx的Web服务器配置文件不能再监听0.0.0.0的443端口,以避免端口冲突。你可能会在网络上找到类似下面的配置文件。

# stream模块设置
stream {
    # SNI识别,将一个个域名映射成一个配置名
    map $ssl_preread_server_name $stream_map {
        website.example.com web;
        xtls.example.com xtls;
    }
 
    # upstream,也就是流量上游的配置
    upstream xtls {
        server 127.0.0.1:9000;
    }
    upstream web {
        server 127.0.0.1:8000;
    }
    # stream模块监听443端口,并进行端口复用
    server {
        listen 443 reuseport;
        proxy_pass $stream_map;
        ssl_preread on;
    }
}
 
# Web服务器的配置
server {
    listen 80;# 我们只对443端口进行SNI分流,80端口依旧做Web服务;SNI分流也只能在443端口上跑TLS流量才能分流
    listen 8000 ssl http2;# 监听8000端口,要和上面的stream模块配置中的upstream配置对的上
    ......
    if ($ssl_protocol = "") {
        return 301 https://$host$request_uri;
    }
    index index.html index.htm index.php;
    try_files $uri $uri/ /index.php?$args;
    ......
}

在应用上述的 Nginx 配置后,TCP 流量的路由方式如下:

nginx-sni-1.png

解决由于自动添加端口号导致访问路径无法访问的问题

在初步测试后,你可能会认为一切都完全正常,网站和TCP应用都运行良好,似乎已经完成了重要的任务。

然而,当你进行深入测试时,你可能会遇到一些问题:假设你的网站后端使用PHP,当你试图访问 https://website.example.com/php,理论上,它应该重定向到 https://website.example.com/php/ 并显示php目录下index.php的内容。但实际情况并非如此,当你输入 https://website.example.com/php 后,你等待了很长时间,最后发现浏览器报错,提示 https://website.example.com:8000/php/ 的响应时间过长。即使是正常的网页,获取的访客IP地址也全部显示为127.0.0.1,而非真实的访客IP。

这是为什么呢?

原因在于,访问 https://website.example.com/php 能够重定向到 https://website.example.com/php/,是因为在Nginx中配置了try_files。在我们完成配置后,如果/php文件不存在,Nginx会尝试重定向到/php/目录。由于Web服务器现在监听在8000端口,我们自然也会被重定向到8000端口上。那么,如何解决这个问题呢?

有两种解决方案。第一种相对复杂,是我在最初时找到的解决方案。然而,在完成后,我找到了一个更优的解决方案。因此,我建议你直接参考第二种方案。

方案一 监听本地443

将本地的Web应用配置在443端口上,理论上应该可以解决这个问题。然而,由于stream模块已经占用了0.0.0.0的443端口,我们似乎无法使用该端口。这个Web服务器实际上是隐藏在stream模块之后的,换句话说,它只需在本地(127.0.0.1)运行并与stream模块进行交互。stream模块并不需要监听0.0.0.0,因为它直接与公网进行交互,只需监听服务器的公网IP即可。这样,我们就可以避免端口冲突,从而完美地解决这个问题。

# stream模块设置
stream {
    # SNI识别,将一个个域名映射成一个配置名
    map $ssl_preread_server_name $stream_map {
        website.example.com web;
        xtls.example.com xtls;
    }
 
    # upstream,也就是流量上游的配置
    upstream xtls {
        server 127.0.0.1:9000;
    }
    upstream web {
        server 127.0.0.1:443;
    }
    # stream模块监听服务器公网IP443端口,并进行端口复用
    server {
        listen [服务器公网IP]:443 reuseport;
        proxy_pass $stream_map;
        ssl_preread on;
    }
}
 
# Web服务器的配置
server {
    listen 80;# 我们只对443端口进行SNI分流,80端口依旧做Web服务;SNI分流也只能在443端口上跑TLS流量才能分流
    listen 127.0.0.1:443 ssl http2;# 监听本地443端口,要和上面的stream模块配置中的upstream配置对的上
    ......
    if ($ssl_protocol = "") {
        return 301 https://$host$request_uri;
    }
    index index.html index.htm index.php;
    try_files $uri $uri/ /index.php?$args;
    ......
}

请注意:在使用VPC网络的大型公司如阿里云、腾讯云中,有时需要监控以10开头的内网地址,而非服务器的公网IP。

方案二 使用 port_in_redirect

尽管方案一能够完美实现我们的目标,但其配置过程却极易出错,需要同时设置端口号和监听的IP地址。监听的IP地址可能是127.0.0.1,也可能是公网IP地址。一旦公网IP地址发生变化,这里的设置也必须相应调整。然而,使用port_in_redirect方法,我们只需配置端口号,无需关心是否监听到公网IP或127.0.0.1。

这种方法的实施步骤非常简单:只需将vhost中原本监听443的端口改为监听其他端口,如8443端口;接着在vhost配置文件的server块中添加port_in_redirect off即可。简单明了,不是吗?port_in_redirect off的意义在于禁用Nginx反向代理中的绝对端口重定向。其默认值为on。

# stream模块设置
stream {
    # SNI识别,将一个个域名映射成一个配置名
    map $ssl_preread_server_name $stream_map {
        website.example.com web;
        xtls.example.com xtls;
    }
 
    # upstream,也就是流量上游的配置
    upstream xtls {
        server 127.0.0.1:9000;
    }
    upstream web {
        server 127.0.0.1:8443;#注意这里改到了8443
    }
    # stream模块监听服务器公网IP443端口,并进行端口复用
    server {
        listen 443 reuseport;
        proxy_pass $stream_map;
        ssl_preread on;
    }
}
 
# Web服务器的配置
server {
    listen 80;# 我们只对443端口进行SNI分流,80端口依旧做Web服务;SNI分流也只能在443端口上跑TLS流量才能分流
    listen 8443 ssl http2;# 监听本地443端口,要和上面的stream模块配置中的upstream配置对的上
    port_in_redirect off;
    ......
    if ($ssl_protocol = "") {
        return 301 https://$host$request_uri;
    }
    index index.html index.htm index.php;
    try_files $uri $uri/ /index.php?$args;
    ......
}

请注意,方案二存在一个显著的缺陷,即兼容性较差。某些应用程序可能会读取到我们在"Web服务器配置"中设置的8443端口,而非实际提供服务的443端口。例如,Wordpress的Jetpack插件在采用方案二时可能无法正常工作。在这种情况下,方案一可能会是更优的选择。

解决无法获取访客真实 IP 问题

问题出现在无法正确获取访客IP,所有获取到的访客IP都是127.0.0.1。

这里的Web服务器和Nginx stream模块实际上是反向代理,只不过它运行在OSI模型的第四层,而非第七层。

那么,是否存在用于四层代理的协议呢?答案是肯定的,那就是代理协议(Proxy protocol)。这是一种由HAProxy的创始人Willy Tarreau在2010年开发和设计的Internet协议。它通过在tcp中添加一个微小的头信息,便于传递客户端信息(如协议栈、源IP、目标IP、源端口、目标端口等)。在网络环境复杂且需要获取用户真实IP的情况下,这种协议非常有用。值得一提的是,Nginx也支持Proxy protocol。接下来,我们将使用Proxy protocol来解决无法获取访客IP的问题,这个协议似乎就是为了解决我们当前遇到的问题而设计的。

然而,Proxy protocol要求代理服务器和被代理的服务器都必须支持这一协议。如果服务器接收到的第一个数据包不符合Proxy Protocol的格式,服务器将直接终止连接。虽然stream模块和Web服务器都是Nginx,且Nginx支持Proxy protocol,但是,基于TCP的普通应用可能并不支持这个协议。这就引出了一个新的问题:如何解决这个困境呢?

解决方案在于让Nginx的stream模块再次充当TCP应用的中介。我们可以在TCP应用前面使用stream模块进行一次转发,从而去除Proxy protocol的封装,确保传递给TCP应用的仍是最原始的TCP流。这个描述可能有些抽象,但下面的路由方式图应该能帮助你更好地理解。

nginx-sni-2.png

# stream模块设置
stream {
    # SNI识别,将一个个域名映射成一个配置名
    map $ssl_preread_server_name $stream_map {
        website.example.com web;
        xtls.example.com beforextls;# 注意这里修改了
    }
 
    # upstream,也就是流量上游的配置
    upstream beforextls {
        server 127.0.0.1:7999;
    }
    upstream xtls {
        server 127.0.0.1:9000;
    }
    upstream web {
        server 127.0.0.1:443;
    }
    # stream模块监听服务器公网IP443端口,并进行端口复用
    server {
        listen [服务器公网IP]:443 reuseport;
        proxy_pass $stream_map;
        ssl_preread on;
        proxy_protocol on; # 开启Proxy protocol
    }
    server {
        listen 127.0.0.1:7999 proxy_protocol;# 开启Proxy protocol
        proxy_pass xtls; # 以真实的XTLS作为上游,这一层是与XTLS交互的“媒人”
    }
}
 
# Web服务器的配置
server {
    listen 80;# 我们只对443端口进行SNI分流,80端口依旧做Web服务;SNI分流也只能在443端口上跑TLS流量才能分流
    listen 127.0.0.1:443 ssl http2 proxy_protocol;# 监听本地443端口,要和上面的stream模块配置中的upstream配置对的上,开启Proxy protocol
    ......
    if ($ssl_protocol = "") {
        return 301 https://$host$request_uri;
    }
    index index.html index.htm index.php;
    try_files $uri $uri/ /index.php?$args;
 
    set_real_ip_from 127.0.0.1;# 从Proxy protocol获取真实IP
    real_ip_header proxy_protocol;
    ......
}