Web 代理是一種存在於網絡中間的實體,提供各式各樣的功能。現代網絡系統中,Web 代理無處不在。我以前有關 HTTP 的博文中,屢次提到了代理對 HTTP 請求及響應的影響。今天這篇文章,我打算談談 HTTP 代理自己的一些原理,以及如何用 Node.js 快速實現代理。web
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 代理原理特別簡單:app
下面這張圖片來自於《HTTP 權威指南》,直觀地展現了上述行爲:post
假如我經過代理訪問 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 權威指南》,直觀地展現了上述行爲:
假如我經過代理訪問 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 安全機制。注:已經寫完了,點這裏查看。
本文連接:https://imququ.com/post/web-proxy.html,參與評論 »
--EOF--