用 Node.JS 搭转发代理服务器 (Forward Proxy Server)

正文从 用 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 的流量转发服务器

理一下逻辑,我们需要:

  1. 接收客户端发来的请求
    • 如果是 http 请求的话,用其内容和参数直接向其目标服务器发起新的 http 请求 (http.request),并将请求结果返回给客户端 (pipe)
    • 如果是 https 请求的话,建立一个 socket,把 socket 的入口和客户端发来的 socket 出口接在一起 (pipe)。此处需要使用 net.connect 制造 socket

先写 http 请求的处理

首先,需要用到 nethttp 包,为了方便取得请求的端口信息,还使用了 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 端口,可以改成 065535之间的其他值,如果选用小于 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 为前向/正向代理,是有点问题的。