HTTP 代理原理及實現(一)

Web 代理是一種存在於網絡中間的實體,提供各式各樣的功能。現代網絡系統中,Web 代理無處不在。我之前有關 HTTP 的博文中,多次提到了代理對 HTTP 請求及響應的影響。今天這篇文章,我打算談談 HTTP 代理本身的一些原理,以及如何用 Node.js 快速實現代理。

HTTP 代理存在兩種形式,分別簡單介紹如下:

第一種是 RFC 7230 - HTTP/1.1: Message Syntax and Routing(即修訂後的 RFC 2616,HTTP/1.1 協議的第一部分)描述的普通代理。這種代理扮演的是「中間人」角色,對於連接到它的客戶端來說,它是服務端;對於要連接的服務端來說,它是客戶端。它就負責在兩端之間來回傳送 HTTP 報文。

第二種是 Tunneling TCP based protocols through Web proxy servers(通過 Web 代理服務器用隧道方式傳輸基於 TCP 的協議)描述的隧道代理。它通過 HTTP 協議正文部分(Body)完成通訊,以 HTTP 的方式實現任意基於 TCP 的應用層協議代理。這種代理使用 HTTP 的 CONNECT 方法建立連接,但 CONNECT 最開始並不是 RFC 2616 - HTTP/1.1 的一部分,直到 2014 年發佈的 HTTP/1.1 修訂版中,才增加了對 CONNECT 及隧道代理的描述,詳見 RFC 7231 - HTTP/1.1: Semantics and Content。實際上這種代理早就被廣泛實現。

本文描述的第一種代理,對應《HTTP 權威指南》一書中第六章「代理」;第二種代理,對應第八章「集成點:網關、隧道及中繼」中的 8.5 小節「隧道」。

普通代理

第一種 Web 代理原理特別簡單:

HTTP 客戶端向代理髮送請求報文,代理服務器需要正確地處理請求和連接(例如正確處理 Connection: keep-alive),同時向服務器發送請求,並將收到的響應轉發給客戶端。

下面這張圖片來自於《HTTP 權威指南》,直觀地展示了上述行爲:


假如我通過代理訪問 A 網站,對於 A 來說,它會把代理當做客戶端,完全察覺不到真正客戶端的存在,這實現了隱藏客戶端 IP 的目的。當然代理也可以修改 HTTP 請求頭部,通過 X-Forwarded-IP 這樣的自定義頭部告訴服務端真正的客戶端 IP。但服務器無法驗證這個自定義頭部真的是由代理添加,還是客戶端修改了請求頭,所以從 HTTP 頭部字段獲取 IP 時,需要格外小心。這部分內容可以參考我之前的《HTTP 請求頭中的 X-Forwarded-For》這篇文章。

給瀏覽器顯式的指定代理,需要手動修改瀏覽器或操作系統相關設置,或者指定 PAC(Proxy Auto-Configuration,自動配置代理)文件自動設置,還有些瀏覽器支持 WPAD(Web Proxy Autodiscovery Protocol,Web 代理自動發現協議)。顯式指定瀏覽器代理這種方式一般稱之爲正向代理,瀏覽器啓用正向代理後,會對 HTTP 請求報文做一些修改,來規避老舊代理服務器的一些問題,這部分內容可以參考我之前的《Http 請求頭中的 Proxy-Connection》這篇文章。

還有一種情況是訪問 A 網站時,實際上訪問的是代理,代理收到請求報文後,再向真正提供服務的服務器發起請求,並將響應轉發給瀏覽器。這種情況一般被稱之爲反向代理,它可以用來隱藏服務器 IP 及端口。一般使用反向代理後,需要通過修改 DNS 讓域名解析到代理服務器 IP,這時瀏覽器無法察覺到真正服務器的存在,當然也就不需要修改配置了。反向代理是 Web 系統最爲常見的一種部署方式,例如本博客就是使用 Nginx 的 proxy_pass 功能將瀏覽器請求轉發到背後的 Node.js 服務。

瞭解完第一種代理的基本原理後,我們用 Node.js 實現一下它。只包含核心邏輯的代碼如下:

JSvar http = require('http');
var net = require('net');
var url = require('url');

function request(cReq, cRes) {
    var u = url.parse(cReq.url);

    var options = {
        hostname : u.hostname, 
        port     : u.port || 80,
        path     : u.path,       
        method     : cReq.method,
        headers     : cReq.headers
    };

    var pReq = http.request(options, function(pRes) {
        cRes.writeHead(pRes.statusCode, pRes.headers);
        pRes.pipe(cRes);
    }).on('error', function(e) {
        cRes.end();
    });

    cReq.pipe(pReq);
}

http.createServer().on('request', request).listen(8888, '0.0.0.0');

以上代碼運行後,會在本地 8888 端口開啓 HTTP 代理服務,這個服務從請求報文中解析出請求 URL 和其他必要參數,新建到服務端的請求,並把代理收到的請求轉發給新建的請求,最後再把服務端響應返回給瀏覽器。修改瀏覽器的 HTTP 代理爲 127.0.0.1:8888 後再訪問 HTTP 網站,代理可以正常工作。

但是,使用我們這個代理服務後,HTTPS 網站完全無法訪問,這是爲什麼呢?答案很簡單,這個代理提供的是 HTTP 服務,根本沒辦法承載 HTTPS 服務。那麼是否把這個代理改爲 HTTPS 就可以了呢?顯然也不可以,因爲這種代理的本質是中間人,而 HTTPS 網站的證書認證機制是中間人劫持的剋星。普通的 HTTPS 服務中,服務端不驗證客戶端的證書,中間人可以作爲客戶端與服務端成功完成 TLS 握手但是中間人沒有證書私鑰,無論如何也無法僞造成服務端跟客戶端建立 TLS 連接。當然如果你擁有證書私鑰,代理證書對應的 HTTPS 網站當然就沒問題了。

HTTP 抓包神器 Fiddler 的工作原理也是在本地開啓 HTTP 代理服務,通過讓瀏覽器流量走這個代理,從而實現顯示和修改 HTTP 包的功能。如果要讓 Fiddler 解密 HTTPS 包的內容,需要先將它自帶的根證書導入到系統受信任的根證書列表中。一旦完成這一步,瀏覽器就會信任 Fiddler 後續的「僞造證書」,從而在瀏覽器和 Fiddler、Fiddler 和服務端之間都能成功建立 TLS 連接。而對於 Fiddler 這個節點來說,兩端的 TLS 流量都是可以解密的。

如果我們不導入根證書,Fiddler 的 HTTP 代理還能代理 HTTPS 流量麼?實踐證明,不導入根證書,Fiddler 只是無法解密 HTTPS 流量,HTTPS 網站還是可以正常訪問。這是如何做到的,這些 HTTPS 流量是否安全呢?這些問題將在下一節揭曉。

隧道代理

第二種 Web 代理的原理也很簡單:

HTTP 客戶端通過 CONNECT 方法請求隧道代理創建一條到達任意目的服務器和端口的 TCP 連接,並對客戶端和服務器之間的後繼數據進行盲轉發。

下面這張圖片同樣來自於《HTTP 權威指南》,直觀地展示了上述行爲:


假如我通過代理訪問 A 網站,瀏覽器首先通過 CONNECT 請求,讓代理創建一條到 A 網站的 TCP 連接;一旦 TCP 連接建好,代理無腦轉發後續流量即可。所以這種代理,理論上適用於任意基於 TCP 的應用層協議,HTTPS 網站使用的 TLS 協議當然也可以。這也是這種代理爲什麼被稱爲隧道的原因。對於 HTTPS 來說,客戶端透過代理直接跟服務端進行 TLS 握手協商密鑰,所以依然是安全的,下圖中的抓包信息顯示了這種場景:


可以看到,瀏覽器與代理進行 TCP 握手之後,發起了 CONNECT 請求,報文起始行如下:

CONNECT imququ.com:443 HTTP/1.1

對於 CONNECT 請求來說,只是用來讓代理創建 TCP 連接,所以只需要提供服務器域名及端口即可,並不需要具體的資源路徑。代理收到這樣的請求後,需要與服務端建立 TCP 連接,並響應給瀏覽器這樣一個 HTTP 報文:

HTTP/1.1 200 Connection Established

瀏覽器收到了這個響應報文,就可以認爲到服務端的 TCP 連接已經打通,後續直接往這個 TCP 連接寫協議數據即可。通過 Wireshark 的 Follow TCP Steam 功能,可以清楚地看到瀏覽器和代理之間的數據傳遞:


可以看到,瀏覽器建立到服務端 TCP 連接產生的 HTTP 往返,完全是明文,這也是爲什麼 CONNECT 請求只需要提供域名和端口:如果發送了完整 URL、Cookie 等信息,會被中間人一覽無餘,降低了 HTTPS 的安全性。HTTP 代理承載的 HTTPS 流量,應用數據要等到 TLS 握手成功之後通過 Application Data 協議傳輸,中間節點無法得知用於流量加密的 master-secret,無法解密數據。而 CONNECT 暴露的域名和端口,對於普通的 HTTPS 請求來說,中間人一樣可以拿到(IP 和端口很容易拿到,請求的域名可以通過 DNS Query 或者 TLS Client Hello 中的 Server Name Indication 拿到),所以這種方式並沒有增加不安全性。

瞭解完原理後,再用 Node.js 實現一個支持 CONNECT 的代理也很簡單。核心代碼如下:

JSvar http = require('http');
var net = require('net');
var url = require('url');

function connect(cReq, cSock) {
    var u = url.parse('http://' + cReq.url);

    var pSock = net.connect(u.port, u.hostname, function() {
        cSock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
        pSock.pipe(cSock);
    }).on('error', function(e) {
        cSock.end();
    });

    cSock.pipe(pSock);
}

http.createServer().on('connect', connect).listen(8888, '0.0.0.0');

以上代碼運行後,會在本地 8888 端口開啓 HTTP 代理服務,這個服務從 CONNECT 請求報文中解析出域名和端口,創建到服務端的 TCP 連接,並和 CONNECT 請求中的 TCP 連接串起來,最後再響應一個 Connection Established 響應。修改瀏覽器的 HTTP 代理爲127.0.0.1:8888 後再訪問 HTTPS 網站,代理可以正常工作。

最後,將兩種代理的實現代碼合二爲一,就可以得到全功能的 Proxy 程序了,全部代碼在 50 行以內(當然異常什麼的基本沒考慮,這是我博客代碼的一貫風格):

JSvar http = require('http');
var net = require('net');
var url = require('url');

function request(cReq, cRes) {
    var u = url.parse(cReq.url);

    var options = {
        hostname : u.hostname, 
        port     : u.port || 80,
        path     : u.path,       
        method     : cReq.method,
        headers     : cReq.headers
    };

    var pReq = http.request(options, function(pRes) {
        cRes.writeHead(pRes.statusCode, pRes.headers);
        pRes.pipe(cRes);
    }).on('error', function(e) {
        cRes.end();
    });

    cReq.pipe(pReq);
}

function connect(cReq, cSock) {
    var u = url.parse('http://' + cReq.url);

    var pSock = net.connect(u.port, u.hostname, function() {
        cSock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
        pSock.pipe(cSock);
    }).on('error', function(e) {
        cSock.end();
    });

    cSock.pipe(pSock);
}

http.createServer()
    .on('request', request)
    .on('connect', connect)
    .listen(8888, '0.0.0.0');

需要注意的是,大部分瀏覽器顯式配置了代理之後,只會讓 HTTPS 網站走隧道代理,這是因爲建立隧道需要耗費一次往返,能不用就儘量不用。但這並不代表 HTTP 請求不能走隧道代理,我們用 Node.js 寫段程序驗證下(先運行前面的代理服務):

JSvar http = require('http');

var options = {
    hostname : '127.0.0.1',
    port     : 8888,
    path     : 'imququ.com:80',
    method     : 'CONNECT'
};

var req = http.request(options);

req.on('connect', function(res, socket) {
    socket.write('GET / HTTP/1.1\r\n' +
                 'Host: imququ.com\r\n' +
                 'Connection: Close\r\n' +
                 '\r\n');

    socket.on('data', function(chunk) {
        console.log(chunk.toString());
    });

    socket.on('end', function() {
        console.log('socket end.');
    });
});

req.end();

這段代碼運行完,結果如下:

HTTP/1.1 301 Moved Permanently
Server: nginx
Date: Thu, 19 Nov 2015 15:57:47 GMT
Content-Type: text/html
Content-Length: 178
Connection: close
Location: https://imququ.com/

<html>
<head><title>301 Moved Permanently</title></head>
<body bgcolor="white">
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx</center>
</body>
</html>

socket end.

可以看到,通過 CONNECT 讓代理打開到目標服務器的 TCP 連接,用來承載 HTTP 流量也是完全沒問題的。

最後,HTTP 的認證機制可以跟代理配合使用,使得必須輸入正確的用戶名和密碼才能使用代理,這部分內容比較簡單,這裏略過。在本文第二部分,我打算談談如何把今天實現的代理改造爲 HTTPS 代理,也就是如何讓瀏覽器與代理之間的流量走 HTTPS 安全機制。

發佈了102 篇原創文章 · 獲贊 27 · 訪問量 29萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章