使用 NGINX 作为 HTTPS 正向代理服务器

  • Post author:
  • Post category:其他




1. 使用 NGINX 作为 HTTPS 正向代理服务器

NGINX 主要设计作为反向代理服务器, 但随着 NGINX 的发展, 它同样能作为正向代理的选项之一。正向代理本身并不复杂, 而如何代理加密的 HTTPS 流量是正向代理需要解决的主要问题。本文将介绍利用 NGINX 来正向代理 HTTPS 流量两种方案, 及其使用场景和主要问题。



1.1. HTTP/HTTPS 正向代理的分类

简单介绍下正向代理的分类作为理解下文的背景知识:

  • 普通代理: 在客户端需要在浏览器中或者系统环境变量手动设置代理的地址和端口。如 squid, 在客户端指定 squid 服务器 IP 和端口 3128。
  • 透明代理: 客户端不需要做任何代理设置, “代理” 这个角色对于客户端是透明的。如企业网络链路中的 Web Gateway 设备。

按代理是否解密 HTTPS 的分类

  • 隧道代理 : 也就是透传代理。代理服务器只是在 TCP 协议上透传 HTTPS 流量, 对于其代理的流量的具体内容不解密不感知。客户端和其访问的目的服务器做直接 TLS/SSL 交互。本文中讨论的 NGINX 代理方式属于这种模式。
  • 中间人 (MITM, Man-in-the-Middle) 代理: 代理服务器解密 HTTPS 流量, 对客户端利用自签名证书完成 TLS/SSL 握手, 对目的服务器端完成正常 TLS 交互。在客户端 – 代理 – 服务器的链路中建立两段 TLS/SSL 会话。如 Charles, 简单原理描述可以参考文章。

注: 这种情况客户端在 TLS 握手阶段实际上是拿到的代理服务器自己的自签名证书, 证书链的验证默认不成功, 需要在客户端信任代理自签证书的 Root CA 证书。所以过程中是客户端有感的。如果要做成无感的透明代理, 需要向客户端推送自建的 Root CA 证书, 在企业内部环境下是可实现的。



1.2. 为什么正向代理处理 HTTPS 流量需要特殊处理?

作为反向代理时, 代理服务器通常终结 (terminate) HTTPS 加密流量, 再转发给后端实例。HTTPS 流量的加解密和认证过程发生在客户端和反向代理服务器之间。

而作为正向代理在处理客户端发过来的流量时, HTTP 加密封装在了 TLS/SSL 中, 代理服务器无法看到客户端请求 URL 中想要访问的域名, 如下图。所以代理 HTTPS 流量, 相比于 HTTP, 需要做一些特殊处理。

在这里插入图片描述



1.3. NGINX 的解决方案

根据前文中的分类方式, NGINX 解决 HTTPS 代理的方式都属于透传 (隧道) 模式, 即不解密不感知上层流量。具体的方式有如下 7 层和 4 层的两类解决方案。



1.3.1. HTTP CONNECT 隧道 (7 层解决方案)



1.3.1.1. 历史背景

早在 1998 年, 也就是 TLS 还没有正式诞生的 SSL 时代, 主导 SSL 协议的 Netscape 公司就提出了关于利用 web 代理来 tunneling SSL 流量的 INTERNET-DRAFT。其核心思想就是利用 HTTP CONNECT 请求在客户端和代理之间建立一个 HTTP CONNECT Tunnel, 在 CONNECT 请求中需要指定客户端需要访问的目的主机和端口。Draft 中的原图如下:

在这里插入图片描述

整个过程可以参考 HTTP 权威指南中的图:

  1. 客户端给代理服务器发送 HTTP CONNECT 请求。
  2. 代理服务器利用 HTTP CONNECT 请求中的主机和端口与目的服务器建立 TCP 连接。
  3. 代理服务器给客户端返回 HTTP 200 响应。
  4. 客户端和代理服务器建立起 HTTP CONNECT 隧道, HTTPS 流量到达代理服务器后, 直接通过 TCP 透传给远端目的服务器。代理服务器的角色是透传 HTTPS 流量, 并不需要解密 HTTPS。

    在这里插入图片描述



1.3.1.2. NGINX ngx_http_proxy_connect_module 模块

NGINX 作为反向代理服务器, 官方一直没有支持 HTTP CONNECT 方法。但是基于 NGINX 的模块化、可扩展性好的特性, 阿里的 @chobits 提供了 ngx_http_proxy_connect_module 模块, 来支持 HTTP CONNECT 方法, 从而让 NGINX 可以扩展为正向代理。



1.3.1.3. 环境搭建

以 CentOS 7 的环境为例。

  1. 安装

    对于新安装的环境, 参考正常的安装步骤和安装这个模块的步骤, 把对应版本的 patch 打上之后, 在 configure 的时候加上参数 –add-module=/path/to/ngx_http_proxy_connect_module, 示例如下:
./configure \
--user=www \
--group=www \
--prefix=/usr/local/nginx \
--with-http_ssl_module \
--with-http_stub_status_module \
--with-http_realip_module \
--with-threads \
--add-module=/root/src/ngx_http_proxy_connect_module

对于已经安装编译安装完的环境, 需要加入以上模块, 步骤如下:

# 停止 NGINX 服务
# systemctl stop nginx
# 备份原执行文件
# cp /usr/local/nginx/sbin/nginx /usr/local/nginx/sbin/nginx.bak
# 在源代码路径重新编译
# cd /usr/local/src/nginx-1.16.0
./configure \
--user=www \
--group=www \
--prefix=/usr/local/nginx \
--with-http_ssl_module \
--with-http_stub_status_module \
--with-http_realip_module \
--with-threads \
--add-module=/root/src/ngx_http_proxy_connect_module
# make
# 不要 make install
# 将新生成的可执行文件拷贝覆盖原来的 nginx 执行文件
# cp objs/nginx /usr/local/nginx/sbin/nginx
# /usr/bin/nginx -V
nginx version: nginx/1.16.0
built by gcc 4.8.5 20150623 (Red Hat 4.8.5-36) (GCC)
built with OpenSSL 1.0.2k-fips  26 Jan 2017
TLS SNI support enabled
configure arguments: --user=www --group=www --prefix=/usr/local/nginx --with-http_ssl_module --with-http_stub_status_module --with-http_realip_module --with-threads --add-module=/root/src/ngx_http_proxy_connect_module
  1. nginx.conf 文件配置
server {
     listen  443;
    
     # dns resolver used by forward proxying
     resolver  114.114.114.114;
 
     # forward proxy for CONNECT request
     proxy_connect;
     proxy_connect_allow            443;
     proxy_connect_connect_timeout  10s;
     proxy_connect_read_timeout     10s;
     proxy_connect_send_timeout     10s;
 
     # forward proxy for non-CONNECT request
     location / {
         proxy_pass http://$host;
         proxy_set_header Host $host;
     }
 }



1.3.1.4. 使用场景

7 层需要通过 HTTP CONNECT 来建立隧道, 属于客户端有感知的普通代理方式, 需要在客户端手动配置 HTTP(S)代理服务器 IP 和端口。在客户端用 curl 加 – x 参数访问如下:


# curl https://www.baidu.com -svo /dev/null -x 39.105.196.164:443
* About to connect() to proxy 39.105.196.164 port 443 (#0)
*   Trying 39.105.196.164...
* Connected to 39.105.196.164 (39.105.196.164) port 443 (#0)
* Establish HTTP proxy tunnel to www.baidu.com:443
> CONNECT www.baidu.com:443 HTTP/1.1
> Host: www.baidu.com:443
> User-Agent: curl/7.29.0
> Proxy-Connection: Keep-Alive
>
< HTTP/1.1 200 Connection Established
< Proxy-agent: nginx
<
* Proxy replied OK to CONNECT request
* Initializing NSS with certpath: sql:/etc/pki/nssdb
*   CAfile: /etc/pki/tls/certs/ca-bundle.crt
  CApath: none
* SSL connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
* Server certificate:
*     subject: CN=baidu.com,O="Beijing Baidu Netcom Science Technology Co., Ltd",OU=service operation department,L=beijing,ST=beijing,C=CN
...
> GET / HTTP/1.1
> User-Agent: curl/7.29.0
> Host: www.baidu.com
> Accept: */*
>
< HTTP/1.1 200 OK
...
{ [data not shown]

从上面 – v 参数打印出的细节, 可以看到客户端先往代理服务器 39.105.196.164 建立了 HTTP CONNECT 隧道, 代理回复 HTTP/1.1 200 Connection Established 后就开始交互 TLS/SSL 握手和流量了。



1.3.2. NGINX stream (4 层解决方案)

既然是使用透传上层流量的方法, 那可不可做成 “4 层代理”, 对 TCP/UDP 以上的协议实现彻底的透传呢? 答案是可以的。NGINX 官方从 1.9.0 版本开始支持 ngx_stream_core_module 模块, 模块默认不 build, 需要 configure 时加上 –with-stream 选项来开启。



1.3.2.1. 问题

用 NGINX stream 在 TCP 层面上代理 HTTPS 流量肯定会遇到本文一开始提到的那个问题: 代理服务器无法获取客户端想要访问的目的域名。因为在 TCP 的层面获取的信息仅限于 IP 和端口层面, 没有任何机会拿到域名信息。要拿到目的域名, 必须要有拆上层报文获取域名信息的能力, 所以 NGINX stream 的方式不是完全严格意义上的 4 层代理, 还是要略微借助些上层能力。



1.3.2.2. ngx_stream_ssl_preread_module 模块

要在不解密的情况下拿到 HTTPS 流量访问的域名, 只有利用 TLS/SSL 握手的第一个 Client Hello 报文中的扩展地址 SNI (Server Name Indication)来获取。NGINX 官方从 1.11.5 版本开始支持利用 ngx_stream_ssl_preread_module 模块来获得这个能力, 模块主要用于获取 Client Hello 报文中的 SNI 和 ALPN 信息。对于 4 层正向代理来说, 从 Client Hello 报文中提取 SNI 的能力是至关重要的, 否则 NGINX stream 的解决方案无法成立。同时这也带来了一个限制, 要求所有客户端都需要在 TLS/SSL 握手中带上 SNI 字段, 否则 NGINX stream 代理完全没办法知道客户端需要访问的目的域名。



1.3.2.3. 环境搭建

  1. 安装

    对于新安装的环境, 参考正常的安装步骤, 直接在 configure 的时候加上 –with-stream, –with-stream_ssl_preread_module 和 –with-stream_ssl_module 选项即可。示例如下:
./configure \
--user=www \
--group=www \
--prefix=/usr/local/nginx \
--with-http_ssl_module \
--with-http_stub_status_module \
--with-http_realip_module \
--with-threads \
--with-stream \
--with-stream_ssl_preread_module \
--with-stream_ssl_module

对于已经安装编译安装完的环境, 需要加入以上 3 个与 stream 相关的模块, 步骤如下:

# 停止 NGINX 服务
# systemctl stop nginx
# 备份原执行文件
# cp /usr/local/nginx/sbin/nginx /usr/local/nginx/sbin/nginx.bak
# 在源代码路径重新编译
# cd /usr/local/src/nginx-1.16.0
# ./configure \
--user=www \
--group=www \
--prefix=/usr/local/nginx \
--with-http_ssl_module \
--with-http_stub_status_module \
--with-http_realip_module \
--with-threads \
--with-stream \
--with-stream_ssl_preread_module \
--with-stream_ssl_module
# make
# 不要 make install
# 将新生成的可执行文件拷贝覆盖原来的 nginx 执行文件
# cp objs/nginx /usr/local/nginx/sbin/nginx
# nginx -V
nginx version: nginx/1.16.0
built by gcc 4.8.5 20150623 (Red Hat 4.8.5-36) (GCC)
built with OpenSSL 1.0.2k-fips  26 Jan 2017
TLS SNI support enabled
configure arguments: --user=www --group=www --prefix=/usr/local/nginx --with-http_ssl_module --with-http_stub_status_module --with-http_realip_module --with-threads --with-stream --with-stream_ssl_preread_module --with-stream_ssl_module
  1. nginx.conf 文件配置

    NGINX stream 与 HTTP 不同, 需要在 stream 块中进行配置, 但是指令参数与 HTTP 块都是类似的, 主要配置部分如下:
stream {
    resolver 114.114.114.114;
    server {
        listen 443;
        ssl_preread on;
        proxy_connect_timeout 5s;
        proxy_pass $ssl_preread_server_name:$server_port;
    }
}



1.3.2.4. 使用场景

对于 4 层正向代理, NGINX 对上层流量基本上是透传, 也不需要 HTTP CONNECT 来建立隧道。适合于透明代理的模式, 比如将访问的域名利用 DNS 解定向到代理服务器。我们可以通过在客户端绑定 / etc/hosts 来模拟。

在客户端:

cat /etc/hosts
...
# 把域名 www.baidu.com 绑定到正向代理服务器 39.105.196.164
39.105.196.164 www.baidu.com
 
# 正常利用 curl 来访问 www.baidu.com 即可。
# curl https://www.baidu.com -svo /dev/null
* About to connect() to www.baidu.com port 443 (#0)
*   Trying 39.105.196.164...
* Connected to www.baidu.com (39.105.196.164) port 443 (#0)
* Initializing NSS with certpath: sql:/etc/pki/nssdb
*   CAfile: /etc/pki/tls/certs/ca-bundle.crt
  CApath: none
* SSL connection using TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
* Server certificate:
*     subject: CN=baidu.com,O="Beijing Baidu Netcom Science Technology Co., Ltd",OU=service operation department,L=beijing,ST=beijing,C=CN
*     start date: 5 月 09 01:22:02 2019 GMT
*     expire date: 6 月 25 05:31:02 2020 GMT
*     common name: baidu.com
*     issuer: CN=GlobalSign Organization Validation CA - SHA256 - G2,O=GlobalSign nv-sa,C=BE
> GET / HTTP/1.1
> User-Agent: curl/7.29.0
> Host: www.baidu.com
> Accept: */*
>
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Cache-Control: private, no-cache, no-store, proxy-revalidate, no-transform
< Connection: Keep-Alive
< Content-Length: 2443
< Content-Type: text/html
< Date: Fri, 21 Jun 2019 05:46:07 GMT
< Etag: "5886041d-98b"
< Last-Modified: Mon, 23 Jan 2017 13:24:45 GMT
< Pragma: no-cache
< Server: bfe/1.0.8.18
< Set-Cookie: BDORZ=27315; max-age=86400; domain=.baidu.com; path=/
<
{ [data not shown]
* Connection #0 to host www.baidu.com left intact



1.3.2.5. 常见问题

  1. 客户端手动设置代理导致访问不成功

    4 层正向代理是透传上层 HTTPS 流量, 不需要 HTTP CONNECT 来建立隧道, 也就是说不需要客户端设置 HTTP(S)代理。如果我们在客户端手动设置 HTTP(s)代理是否能访问成功呢? 我们可以用 curl -x 来设置代理为这个正向服务器访问测试, 看看结果:
# curl https://www.baidu.com -svo /dev/null -x 39.105.196.164:443
* About to connect() to proxy 39.105.196.164 port 443 (#0)
*   Trying 39.105.196.164...
* Connected to 39.105.196.164 (39.105.196.164) port 443 (#0)
* Establish HTTP proxy tunnel to www.baidu.com:443
> CONNECT www.baidu.com:443 HTTP/1.1
> Host: www.baidu.com:443
> User-Agent: curl/7.29.0
> Proxy-Connection: Keep-Alive
>
* Proxy CONNECT aborted
* Connection #0 to host 39.105.196.164 left intact

可以看到客户端试图于正向 NGINX 前建立 HTTP CONNECT tunnel, 但是由于 NGINX 是透传, 所以把 CONNECT 请求直接转发给了目的服务器。目的服务器不接受 CONNECT 方法, 所以最终出现 “Proxy CONNECT aborted”, 导致访问不成功。

  1. 客户端没有带 SNI 导致访问不成功

    上文提到用 NGINX stream 做正向代理的关键因素之一是利用 ngx_stream_ssl_preread_module 提取出 Client Hello 中的 SNI 字段。如果客户端客户端不携带 SNI 字段, 会造成代理服务器无法获知目的域名的情况, 导致访问不成功。

在透明代理模式下(用手动绑定 hosts 的方式模拟), 我们可以在客户端用 openssl 来模拟:

# openssl s_client -connect www.baidu.com:443 -msg
CONNECTED(00000003)
>>> TLS 1.2  [length 0005]
    16 03 01 01 1c
>>> TLS 1.2 Handshake [length 011c], ClientHello
    01 00 01 18 03 03 6b 2e 75 86 52 6c d5 a5 80 d7
    a4 61 65 6d 72 53 33 fb 33 f0 43 a3 aa c2 4a e3
    47 84 9f 69 8b d6 00 00 ac c0 30 c0 2c c0 28 c0
    24 c0 14 c0 0a 00 a5 00 a3 00 a1 00 9f 00 6b 00
    6a 00 69 00 68 00 39 00 38 00 37 00 36 00 88 00
    87 00 86 00 85 c0 32 c0 2e c0 2a c0 26 c0 0f c0
    05 00 9d 00 3d 00 35 00 84 c0 2f c0 2b c0 27 c0
    23 c0 13 c0 09 00 a4 00 a2 00 a0 00 9e 00 67 00
    40 00 3f 00 3e 00 33 00 32 00 31 00 30 00 9a 00
    99 00 98 00 97 00 45 00 44 00 43 00 42 c0 31 c0
    2d c0 29 c0 25 c0 0e c0 04 00 9c 00 3c 00 2f 00
    96 00 41 c0 12 c0 08 00 16 00 13 00 10 00 0d c0
    0d c0 03 00 0a 00 07 c0 11 c0 07 c0 0c c0 02 00
    05 00 04 00 ff 01 00 00 43 00 0b 00 04 03 00 01
    02 00 0a 00 0a 00 08 00 17 00 19 00 18 00 16 00
    23 00 00 00 0d 00 20 00 1e 06 01 06 02 06 03 05
    01 05 02 05 03 04 01 04 02 04 03 03 01 03 02 03
    03 02 01 02 02 02 03 00 0f 00 01 01
140285606590352:error:140790E5:SSL routines:ssl23_write:ssl handshake failure:s23_lib.c:177:
---
no peer certificate available
---
No client certificate CA names sent
---
SSL handshake has read 0 bytes and written 289 bytes
...

openssl s_client 默认不带 SNI, 可以看到上面的请求在 TLS/SSL 握手阶段, 发出 Client Hello 后就结束了。因为代理服务器不知道要把 Client Hello 往哪个目的域名转发。

如果用 openssl 带 servername 参数来指定 SNI, 则可以正常访问成功, 命令如下:

# openssl s_client -connect www.baidu.com:443 -servername www.baidu.com



1.4. 总结

本文总结了 NGINX 利用 HTTP CONNECT 隧道和 NGINX stream 两种方式做 HTTPS 正向代理的原理, 环境搭建, 使用场景和主要问题, 希望给大家在做各种场景的正向代理时提供参考。