1. 背景
本文主要介绍如何使用 nodejs 搭建 http(s)中转服务,本文所写内容仅以学习为目的,请勿作为其他用途。阅读本文之前,请先了解:
- 如何自建证书,可以参考这里
- 如何使用 nodejs 创建 http(s) 服务器
- 如何使用 nodejs 创建 tls 服务器
- http 协议的格式及请求类型
- http 请求数据体的格式(在转发请求的之前,需要解析获取完整的请求数据包,请尤其注意
transfer-encoding: chunked
格式的请求数据解析) - http 响应数据的压缩方式(在向真实的客户端发送数据的时候,尽量按照原本的
content-encoding
和transfer-encoding
所要求的数据格式将数据返回给客户端)
2. 实现步骤
2.1 http 中转思路
- 创建 http(s) 服务器作为 proxy server
- 监听 request 请求
- 通过 nodejs 网路请求相关的三方库(比如:axios、node-fetch、got 等)转发请求
- 获取到请求对应的数据之后将其返回给客户端即可
2.3 https 中转思路
- 创建 http(s) 服务器作为 proxy server
- 劫持所有请求到 proxy server
- 监听 connect 请求
- 解析 connect 请求,得到 host 和 port,使用 net 模块转发
- 将使用 net 转发时的 socket 与 connect 请求的客户端 socket 关联在一起即可实现请求中转
2.3 https 解密思路
- 创建 http(s) 服务器作为 proxy server
- 创建 tls 服务器,tls server
- 劫持所有请求到 proxy server
- 监听 connect 请求
- 将所有 connect 请求,全部发送到 tls server,请使用 net 模块转发,如果使用 tls 模块,将导致在 tls server 监听数据的时候,获取到的是 https 加密后的数据
- tls server 监听客户端转发过来的数据(需是解密之后的数据,使用 net 模块转发时,会自动解密),使用 nodejs 网路请求相关的三方库(比如:axios、node-fetch、got 等)转发请求到目标服务器
- 通过 nodejs 转发请求并取到数据之后,将其返回给 proxy server 的 socket,这样客户端便能拿到数据
- 客户端拿到了目标服务器的内容,然后正常显示其内容
2.4 局限
对于做了证书固定(SSL Pinnig)处理的 App,处理起来非常麻烦(对于此类情况,笔者放弃 https 解密)。关于安全相关的描述,请查阅下文第四节的内容。
3 常见问题
3.1 websocket
- 请先学习websocket 协议。
- 当我们设置浏览器的 proxy 为我们自己写的服务时,ws 协议将通过 connect 请求转发,此时 http server 的
upgrade
事件将不会触发,因此在 connect 请求里正常转发请求(直连模式)即可实现 ws 的转发。
3.2 二次转发(仅 http/https)
3.2.1 http
只需要使用常规的代理方式转发请求即可。
3.2.2 https
3.2.2.1 仅转发
- 请学习 connect 请求的格式。
- 获取 connect 请求的数据
- 与二级代理建立 socket 连接
- 单独组装 connect 请求,将其发送给 targetSockect(与二级代理建立连接时的 socket)
- 然后将真实的 clientSocket 和 targetSockect 关联起来(就是将 targetSockect 获取到的数据,原封不动转给 clientSocket)
- 在 targetSockect 的 end 事件触发的时候,关闭 clientSocket,至此,该 https 请求便已完成转发
3.2.2.2 转发+数据解密
请参见“2.3 https 解密思路”部分的内容,只是在转发请求的时候,使用代理发送请求即可。此时请注意存在“2.4 局限”和“4. 关于安全”里所描述的问题。
3.3 内容被截断
请关注 content-length 响应头,在执行解密操作时,可以将响应头里的 content-length 字段删除,以避免出现 content-length 错误的问题。
3.4 关于响应数据的压缩
请学习 zlib 库的使用方法。
示例:
const zlib = require('zlib')
async function compressResonpseData (contentEncoding, respBuffer) {
const NAME = 'compressResonpseData'
return new Promise((resolve, reject) => {
let zlibStream = null
const respDataStream = Readable.from([respBuffer])
let compressedData = Buffer.alloc(0)
const zlibCallback = err => {
if (err) return reject(`${NAME} 管道传送失败: ${err.message}`)
}
if (/\bdeflate\b/.test(contentEncoding)) {
zlibStream = pipeline(
respDataStream,
zlib.createDeflate(),
zlibCallback
)
} else if (/\bgzip\b/.test(contentEncoding)) {
zlibStream = pipeline(respDataStream, zlib.createGzip(), zlibCallback)
} else if (/\bbr\b/.test(contentEncoding)) {
zlibStream = pipeline(
respDataStream,
zlib.createBrotliCompress(),
zlibCallback
)
}
if (zlibStream) {
zlibStream
.on('data', data => {
compressedData = Buffer.concat([compressedData, data])
})
.on('end', () => {
resolve(compressedData)
})
} else {
resolve(respBuffer)
}
})
}
3.5 关于二级转发的限制
3.5.1 以下的三方库不支持 proxy
以下内容将列表不支持 proxy 或者对 proxy 的支持存在问题的库。
3.5.2 proxy 的组合方案
3.6 https 解密时,如何处理重定向
在使用
fetch
转发请求时,redirect
有两种模式,请使用manual
模式。
3.7 仅当使用转发时,状态码 403
当不使用转发时,请求正常;使用服务转发,提示 403 时,请检查
request header
,转发请求时请将request header
里的host
属性删除。
3.8 curl 可以正常请求,node-fetch 403
当使用 curl 时,请求正常;使用 node-fetch 转发,提示 403 时,请使用浏览器正常访问服务,检查是否是 http2(h2),node-fetch(笔者使用的是 v2.6.1)不支持 http2,可以使用node-libcurl、fetch-h2、got代替,此处推荐使用fetch-h2(因为它与 fetch 最接近,后续替换的成本最低,它也有局限性,具体请参见3.5.1)。
3.9 fetch-h2 Header guard error
类似于:
Header guard error: Cannot set forbidden header for requests (dnt)
之类的错误,请在转发的时候增加allowForbiddenHeaders: true
处理该问题。
4. 关于安全
4.1 防解密
以下仅介绍方案名称,关于方案的具体细节请自行查找资料学习。
- 证书固定 > 每次请求对比证书是否一致。
- 公钥固定 > 每次请求对比公钥是否一致。
- 双向认证
- 证书签名校验
- 使用自定义的加解密规则对数据做加解密处理
4.2 防篡改
- 对数据做签名