正文从 用 Node.JS 写的流量转发服务器 开始,前面的内容可以跳过。
前言
最近在写一个 Node.JS 项目的时候,我遇到这样一个需求:
- 把客户端 (Client) 发往指定域名的 http 和 https 请求转发到我搭在 ubuntu (18.04 LTS) 上的 Node.JS 项目进行后续处理 (https 请求需要解密),发往其他域名的请求则不作干涉
几番探索后决定用 Node 写了一个简单的转发代理 (forward proxy), 搭了一个小轮子,专门做此用途。
探索过程
因为涉及到请求的重定向,一开始我考虑的是各种代理协议,粗略查了一下,ubuntu 上有各种代理程序可以用: squid, tinyproxy, simpleproxy 等等,涉及到 https 解密则甚至要用到 stunnel (后来才知道到我其实并不是要造一个加密的通道,所以并不需要 stunnel)。
于是先 apt install
了一个 squid + stunnel 的组合,分别启动,连接好端口以后,用 nginx 监听转发,然后 ufw 打开防火墙,这时我理想中的流程是这样的:
客户端
||
Nginx (公网 ip 监听与端口转发)
||
stunnel (https 解密)
||
squid (修改请求)
||
我的项目
然后开始看 /etc/squid3/squid.conf/
和 /etc/stunnel/stunnel.conf
,发现 squid 的设置文件贼长, nano
翻半天都没法翻完,而且关键词们都在 comment 里重复了好多次,^ + w
定位都要跳好多下…下载下来看发现有一千多行。最后按说明设置了规则,加入了自签名的 CA 证书以后,并没有实现想要的效果:我的项目收不到转发过来的请求,更不用说解密了。毕竟此处重点不在于弄清 squid 的用法,而在于把我需要的流量转到我的应用接收,必要的时候 (https 流量) 先进行解密。
遂咨询流光,描述了半天以后他明白过来:「你尝试的是反代 (reverse proxy),你这个需求正向代理 (forward proxy) 就可以了,也就是在 http(s) 包外面再套一层http(s)。」
接下来他提出一个本质解法:
「本质一点,客户端既然没 pin 证书的话,其实可以用 dnsmasq 把你服务器变成 DNS,然后直接把你需要的域名指向自己服务器。」
于是用了最基本的
sudo dnsmasq --no-resolv --address=/target.domain.com/[my_server_ip] --server=8.8.8.8
然后 ufw 开放 53 端口,客户端设备上把 dns 手动设为我服务器 ip,发现果然有效。于是问题解决(。
以上です。
然而并不是这样…
接下来让某人测试,发现路由器开了梯子的情况下这个就不管用了(废话,域名解析直接被梯子接管了…)。想要不受这个限制需要客户端设备上 pac。
于是还是得研究代理。
然后想到了 Nginx 的 proxy_pass
功能,进行试验:
server {
listen 3000;
listen 3001 ssl;
server_name my-node-project.example.com;
ssl_certificate /path_to_my_certificate/ssl.crt;
ssl_certificate_key /path_to_my_certificate/ssl.key;
location / {
# 我的 Node 项目监听 1919 端口
proxy_pass: http://127.0.0.1:1919;
}
}
简而言之,让 nginx 监听 http 和 https 请求,并对后者进行解密,把明文请求传给在本地 1919 端口监听的我的应用。
先试试看能不能用
在客户端设备上,启用 PAC 代理:
- 发往目标域名的 http 流量
PROXY
至我服务器 ip 的 3000 端口 - 发往目标域名的 https 流量
PROXY
至我服务器 ip 的 3001 端口
测试结果
http 流量能正常被我的应用接收并处理,https 的不行。
继续研究
研究发现 (流光曰),https 流量之所以出问题,是因为当我在客户端主机上将流量用代理规则 (而不是用 hosts 文件或者自定义 dns) 指向我的服务器时,客户端发出的是 HTTP CONNECT
请求,而这一请求是 https 会话特有的,相当于告诉服务器「我希望通过连接你提供的代理来访问我需要的资源」,但是 nginx 并不支持这一请求的处理。
查了一下,有 第三方 module 支持这个功能,具体实施起来得把补丁打进 nginx 源代码,然后配置 (./configure ...
),编译和安装 (make && make install
) 修改后的 nginx 可执行文件。
哆哆嗦嗦编译完了,不敢最后往系统里装,不然万一哪里出问题,最坏可能是一把梭重配服务器的结局 😅,这条路就此打住。
怎么办呢?
后续又试了一下 tinyproxy 和 simpleproxy,然后发现这俩的用途其实是 PAC,也就是告诉发往它们的请求「该去哪里找代理服务器」,而并不是直接把请求转交给我的 Node 应用来处理。(我没再往下研究了,可能是我没配对,配对了可能可以当流量转发工具用的。)
最后,绕了一大圈,发现,nodejs 直接就能处理 CONNECT 请求,然后 https 解密则仍可以交给 nginx 处理 (毕竟我有其他东西也用 nginx 管理)…于是有了下面的解法。
基于 Node.JS 的流量转发服务器
理一下逻辑,我们需要:
- 接收客户端发来的请求
- 如果是 http 请求的话,用其内容和参数直接向其目标服务器发起新的 http 请求 (
http.request
),并将请求结果返回给客户端 (pipe) - 如果是 https 请求的话,建立一个 socket,把 socket 的入口和客户端发来的 socket 出口接在一起 (pipe)。此处需要使用
net.connect
制造 socket
- 如果是 http 请求的话,用其内容和参数直接向其目标服务器发起新的 http 请求 (
先写 http 请求的处理
首先,需要用到 net
和 http
包,为了方便取得请求的端口信息,还使用了 url
包:
var net = require('net');
var http = require('http');
var url = require('url');
创建代理服务器变量, 其中 httpOptions
是在收到 http 流量的时候所使用的请求处理与衔接函数。(为了把变量声明放在最开头,httpOptions
没法使用 arrow function 方式来声明):
var proxyServer = http.createServer(httpOptions);
function httpOptions(clientReq, clientRes) {}
httpOptions
函数的具体功能是,用客户端的请求参数 (clientReq里的信息),向目标服务器发出请求 (http.connect
),并把得到的结果返回给客户端 (传给 clientRes),使用 pipe 进行传递:
function httpOptions(clientReq, clientRes) {
var reqUrl = url.parse(clientReq.url);
console.log('proxy for http request: ' + reqUrl.href);
var options = {
hostname: reqUrl.hostname, // 我的项目需求会把这里写成 localhost
port: reqUrl.port,
path: reqUrl.path,
method: clientReq.method,
headers: clientReq.headers
};
// create socket connection on behalf of client, then pipe the response to client response (pass it on)
var serverConnection = http.request(options, function (res) {
clientRes.writeHead(res.statusCode, res.headers)
res.pipe(clientRes);
});
clientReq.pipe(serverConnection);
clientReq.on('error', (e) => {
console.log('client socket error: ' + e);
});
serverConnection.on('error', (e) => {
console.log('server socket error: ' + e);
});
}
然后是比较麻烦的 https 请求处理
Node.JS http
部分的文档里有服务器针对 HTTP CONNECT
事件的 处理说明:
Class: http.Server
Event: 'connect'
Added in: v0.7.0
request <http.IncomingMessage>
socket <net.Socket>
head <Buffer>
说明原文是这样的:
Emitted each time a client requests an HTTP CONNECT
method. If this event is not listened for, then clients requesting a CONNECT
method will have their connections closed.
翻译过来:
每当客户端用 CONNECT 方法发来 HTTP 请求时,该事件会被触发 (广播)。如果这个事件没有被监听,发起 CONNECT
请求的客户端会关闭其连接。
同时,该文档里也写了当客户端发起 HTTP CONNECT
请求时,客户端对应的 处理说明 (此处原文有代码示例):
Class: http.clientRequest
Event: 'connect'
Added in: v0.7.0
request <http.IncomingMessage>
socket <net.Socket>
head <Buffer>
原文:
Emitted each time a server responds to a request with a CONNECT
method. If this event is not being listened for, clients receiving a CONNECT
method will have their connections closed.
翻译:
每当服务器答复 (响应) 了以 CONNECT
方法发出的请求时,该事件会被触发 (广播)。如果这个事件没有被监听,收到 CONNECT
回复的客户端会关闭其连接。
我要用的,就是服务器针对 CONNECT
的响应处理,用 on('event', callback)
里的 callback 函数来处理和转发请求,文档中提到的三个变量分别对应客户端发来的请求(request), 客户端提供的 socket,以及二进制数据缓存 (head):
proxyServer.on('connect', (clientReq, clientSocket, head) => {
// https proxy
});
通过事件监听传入的 clientReq.url 是不带协议前缀的,为了统一和方便记录(console.log
),并方便从中取得目标域名和端口,使用 url.parse()
将其转成 url
对象:
proxyServer.on('connect', (clientReq, clientSocket, head) => {
// https proxy
var reqUrl = url.parse('https://' + clientReq.url);
console.log('proxy for https request: ' + reqUrl.href + '(path encrypted by ssl)');
}
然后用 net.connect([options, ]callback)
建立和目标服务器之间的 socket:
var options = {
port: reqUrl.port,
host: reqUrl.hostname // 我的项目需求会把这里写成 localhost
};
// create socket connection for client, then pipe (redirect) it to client socket
var serverSocket = net.connect(options, () => {});
callback
的 body 里首先 clientSocket.write
告诉客户端「连接已经建立了」,返回 HTTP 200:
var serverSocket = net.connect(options, () => {
clientSocket.write('HTTP/' + clientReq.httpVersion + ' 200 Connection Established\r\n' +
'Proxy-agent: Node.js-Proxy\r\n\r\n',
'UTF-8',
() => {});
});
接下来在 clientSocket.write()
的 callback
里把 serverSocket 和 clientSocket 对接起来:
var serverSocket = net.connect(options, () => {
clientSocket.write(
'HTTP/' + clientReq.httpVersion + ' 200 Connection Established\r\n' +
'Proxy-agent: Node.js-Proxy\r\n\r\n',
'UTF-8',
() => {
// creating pipes in both ends
serverSocket.write(head);
serverSocket.pipe(clientSocket);
clientSocket.pipe(serverSocket);
});
});
然后设置一下错误应对:
clientSocket.on('error', (e) => {
console.log("client socket error: " + e);
serverSocket.end();
});
serverSocket.on('error', (e) => {
console.log("forward proxy server connection socket error: " + e);
clientSocket.end();
});
至此, https 的转发代理就设置完毕了。
整合
整合上述关于 http 和 https 的转发代理以后,最后加上针对客户端出错的处理:
proxyServer.on('clientError', (err, clientSocket) => {
console.log('client error: ' + err);
clientSocket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});
然后启动服务器,开始监听。(这里选的是 2560 端口,可以改成 0
至65535
之间的其他值,如果选用小于 1024
的端口,需要以 root 身份执行):
proxyServer.listen(2560);
同样在服务器上记得让防火墙允许 2560
端口的通信, 就实现了我需要的流量转发功能。
完整代码的 GitHub 链接 在此。
题外话
这里弄明白一件事,一般大家说的代理 (正向代理) 和反向代理,英文原文分别是 proxy (forward proxy) 和 reverse proxy,其实二者更准确的翻译应该是转发代理和逆向代理。原文中 forward 的意思其实和邮件转发时的 forward (fwd) 是相似的意思,意指把接收到的信息转达给另一个接收方。
与 redirect (重定向) 不同的是,forward 表示信息是在自己这里通过了一次:
source <-> forwarder <-> target
而 redirect 则是告诉信息源「你找错地方了,请去这个地址再看看」:
source -(1)-> forwarder
| ^ |
| \ | (address of new place to look up)
(3) \__(2)___|
|
|
--> address of new place
所以个人认为,直接根据不容易产生翻译误解的反代来类比翻译 forward proxy 为前向/正向代理,是有点问题的。