1. 项目概述
如果你是一名Go语言开发者,或者对网络协议、数据抓包和中间人代理技术感兴趣,那么你很可能遇到过这样的需求:想要清晰地看到你的应用在网络上到底发送和接收了什么数据,甚至想在数据流动的过程中“动点手脚”,比如修改请求参数、篡改响应内容,或者单纯地记录下所有经过的流量用于调试分析。市面上虽然有不少成熟的抓包工具,但它们要么是黑盒的图形化工具,定制化能力弱;要么是庞大的代理服务器,难以嵌入到你的Go应用中进行深度集成。今天要聊的这个项目——kxg3030/shermie-proxy,就是一个用纯Go语言实现的、轻量级但功能强大的代理数据抓包工具库。它最吸引我的地方在于,它不仅仅是一个代理,更是一个提供了丰富事件钩子的“可编程中间人”,让你能以一种非常优雅的方式,深入到HTTP、HTTPS、WebSocket、TCP乃至Socks5协议的数据流中。
简单来说,Shermie-Proxy是一个单端口的全能协议代理。你启动一个服务,监听一个端口(比如9090),然后将你的客户端(浏览器、手机App、其他服务)的代理设置指向它。接下来,所有经过这个端口的数据,无论是明文的HTTP、加密的HTTPS(它通过自动签发和信任根证书实现中间人解密)、双向的WebSocket,还是原始的TCP流、Socks5代理协议,都会被它识别、解析,并分门别类地通过事件回调暴露给你。你可以在这些回调函数里打印日志、分析内容、修改数据,或者根据自定义逻辑决定是否转发。这对于API调试、爬虫开发、安全测试、流量录制回放、甚至是构建一些需要深度介入网络通信的中间件(比如自动化测试Mock服务器、数据脱敏网关)来说,都是一个极其趁手的工具。
2. 核心设计思路与架构解析
2.1 为什么选择Go语言实现?
在深入代码之前,我们先聊聊技术选型。作者选择用Go来实现这样一个代理工具,我认为是经过深思熟虑的。首先,网络编程是Go的“看家本领”,其标准库net、net/http等提供了高性能、易用的原语,goroutine的并发模型使得处理海量并发连接变得简单而高效,这对于一个代理服务器来说是至关重要的基础。其次,Go编译后是单个静态二进制文件,部署和分发极其方便,没有复杂的运行时依赖,这符合工具类软件的定位。最后,Go的强类型和清晰的错误处理机制,使得构建一个稳定、可靠的网络中间件变得更加可控,减少了内存泄漏和并发陷阱的风险。相比于用Python或Node.js实现的类似工具,Go版本的Shermie-Proxy在性能和资源占用上通常更有优势,尤其是在需要长时间运行或处理高并发流量的场景下。
2.2 单端口多协议的设计哲学
Shermie-Proxy一个非常巧妙的设计是“单端口多协议”。传统的方案可能需要为HTTP、SOCKS5等不同协议分别监听不同的端口,或者在客户端连接时进行复杂的协议协商。而Shermie-Proxy的做法更聪明:它只监听一个TCP端口。当一个新的客户端连接建立时,服务器会先读取连接上的前几个字节(通常是协议握手阶段的数据),然后根据这些字节的特征(即协议指纹)来自动判断客户端想要使用哪种协议。
这个过程在源码中通常体现为一个协议分发器(Dispatcher)。例如,客户端发送的第一个字节如果是0x05,那么它很可能是一个SOCKS5代理请求;如果前几个字符是GET、POST等HTTP方法,那么这就是一个HTTP请求;如果是CONNECT方法,则可能是HTTPS隧道建立的开始;对于WebSocket,则是在HTTP Upgrade请求之后进行判断。对于无法识别的协议,或者配置了--to参数的TCP转发模式,则将其视为普通的TCP字节流进行处理。这种设计极大地简化了客户端的配置,也使得服务端的架构更加统一和清晰。
2.3 事件驱动与可插拔架构
项目的核心价值不在于其代理转发的基本功能,而在于其“事件驱动”和“可插拔”的架构。我们看提供的示例代码,它没有把代理逻辑写死,而是定义了一系列的事件回调接口(OnHttpRequestEvent,OnTcpClientStreamEvent等)。这种设计模式将框架的“骨架”(连接管理、协议解析、数据转发)和业务的“血肉”(数据检查、修改、记录)彻底解耦。
作为使用者,你不需要关心TCP连接是如何复用的、TLS证书是如何自动签发的、WebSocket帧是如何组装的。你只需要在你关心的事件回调里,编写你的业务逻辑。比如,在OnHttpRequestEvent中,你可以解析*http.Request对象,修改其Header或Body,然后调用resolve函数将修改后的数据继续转发;你也可以选择返回false,中断默认的转发流程,自己通过conn向客户端写入自定义响应。这种灵活性是预制好的图形化抓包软件无法比拟的。它使得Shermie-Proxy从一个工具进化成了一个开发框架,你可以基于它快速构建出符合自己特定需求的网络中间件。
2.4 HTTPS中间人解密原理
支持HTTPS抓包是此类工具的必备能力,也是技术难点。Shermie-Proxy实现这一功能的原理是标准的“中间人攻击”(Man-in-the-Middle, MITM)技术,但用于合法的调试目的。其核心步骤通常如下:
- 根证书初始化:在服务启动时(
init函数中),会调用Core.NewCertificate().Init()。这个方法会检查本地是否已经存在一个由Shermie-Proxy自己生成的CA(证书颁发机构)根证书和私钥。如果不存在,则动态创建一套。这套根证书是整个HTTPS解密信任链的起点。 - 客户端连接与CONNECT请求:当客户端(如浏览器)配置代理访问
https://example.com时,它会先向代理服务器(Shermie-Proxy)发送一个HTTPCONNECT请求,请求连接到example.com:443。 - 建立隧道与SSL握手拦截:代理服务器收到
CONNECT请求后,会与目标服务器example.com:443建立TCP连接。此时,关键的一步来了:代理服务器不会简单地将客户端与目标服务器的TLS握手数据原样转发,而是会“冒充”目标服务器,与客户端进行TLS握手。 - 动态签发站点证书:在握手过程中,客户端会发送它想要访问的域名(SNI扩展)。Shermie-Proxy会利用第一步生成的CA根证书私钥,即时地为这个域名签发一张“伪造”的SSL证书。这张证书的Subject(主题)等信息与真实证书相似,但签名者是我们自己的CA。
- 完成握手与双向解密:客户端收到这张伪造的证书。如果客户端信任了我们预先安装好的CA根证书(这就是为什么有时需要手动信任一个根证书),那么TLS握手就会成功。至此,客户端与代理之间建立了一条TLS连接,代理与目标服务器之间也建立了一条TLS连接。代理处于中间位置,可以完全解密客户端发来的数据(看到明文请求),也能解密目标服务器返回的数据(看到明文响应),并在事件回调中暴露给我们。
- 数据转发与事件触发:解密后的HTTP请求和响应,会分别触发
OnHttpRequestEvent和OnHttpResponseEvent,就像处理普通HTTP一样。修改后的数据会被重新加密,发往另一端。
这个过程在代码中通常被封装在Core包下的MITM或TLS相关模块里,对使用者是透明的。你只需要确保在测试设备上安装并信任了Shermie-Proxy生成的根证书(通常是一个.pem或.crt文件),就能在回调中看到明文的HTTPS流量了。
3. 环境准备与快速上手
3.1 安装与基础依赖
使用Shermie-Proxy的第一步是获取它。由于它是一个Go模块,安装非常简单。确保你的本地环境已经安装了Go(1.16及以上版本推荐),然后执行:
go get github.com/kxg3030/shermie-proxy这条命令会将这个库下载到你的$GOPATH/pkg/mod目录下。接下来,你可以创建一个新的Go项目,或者在你的现有项目中引入它:
import "github.com/kxg3030/shermie-proxy/Core" import "github.com/kxg3030/shermie-proxy/Log" // ... 其他需要的子包项目本身除了Go标准库外,没有引入特别复杂的外部依赖,这保证了其轻量和易于构建。你可以直接使用go build或go run来编译运行你的代理程序。
3.2 编写一个最简单的代理服务器
让我们从最基础的版本开始,创建一个main.go文件。这个版本只启动代理,不做任何数据拦截,仅仅作为一个透明的转发代理。
package main import ( "flag" "github.com/kxg3030/shermie-proxy/Core" "github.com/kxg3030/shermie-proxy/Log" ) func init() { // 初始化日志器,默认会输出到控制台 Log.NewLogger().Init() // 初始化根证书,这是HTTPS抓包功能的前提 err := Core.NewCertificate().Init() if err != nil { Log.Log.Fatal("Failed to init certificate: ", err) } } func main() { // 解析命令行参数 port := flag.String("port", "9090", "The port for proxy to listen on") flag.Parse() // 创建代理服务器实例,只指定监听端口,其他参数用默认值 server := Core.NewProxyServer(*port, true, "", "") // 启动服务器,这是一个阻塞调用 err := server.Start() if err != nil { Log.Log.Fatal("Failed to start proxy server: ", err) } }保存后,在终端运行:
go run main.go --port=8888现在,一个监听在本机8888端口的代理服务器就运行起来了。你可以将系统或浏览器的代理设置为127.0.0.1:8888,然后访问任何HTTP网站(如http://httpbin.org/get),流量就会经过你的代理。此时,代理只是默默地转发数据,控制台除了启动日志外,不会有其他输出。但这已经验证了基础代理功能是正常的。
3.3 关键命令行参数详解
从示例代码中我们可以看到,NewProxyServer函数和flag解析接收了几个参数,它们决定了代理服务器的行为模式:
--port:这是唯一必须指定的参数(除非你在代码里写死)。它定义了代理服务器监听的本机端口。就像上面例子中的8888。--nagle:布尔值,默认为true。它控制是否启用Nagle算法。这是一个TCP层的优化算法,旨在减少小数据包的数量,将它们合并成更大的包再发送,可以提高网络利用率。但在交互性要求极高的场景(如远程桌面、游戏)中,它可能会增加延迟。对于普通的HTTP代理,保持默认的true即可;如果你代理的是对延迟敏感的游戏或实时通信协议,可以尝试设置为false。--proxy:字符串,默认为空。这个参数赋予了Shermie-Proxy“链式代理”的能力。如果你想让你的代理服务器将收到的流量,再通过另一个上游代理(比如公司网关、或者一个海外代理)发送出去,就可以在这里指定上游代理的地址。格式通常是host:port,例如--proxy="192.168.1.100:8080"。需要注意的是,目前仅支持一级上游代理,且上游代理需要支持TCP转发。--to:字符串,默认为空。这个参数用于纯TCP端口转发模式。当你不希望代理去解析HTTP等应用层协议,而只是简单地将某个端口的所有原始TCP流量转发到另一个目标服务器时,就使用这个参数。例如,你本机有一个服务在3000端口,你想通过代理的9090端口暴露出去,可以运行go run main.go --port=9090 --to=127.0.0.1:3000。此时,连接到localhost:9090的所有数据都会被原样转发到localhost:3000。这个模式不会触发HTTP/WebSocket等事件回调。
理解这些参数是灵活运用Shermie-Proxy的关键。--proxy和--to参数定义了代理的两种不同工作模式:前者是“客户端->本代理->上游代理->目标”,后者是“客户端->本代理->目标(纯TCP)”。
4. 核心功能实战:拦截与修改数据
现在,让我们进入最有趣的部分:如何利用事件回调来拦截和修改数据。我们将基于前面的简单示例,逐步添加功能。
4.1 拦截并打印所有HTTP(S)请求
首先,我们实现一个最常用的功能:记录所有经过代理的HTTP和HTTPS请求的URL和方法。
package main import ( "flag" "github.com/kxg3030/shermie-proxy/Core" "github.com/kxg3030/shermie-proxy/Log" "net/http" ) func init() { Log.NewLogger().Init() err := Core.NewCertificate().Init() if err != nil { Log.Log.Fatal("Failed to init certificate: ", err) } } func main() { port := flag.String("port", "9090", "listen port") flag.Parse() server := Core.NewProxyServer(*port, true, "", "") // 注册HTTP请求事件 server.OnHttpRequestEvent = func(message []byte, request *http.Request, resolve Core.ResolveHttpRequest, conn net.Conn) bool { // 打印客户端地址、请求方法和URL Log.Log.Printf("[%s] %s %s", conn.RemoteAddr().String(), request.Method, request.URL.String()) // 调用resolve函数,将原始的(或可被修改的)消息和请求对象继续传递下去 // 这里我们传入原始的message和request,表示不修改 resolve(message, request) // 返回true,告知框架继续正常的响应流程 return true } // 暂时不处理响应和其他协议 _ = server.Start() }运行这个代理,然后用浏览器访问几个网站。你会在控制台看到类似这样的输出:
[127.0.0.1:63452] GET https://www.google.com/ [127.0.0.1:63452] GET https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_92x30dp.png这说明代理已经成功捕获了HTTPS请求(URL是https://开头),并且*http.Request对象已经被成功解析,包含了所有标准HTTP信息,如Header、Host等。
4.2 修改HTTP请求与响应
仅仅记录不够,我们试试修改数据。假设我们想给所有发出的HTTP请求都加上一个自定义的HeaderX-Proxy-By: Shermie,并且修改某个特定API的响应体。
server.OnHttpRequestEvent = func(message []byte, request *http.Request, resolve Core.ResolveHttpRequest, conn net.Conn) bool { // 1. 添加自定义Header request.Header.Set("X-Proxy-By", "Shermie-Proxy") // 2. 打印一下添加后的Header(可选) Log.Log.Printf("Added header to request to %s", request.URL.Host) // 3. 注意:修改了request对象后,需要将其传回给resolve。 // 框架内部会用这个修改后的request重新序列化并发送。 resolve(message, request) return true } server.OnHttpResponseEvent = func(body []byte, response *http.Response, resolve Core.ResolveHttpResponse, conn net.Conn) bool { // 检查响应URL,针对特定API进行修改 if response.Request != nil && response.Request.URL.Path == "/api/user/info" { Log.Log.Println("Intercepting response for /api/user/info") // 假设原响应是JSON,我们想修改其中的一个字段。 // 注意:这里需要根据实际的Content-Type和编码(如gzip)进行处理,示例简化。 // 这里我们简单地在body前添加一段注释,实际中应解析JSON。 modifiedBody := []byte("// Modified by Shermie Proxy\n" + string(body)) // 调用resolve,传入修改后的body和原始的response对象。 // 框架会处理Content-Length等Header的更新。 resolve(modifiedBody, response) return true } // 对于其他响应,原样转发 resolve(body, response) return true }重要提示:在OnHttpResponseEvent中修改body时,必须注意响应的Content-Encoding(如gzip)和Content-Length。如果你修改了body内容,特别是长度发生变化时,必须同步更新response.Header中的Content-Length,或者将其删除(让Go的http库自动计算)。更稳妥的做法是,在resolve函数内部,框架应该会处理这些细节。但如果你遇到响应乱码或截断的问题,需要检查是否是gzip压缩体被修改但未重新压缩导致的。
4.3 处理WebSocket消息
WebSocket的拦截与HTTP类似,但消息是以帧(Frame)的形式流动的。Shermie-Proxy将建立连接后的双向数据流通过OnWsRequestEvent(客户端->服务器)和OnWsResponseEvent(服务器->客户端)暴露出来。
server.OnWsRequestEvent = func(msgType int, message []byte, resolve Core.ResolveWs, conn net.Conn) error { // msgType是websocket帧类型,如 TextMessage (1), BinaryMessage (2), CloseMessage (8)等 if msgType == websocket.TextMessage { Log.Log.Printf("WS Client -> Server [Text]: %s", string(message)) // 可以在这里修改message // modifiedMsg := []byte("PREFIX:" + string(message)) // return resolve(msgType, modifiedMsg) } else if msgType == websocket.BinaryMessage { Log.Log.Printf("WS Client -> Server [Binary], length: %d", len(message)) } // 默认原样转发 return resolve(msgType, message) } server.OnWsResponseEvent = func(msgType int, message []byte, resolve Core.ResolveWs, conn net.Conn) error { if msgType == websocket.TextMessage { Log.Log.Printf("WS Server -> Client [Text]: %s", string(message)) } return resolve(msgType, message) }WebSocket常用于实时应用,拦截其消息可以用于分析通信协议、模拟消息或进行安全测试。
4.4 处理原始TCP流与Socks5协议
对于非HTTP/WebSocket的TCP流量(比如自定义的二进制协议),或者当客户端使用Socks5代理协议连接时,可以使用对应的事件。
- TCP流事件:当协议无法识别或处于纯TCP转发模式时,
OnTcpClientStreamEvent(客户端发来的数据)和OnTcpServerStreamEvent(服务器返回的数据)会被触发。这里你拿到的是原始的[]byte。server.OnTcpClientStreamEvent = func(message []byte, resolve Core.ResolveTcp, conn net.Conn) (int, error) { Log.Log.Printf("TCP Client -> Server, %d bytes: %x", len(message), message[:min(20, len(message))]) // 打印前20字节的十六进制 return resolve(message) // 返回写入的字节数和错误 } - Socks5事件:当客户端使用Socks5协议连接时,
OnSocks5RequestEvent和OnSocks5ResponseEvent会在握手和连接阶段被触发。你可以在这里获取Socks5客户端想要连接的目标地址和端口。server.OnSocks5RequestEvent = func(message []byte, resolve Core.ResolveSocks5, conn net.Conn) (int, error) { // message包含Socks5请求数据,可以解析出目标地址 // 例如,可以在这里记录或过滤某些目标地址的连接 Log.Log.Println("Socks5 connection request received.") return resolve(message) }
4.5 连接生命周期事件
除了数据事件,还有两个连接生命周期事件非常有用:
// 当有新的TCP连接建立时触发 server.OnTcpConnectEvent = func(conn net.Conn) { Log.Log.Printf("New connection from: %s", conn.RemoteAddr().String()) // 可以在这里将conn存入一个map,用于后续管理或统计 } // 当TCP连接关闭时触发 server.OnTcpCloseEvent = func(conn net.Conn) { Log.Log.Printf("Connection closed: %s", conn.RemoteAddr().String()) // 可以在这里从map中移除conn,清理资源 }这两个事件可以帮你做连接数统计、超时管理、资源清理等工作。
5. 高级应用场景与实战技巧
掌握了基本的数据拦截修改后,我们可以探索一些更高级的、贴近实际开发的用法。
5.1 构建一个API调试与Mock服务器
在前后端分离开发中,前端经常需要后端API未完成时进行联调。你可以用Shermie-Proxy快速搭建一个Mock服务器。
- 拦截特定API路径:在
OnHttpRequestEvent中,检查request.URL.Path。 - 返回模拟数据:直接构造一个
http.Response,写入模拟的JSON数据,然后通过conn直接写回给客户端,并返回false以阻止框架继续转发请求到真实服务器。server.OnHttpRequestEvent = func(message []byte, request *http.Request, resolve Core.ResolveHttpRequest, conn net.Conn) bool { if request.URL.Path == "/api/mock/user" && request.Method == "GET" { Log.Log.Println("Mocking response for /api/mock/user") // 构造模拟响应 mockData := `{"id": 123, "name": "Mock User", "status": "active"}` response := &http.Response{ StatusCode: 200, ProtoMajor: 1, ProtoMinor: 1, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(mockData)), Request: request, } // 重要:必须设置Content-Length response.ContentLength = int64(len(mockData)) // 将响应写回客户端 err := response.Write(conn) if err != nil { Log.Log.Println("Failed to write mock response:", err) } // 返回false,告知框架我们已经处理完毕,不要继续后续流程 return false } // 其他请求正常处理 resolve(message, request) return true } - 动态匹配规则:你可以将Mock规则(URL路径、方法、响应数据)配置在文件或数据库中,使Mock服务器更灵活。
5.2 实现请求/响应的录制与回放
这对于自动化测试和问题复现非常有用。
- 录制:在
OnHttpRequestEvent和OnHttpResponseEvent中,将request和response对象(连同Body)序列化后保存到文件或数据库。注意存储时可能需要关联同一个会话的请求和响应(可以通过一个唯一的连接ID或请求ID)。type RecordedSession struct { ID string Request *http.Request ReqBody []byte Response *http.Response RespBody []byte Timestamp time.Time } // 在事件中,将数据填充到结构体并存储 - 回放:实现一个回放模式。当代理启动在回放模式时,
OnHttpRequestEvent不再转发请求,而是根据当前请求的特征(如URL、Method、Header)去录制库中查找匹配的历史记录,然后将历史响应直接返回给客户端。这可以用于性能测试(模拟真实响应)或离线调试。
5.3 性能测试与流量复制
你可以稍微修改代理,使其在将请求转发给真实服务的同时,也异步地发送一份到另一个测试服务器(比如一个正在做压测的服务)。这就是简单的流量复制(Traffic Shadowing)。切记要在独立的goroutine中进行,避免阻塞主请求链路。
server.OnHttpRequestEvent = func(message []byte, request *http.Request, resolve Core.ResolveHttpRequest, conn net.Conn) bool { // 主流程:正常转发 go func() { // 异步复制流量 shadowConn, err := net.Dial("tcp", "shadow-server:8080") if err != nil { Log.Log.Println("Failed to connect to shadow server:", err) return } defer shadowConn.Close() // 需要重新构造请求并发送,注意处理Body的复制和读取 // 这是一个简化示例,实际实现需要考虑Body的读取和重置 shadowReq := request.Clone(context.Background()) err = shadowReq.Write(shadowConn) if err != nil { Log.Log.Println("Failed to send to shadow server:", err) } }() resolve(message, request) return true }5.4 安全审计与敏感信息过滤
在OnHttpResponseEvent中,你可以扫描响应体,查找可能泄露的敏感信息,如身份证号、手机号、邮箱、密钥等,并进行打码或报警。
server.OnHttpResponseEvent = func(body []byte, response *http.Response, resolve Core.ResolveHttpResponse, conn net.Conn) bool { mimeType := response.Header.Get("Content-Type") if strings.Contains(mimeType, "application/json") { var result map[string]interface{} if json.Unmarshal(body, &result) == nil { // 假设我们想过滤掉名为"password"的字段 if pwd, ok := result["password"].(string); ok && pwd != "" { result["password"] = "***FILTERED***" newBody, _ := json.Marshal(result) resolve(newBody, response) Log.Log.Println("Filtered password field in response from:", response.Request.URL) return true } } } // 也可以使用正则表达式扫描文本响应 resolve(body, response) return true }6. 常见问题、故障排查与性能调优
在实际使用中,你可能会遇到一些问题。这里总结一些常见的情况和解决思路。
6.1 HTTPS抓包失败或证书警告
这是最常见的问题。现象可能是浏览器显示“您的连接不是私密连接”,或者根本抓不到HTTPS请求。
- 原因与解决:
- 根证书未安装/未信任:这是根本原因。Shermie-Proxy启动时在
init中生成的根证书(通常位于用户目录下的某个路径,如~/.shermie-proxy或程序运行目录)需要被操作系统或浏览器信任。- 查找证书:查看程序启动日志,或修改代码打印出证书路径。
- 安装证书:将生成的
.crt或.pem文件导入到系统的“受信任的根证书颁发机构”存储中。具体步骤因操作系统和浏览器而异。
- 证书缓存:即使安装了证书,浏览器或系统可能缓存了旧的证书信息。尝试清除浏览器SSL状态缓存,或重启浏览器。
- HSTS(HTTP严格传输安全):某些网站(如
google.com,github.com)启用了HSTS,强制浏览器只使用HTTPS连接,并且可能禁止不信任的证书。对于这类站点,常规的MITM代理可能失效。在开发测试时,可以暂时访问非HSTS的站点,或使用--proxy参数将流量导向一个可以忽略证书错误的上级代理(不推荐生产环境使用)。
- 根证书未安装/未信任:这是根本原因。Shermie-Proxy启动时在
6.2 代理后部分应用无法联网或速度慢
- 检查代理设置:确保客户端(浏览器、系统设置、
curl -x等)正确配置了代理服务器的IP和端口。 - 检查防火墙:确保运行代理服务器的机器的防火墙允许入站连接到你指定的
--port。 - Nagle算法:如果代理的是大量小数据包且对延迟敏感的应用(如在线游戏、实时音视频),尝试将
--nagle=false,禁用Nagle算法,可能改善延迟。 - 上游代理问题:如果使用了
--proxy,请检查上游代理是否稳定且可达。 - DNS解析:某些应用可能不使用代理进行DNS查询,导致解析失败。确保客户端配置了正确的DNS,或尝试在代理代码中处理DNS(更复杂)。
6.3 内存泄漏与连接数增长
代理服务器长时间运行,如果连接没有正确关闭,可能会导致内存泄漏或文件描述符耗尽。
- 利用生命周期事件:确保在
OnTcpCloseEvent中清理为该连接分配的资源(如map中的引用)。 - 设置超时:Go的
net.Conn可以设置读写超时。虽然Shermie-Proxy框架可能已经做了处理,但在自己操作conn时要注意。conn.SetReadDeadline(time.Now().Add(30 * time.Second)) conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) - 监控:在
OnTcpConnectEvent和OnTcpCloseEvent中增加计数,定期打印活跃连接数,观察是否稳定。
6.4 修改响应后内容乱码或截断
这通常发生在修改了经过gzip压缩的响应体时。
- 问题分析:服务器返回的响应头
Content-Encoding: gzip,Body是压缩后的数据。如果你直接修改压缩后的[]byte,然后转发,客户端解压时会失败。如果你先解压、修改、再压缩,但忘记更新Content-Length头,也会出问题。 - 解决方案:在
OnHttpResponseEvent中,先检查response.Header.Get("Content-Encoding")。- 如果是
gzip,需要使用compress/gzip包先解压body,修改明文数据,再用gzip压缩回去,然后用新的body调用resolve。同时,最好删除response.Header中的Content-Length,让http库自动计算。 - 框架的
resolve函数可能会自动处理这些,但文档未明确说明。最稳妥的方式是自己处理压缩/解压逻辑。示例代码:
import "compress/gzip" import "bytes" server.OnHttpResponseEvent = func(body []byte, response *http.Response, resolve Core.ResolveHttpResponse, conn net.Conn) bool { // 检查是否为gzip压缩 if strings.Contains(response.Header.Get("Content-Encoding"), "gzip") { reader, err := gzip.NewReader(bytes.NewReader(body)) if err != nil { Log.Log.Println("Failed to create gzip reader:", err) resolve(body, response) // 出错则原样转发 return true } defer reader.Close() decompressedBody, err := io.ReadAll(reader) if err != nil { Log.Log.Println("Failed to decompress:", err) resolve(body, response) return true } // 在这里修改 decompressedBody (明文) // modifiedPlainBody := modify(decompressedBody) // 重新压缩 var buf bytes.Buffer gzWriter := gzip.NewWriter(&buf) if _, err := gzWriter.Write(decompressedBody); err != nil { Log.Log.Println("Failed to compress:", err) resolve(body, response) return true } gzWriter.Close() newBody := buf.Bytes() // 删除旧的Content-Length,让底层库或resolve函数处理 response.Header.Del("Content-Length") resolve(newBody, response) return true } // 非压缩响应,直接处理 resolve(body, response) return true } - 如果是
6.5 性能调优建议
- 连接复用:项目TODO中提到了“tcp connection multiplexing”。在高并发场景下,为每个请求创建新的到目标服务器的TCP连接开销很大。你可以考虑在框架外自己实现一个简单的连接池,在
OnTcpCloseEvent中并非立即关闭连接,而是放入池中等待复用(需要注意协议兼容性和连接状态)。 - 异步处理:在事件回调中,如果进行耗时的操作(如写入慢速磁盘、调用外部API),务必使用goroutine异步执行,避免阻塞数据转发的主流程。
- 减少内存分配:在频繁调用的回调函数中(如处理每个TCP数据包),尽量避免创建大量临时
[]byte切片。可以使用sync.Pool来缓存一些缓冲区。 - 日志级别:生产环境或高性能场景下,将日志输出级别调高(如果支持),或减少不必要的日志打印,特别是打印整个请求/响应体,I/O开销巨大。
7. 项目局限性与扩展思考
Shermie-Proxy作为一个轻量级库,功能强大,但也有其局限性,了解这些能帮助你在合适的场景使用它,并知道如何扩展。
- 协议支持深度:它自动识别主流协议,但对于一些变种或非标准协议(如HTTP/2、QUIC),可能无法正确识别和处理。对于HTTP/2,由于其多路复用等特性,在TCP流层面进行拦截修改会比HTTP/1.1复杂得多。
- 性能与稳定性:虽然Go本身性能不错,但作为一个单进程代理,在极端高并发(如数十万连接)下可能会成为瓶颈。对于生产级流量转发,通常需要更专业的代理软件(如Nginx, Envoy)或集群化部署。
- 上游代理限制:目前仅支持一级TCP上游代理,且上游代理需要支持
CONNECT等方法。对于需要认证的上游代理,可能需要修改源码来支持。 - 配置化:当前的事件回调需要写死在代码里并重新编译。可以将其改进为通过配置文件或插件机制来动态加载处理逻辑,增加灵活性。
- 管理界面:缺乏一个Web管理界面来查看实时流量、动态配置规则、管理证书等。可以作为一个扩展方向,用Go的模板或前端框架构建一个控制台。
尽管有这些局限,Shermie-Proxy在开发、测试、调试、以及构建特定中间件场景下,其简单、直接、高可编程性的特点,使其成为一个不可多得的利器。它更像是一把手术刀,让你可以精准地解剖和干预网络流量,而不是一个面面俱到的重型器械。理解其原理,善用其事件钩子,你就能在Go生态的网络编程中解决很多实际问题。