NGINX主要設(shè)計作為反向代理服務(wù)器,但隨著NGINX的發(fā)展,它同樣能作為正向代理的選項之一。正向代理本身并不復(fù)雜,而如何代理加密的HTTPS流量是正向代理需要解決的主要問題。本文將介紹利用NGINX來正向代理HTTPS流量兩種方案,及其使用場景和主要問題。
HTTP/HTTPS正向代理的分類
簡單介紹下正向代理的分類作為理解下文的背景知識:
按客戶端有無感知的分類
普通代理:在客戶端需要在瀏覽器中或者系統(tǒng)環(huán)境變量手動設(shè)置代理的地址和端口。如squid,在客戶端指定squid服務(wù)器IP和端口3128。
透明代理:客戶端不需要做任何代理設(shè)置,“代理”這個角色對于客戶端是透明的。如企業(yè)網(wǎng)絡(luò)鏈路中的Web Gateway設(shè)備。
按代理是否解密HTTPS的分類
隧道代理 :也就是透傳代理。代理服務(wù)器只是在TCP協(xié)議上透傳HTTPS流量,對于其代理的流量的具體內(nèi)容不解密不感知。客戶端和其訪問的目的服務(wù)器做直接TLS/SSL交互。本文中討論的NGINX代理方式屬于這種模式。
中間人(MITM, Man-in-the-Middle)代理:代理服務(wù)器解密HTTPS流量,對客戶端利用自簽名證書完成TLS/SSL握手,對目的服務(wù)器端完成正常TLS交互。在客戶端-代理-服務(wù)器的鏈路中建立兩段TLS/SSL會話。如Charles,簡單原理描述可以參考文章。
https://www.jianshu.com/p/405f9d76f8c4
注:這種情況客戶端在TLS握手階段實際上是拿到的代理服務(wù)器自己的自簽名證書,證書鏈的驗證默認(rèn)不成功,需要在客戶端信任代理自簽證書的Root CA證書。所以過程中是客戶端有感的。如果要做成無感的透明代理,需要向客戶端推送自建的Root CA證書,在企業(yè)內(nèi)部環(huán)境下是可實現(xiàn)的。
為什么正向代理處理HTTPS流量需要特殊處理?
作為反向代理時,代理服務(wù)器通常終結(jié) (terminate) HTTPS加密流量,再轉(zhuǎn)發(fā)給后端實例。HTTPS流量的加解密和認(rèn)證過程發(fā)生在客戶端和反向代理服務(wù)器之間。
而作為正向代理在處理客戶端發(fā)過來的流量時,HTTP加密封裝在了TLS/SSL中,代理服務(wù)器無法看到客戶端請求URL中想要訪問的域名,如下圖。所以代理HTTPS流量,相比于HTTP,需要做一些特殊處理。
NGINX的解決方案
根據(jù)前文中的分類方式,NGINX解決HTTPS代理的方式都屬于透傳(隧道)模式,即不解密不感知上層流量。具體的方式有如下7層和4層的兩類解決方案。
HTTP CONNECT隧道 (7層解決方案)
歷史背景
早在1998年,也就是TLS還沒有正式誕生的SSL時代,主導(dǎo)SSL協(xié)議的Netscape公司就提出了關(guān)于利用web代理來tunneling SSL流量的INTERNET-DRAFT。其核心思想就是利用HTTP CONNECT請求在客戶端和代理之間建立一個HTTP CONNECT Tunnel,在CONNECT請求中需要指定客戶端需要訪問的目的主機(jī)和端口。Draft中的原圖如下:
整個過程可以參考HTTP權(quán)威指南中的圖:
客戶端給代理服務(wù)器發(fā)送HTTP CONNECT請求。
代理服務(wù)器利用HTTP CONNECT請求中的主機(jī)和端口與目的服務(wù)器建立TCP連接。
代理服務(wù)器給客戶端返回HTTP 200響應(yīng)。
客戶端和代理服務(wù)器建立起HTTP CONNECT隧道,HTTPS流量到達(dá)代理服務(wù)器后,直接通過TCP透傳給遠(yuǎn)端目的服務(wù)器。代理服務(wù)器的角色是透傳HTTPS流量,并不需要解密HTTPS。
NGINX ngx_http_proxy_connect_module模塊
NGINX作為反向代理服務(wù)器,官方一直沒有支持HTTP CONNECT方法。但是基于NGINX的模塊化、可擴(kuò)展性好的特性,阿里的@chobits提供了ngx_http_proxy_connect_module模塊,來支持HTTP CONNECT方法,從而讓NGINX可以擴(kuò)展為正向代理。
環(huán)境搭建
以CentOS 7的環(huán)境為例。
1) 安裝
對于新安裝的環(huán)境,參考正常的安裝步驟和安裝這個模塊的步驟(https://github.com/chobits/ngx_http_proxy_connect_module),把對應(yīng)版本的patch打上之后,在configure的時候加上參數(shù)--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
對于已經(jīng)安裝編譯安裝完的環(huán)境,需要加入以上模塊,步驟如下:
#停止NGINX服務(wù)
#systemctlstopnginx
#備份原執(zhí)行文件
#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
#不要makeinstall
#將新生成的可執(zhí)行文件拷貝覆蓋原來的nginx執(zhí)行文件
#cpobjs/nginx/usr/local/nginx/sbin/nginx
#/usr/bin/nginx-V
nginxversion:nginx/1.16.0
builtbygcc4.8.520150623(RedHat4.8.5-36)(GCC)
builtwithOpenSSL1.0.2k-fips26Jan2017
TLSSNIsupportenabled
configurearguments:--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
2) nginx.conf文件配置
server{
listen443;
#dnsresolverusedbyforwardproxying
resolver114.114.114.114;
#forwardproxyforCONNECTrequest
proxy_connect;
proxy_connect_allow443;
proxy_connect_connect_timeout10s;
proxy_connect_read_timeout10s;
proxy_connect_send_timeout10s;
#forwardproxyfornon-CONNECTrequest
location/{
proxy_passhttp://$host;
proxy_set_headerHost$host;
}
}
使用場景
7層需要通過HTTP CONNECT來建立隧道,屬于客戶端有感知的普通代理方式,需要在客戶端手動配置HTTP(S)代理服務(wù)器IP和端口。在客戶端用curl 加-x參數(shù)訪問如下:
#curlhttps://www.baidu.com-svo/dev/null-x39.105.196.164:443
*Abouttoconnect()toproxy39.105.196.164port443(#0)
*Trying39.105.196.164...
*Connectedto39.105.196.164(39.105.196.164)port443(#0)
*EstablishHTTPproxytunneltowww.baidu.com:443
>CONNECTwww.baidu.com:443HTTP/1.1
>Host:www.baidu.com:443
>User-Agent:curl/7.29.0
>Proxy-Connection:Keep-Alive
>
<
*ProxyrepliedOKtoCONNECTrequest
*InitializingNSSwithcertpath:sql:/etc/pki/nssdb
*CAfile:/etc/pki/tls/certs/ca-bundle.crt
CApath:none
*SSLconnectionusingTLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
*Servercertificate:
*subject:CN=baidu.com,O="BeijingBaiduNetcomScienceTechnologyCo.,Ltd",OU=serviceoperationdepartment,L=beijing,ST=beijing,C=CN
...
>GET/HTTP/1.1
>User-Agent:curl/7.29.0
>Host:www.baidu.com
>Accept:*/*
>
...
{[datanotshown]
從上面-v參數(shù)打印出的細(xì)節(jié),可以看到客戶端先往代理服務(wù)器39.105.196.164建立了HTTP CONNECT隧道,代理回復(fù)HTTP/1.1 200 Connection Established后就開始交互TLS/SSL握手和流量了。
NGINX stream (4層解決方案)
既然是使用透傳上層流量的方法,那可不可做成“4層代理”,對TCP/UDP以上的協(xié)議實現(xiàn)徹底的透傳呢?答案是可以的。NGINX官方從1.9.0版本開始支持ngx_stream_core_module模塊,模塊默認(rèn)不build,需要configure時加上--with-stream選項來開啟。
問題
用NGINX stream在TCP層面上代理HTTPS流量肯定會遇到本文一開始提到的那個問題:代理服務(wù)器無法獲取客戶端想要訪問的目的域名。因為在TCP的層面獲取的信息僅限于IP和端口層面,沒有任何機(jī)會拿到域名信息。要拿到目的域名,必須要有拆上層報文獲取域名信息的能力,所以NGINX stream的方式不是完全嚴(yán)格意義上的4層代理,還是要略微借助些上層能力。
ngx_stream_ssl_preread_module模塊
要在不解密的情況下拿到HTTPS流量訪問的域名,只有利用TLS/SSL握手的第一個Client Hello報文中的擴(kuò)展地址SNI (Server Name Indication)來獲取。NGINX官方從1.11.5版本開始支持利用ngx_stream_ssl_preread_module模塊來獲得這個能力,模塊主要用于獲取Client Hello報文中的SNI和ALPN信息。對于4層正向代理來說,從Client Hello報文中提取SNI的能力是至關(guān)重要的,否則NGINX stream的解決方案無法成立。同時這也帶來了一個限制,要求所有客戶端都需要在TLS/SSL握手中帶上SNI字段,否則NGINX stream代理完全沒辦法知道客戶端需要訪問的目的域名。
環(huán)境搭建
1) 安裝
對于新安裝的環(huán)境,參考正常的安裝步驟,直接在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
對于已經(jīng)安裝編譯安裝完的環(huán)境,需要加入以上3個與stream相關(guān)的模塊,步驟如下:
#停止NGINX服務(wù)
#systemctlstopnginx
#備份原執(zhí)行文件
#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
#不要makeinstall
#將新生成的可執(zhí)行文件拷貝覆蓋原來的nginx執(zhí)行文件
#cpobjs/nginx/usr/local/nginx/sbin/nginx
#nginx-V
nginxversion:nginx/1.16.0
builtbygcc4.8.520150623(RedHat4.8.5-36)(GCC)
builtwithOpenSSL1.0.2k-fips26Jan2017
TLSSNIsupportenabled
configurearguments:--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
2) nginx.conf文件配置
NGINX stream與HTTP不同,需要在stream塊中進(jìn)行配置,但是指令參數(shù)與HTTP塊都是類似的,主要配置部分如下:
stream{
resolver114.114.114.114;
server{
listen443;
ssl_prereadon;
proxy_connect_timeout5s;
proxy_pass$ssl_preread_server_name:$server_port;
}
}
使用場景
對于4層正向代理,NGINX對上層流量基本上是透傳,也不需要HTTP CONNECT來建立隧道。適合于透明代理的模式,比如將訪問的域名利用DNS解定向到代理服務(wù)器。我們可以通過在客戶端綁定/etc/hosts來模擬。
在客戶端:
cat/etc/hosts
...
#把域名www.baidu.com綁定到正向代理服務(wù)器39.105.196.164
39.105.196.164www.baidu.com
#正常利用curl來訪問www.baidu.com即可。
#curlhttps://www.baidu.com-svo/dev/null
*Abouttoconnect()towww.baidu.comport443(#0)
*Trying39.105.196.164...
*Connectedtowww.baidu.com(39.105.196.164)port443(#0)
*InitializingNSSwithcertpath:sql:/etc/pki/nssdb
*CAfile:/etc/pki/tls/certs/ca-bundle.crt
CApath:none
*SSLconnectionusingTLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
*Servercertificate:
*subject:CN=baidu.com,O="BeijingBaiduNetcomScienceTechnologyCo.,Ltd",OU=serviceoperationdepartment,L=beijing,ST=beijing,C=CN
*startdate:5月0901:22:022019GMT
*expiredate:6月2505:31:022020GMT
*commonname:baidu.com
*issuer:CN=GlobalSignOrganizationValidationCA-SHA256-G2,O=GlobalSignnv-sa,C=BE
>GET/HTTP/1.1
>User-Agent:curl/7.29.0
>Host:www.baidu.com
>Accept:*/*
>
<
{[datanotshown]
*Connection#0tohostwww.baidu.comleftintact
常見問題
1) 客戶端手動設(shè)置代理導(dǎo)致訪問不成功
4層正向代理是透傳上層HTTPS流量,不需要HTTP CONNECT來建立隧道,也就是說不需要客戶端設(shè)置HTTP(S)代理。如果我們在客戶端手動設(shè)置HTTP(s)代理是否能訪問成功呢? 我們可以用curl -x來設(shè)置代理為這個正向服務(wù)器訪問測試,看看結(jié)果:
#curlhttps://www.baidu.com-svo/dev/null-x39.105.196.164:443
*Abouttoconnect()toproxy39.105.196.164port443(#0)
*Trying39.105.196.164...
*Connectedto39.105.196.164(39.105.196.164)port443(#0)
*EstablishHTTPproxytunneltowww.baidu.com:443
>CONNECTwww.baidu.com:443HTTP/1.1
>Host:www.baidu.com:443
>User-Agent:curl/7.29.0
>Proxy-Connection:Keep-Alive
>
*ProxyCONNECTaborted
*Connection#0tohost39.105.196.164leftintact
可以看到客戶端試圖于正向NGINX前建立HTTP CONNECT tunnel,但是由于NGINX是透傳,所以把CONNECT請求直接轉(zhuǎn)發(fā)給了目的服務(wù)器。目的服務(wù)器不接受CONNECT方法,所以最終出現(xiàn)"Proxy CONNECT aborted",導(dǎo)致訪問不成功。
2) 客戶端沒有帶SNI導(dǎo)致訪問不成功
上文提到用NGINX stream做正向代理的關(guān)鍵因素之一是利用ngx_stream_ssl_preread_module提取出Client Hello中的SNI字段。如果客戶端客戶端不攜帶SNI字段,會造成代理服務(wù)器無法獲知目的域名的情況,導(dǎo)致訪問不成功。
在透明代理模式下(用手動綁定hosts的方式模擬),我們可以在客戶端用openssl來模擬:
#openssls_client-connectwww.baidu.com:443-msg
CONNECTED(00000003)
>>>TLS1.2[length0005]
160301011c
>>>TLS1.2Handshake[length011c],ClientHello
0100011803036b2e7586526cd5a580d7
a461656d725333fb33f043a3aac24ae3
47849f698bd60000acc030c02cc028c0
24c014c00a00a500a300a1009f006b00
6a006900680039003800370036008800
8700860085c032c02ec02ac026c00fc0
05009d003d00350084c02fc02bc027c0
23c013c00900a400a200a0009e006700
40003f003e0033003200310030009a00
99009800970045004400430042c031c0
2dc029c025c00ec004009c003c002f00
960041c012c008001600130010000dc0
0dc003000a0007c011c007c00cc00200
05000400ff01000043000b0004030001
02000a000a0008001700190018001600
230000000d0020001e06010602060305
01050205030401040204030301030203
03020102020203000f000101
140285606590352:error:140790E5:SSLroutines:ssl23_write:sslhandshakefailure:s23_lib.c:177:
---
nopeercertificateavailable
---
NoclientcertificateCAnamessent
---
SSLhandshakehasread0bytesandwritten289bytes
...
openssl s_client默認(rèn)不帶SNI,可以看到上面的請求在TLS/SSL握手階段,發(fā)出Client Hello后就結(jié)束了。因為代理服務(wù)器不知道要把Client Hello往哪個目的域名轉(zhuǎn)發(fā)。
如果用openssl帶servername參數(shù)來指定SNI,則可以正常訪問成功,命令如下:
#openssls_client-connectwww.baidu.com:443-servernamewww.baidu.com
總結(jié)
本文總結(jié)了NGINX利用HTTP CONNECT隧道和NGINX stream兩種方式做HTTPS正向代理的原理,環(huán)境搭建,使用場景和主要問題,希望給大家在做各種場景的正向代理時提供參考。
作者:懷知