refactor(sniffing): split sniffer monolith into focused files, extract testable helpers

Split ~850-line sniffer.go into 6 focused files (http, h2, tls, ws,
rewrite, test), extract pure helpers (clampBodySize, normalizeHost,
effectiveReadTimeout), fix "DeafultSampleRate" typo, add doc comments
on all exported symbols, and add 22 unit tests.
This commit is contained in:
ginuerzh
2026-05-27 21:21:40 +08:00
parent 87b055a83b
commit 4f526c2aee
7 changed files with 1605 additions and 812 deletions
+282
View File
@@ -0,0 +1,282 @@
package sniffing
import (
"bufio"
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/observer/stats"
xbypass "github.com/go-gost/x/bypass"
xctx "github.com/go-gost/x/ctx"
xio "github.com/go-gost/x/internal/io"
xnet "github.com/go-gost/x/internal/net"
xhttp "github.com/go-gost/x/internal/net/http"
xstats "github.com/go-gost/x/observer/stats"
stats_wrapper "github.com/go-gost/x/observer/stats/wrapper"
xrecorder "github.com/go-gost/x/recorder"
)
// HandleHTTP sniffs and proxies an HTTP connection. It reads the initial
// request, applies bypass rules, and forwards traffic to the upstream.
func (h *Sniffer) HandleHTTP(ctx context.Context, network string, conn net.Conn, opts ...HandleOption) error {
var ho HandleOptions
for _, opt := range opts {
opt(&ho)
}
readTimeout := h.effectiveReadTimeout()
pStats := xstats.Stats{}
conn = stats_wrapper.WrapConn(conn, &pStats)
br := bufio.NewReader(conn)
req, err := http.ReadRequest(br)
if err != nil {
return err
}
log := ho.log
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpRequest(req, false)
log.Trace(string(dump))
}
ro := ho.recorderObject
ro.HTTP = &xrecorder.HTTPRecorderObject{
Host: req.Host,
Proto: req.Proto,
Scheme: req.URL.Scheme,
Method: req.Method,
URI: req.RequestURI,
Request: xrecorder.HTTPRequestRecorderObject{
ContentLength: req.ContentLength,
Header: req.Header.Clone(),
},
}
if clientIP := xhttp.GetClientIP(req); clientIP != nil {
ro.ClientIP = clientIP.String()
ctx = xctx.ContextWithSrcAddr(ctx, &net.TCPAddr{IP: clientIP})
}
// http/2
if req.Method == "PRI" && len(req.Header) == 0 && req.URL.Path == "*" && req.Proto == "HTTP/2.0" {
return h.serveH2(ctx, network, xnet.NewReadWriteConn(br, conn, conn), &ho)
}
host := normalizeHost(req.Host, "80")
if host != "" {
ro.Host = host
log = log.WithFields(map[string]any{
"host": host,
})
if ho.bypass != nil && ho.bypass.Contains(ctx, network, host, bypass.WithService(ho.service)) {
return xbypass.ErrBypass
}
}
dial := ho.dial
if dial == nil {
dial = (&net.Dialer{}).DialContext
}
cc, err := dial(ctx, network, host)
if err != nil {
return err
}
defer cc.Close()
log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
ro.Time = time.Time{}
shouldClose, err := h.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), cc, req, readTimeout, ro, &pStats, log)
if err != nil || shouldClose {
return err
}
for {
pStats.Reset()
req, err := http.ReadRequest(br)
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpRequest(req, false)
log.Trace(string(dump))
}
if shouldClose, err := h.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), cc, req, readTimeout, ro, &pStats, log); err != nil || shouldClose {
return err
}
}
}
// httpRoundTrip forwards a single HTTP request/response pair and records
// traffic metadata. Returns whether the connection should be closed.
func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser, req *http.Request, readTimeout time.Duration, ro *xrecorder.HandlerRecorderObject, pStats stats.Stats, log logger.Logger) (close bool, err error) {
close = true
ro2 := &xrecorder.HandlerRecorderObject{}
*ro2 = *ro
ro = ro2
ro.Time = time.Now()
log.Infof("%s <-> %s", ro.RemoteAddr, req.Host)
defer func() {
if err != nil {
ro.Err = err.Error()
}
ro.InputBytes = pStats.Get(stats.KindInputBytes)
ro.OutputBytes = pStats.Get(stats.KindOutputBytes)
ro.Duration = time.Since(ro.Time)
if rerr := ro.Record(ctx, h.Recorder); rerr != nil {
log.Errorf("record: %v", rerr)
}
log.WithFields(map[string]any{
"duration": time.Since(ro.Time),
"inputBytes": ro.InputBytes,
"outputBytes": ro.OutputBytes,
}).Infof("%s >-< %s", ro.RemoteAddr, req.Host)
}()
ro.HTTP = &xrecorder.HTTPRecorderObject{
Host: req.Host,
Proto: req.Proto,
Scheme: req.URL.Scheme,
Method: req.Method,
URI: req.RequestURI,
Request: xrecorder.HTTPRequestRecorderObject{
ContentLength: req.ContentLength,
Header: req.Header.Clone(),
},
}
// HTTP/1.0
if req.ProtoMajor == 1 && req.ProtoMinor == 0 {
if strings.ToLower(req.Header.Get("Connection")) == "keep-alive" {
req.Header.Del("Connection")
} else {
req.Header.Set("Connection", "close")
}
}
var reqBody *xhttp.Body
if bodySize := clampBodySize(h.RecorderOptions); bodySize > 0 && req.Body != nil {
reqBody = xhttp.NewBody(req.Body, bodySize)
req.Body = reqBody
}
err = req.Write(cc)
if reqBody != nil {
ro.HTTP.Request.Body = reqBody.Content()
ro.HTTP.Request.ContentLength = reqBody.Length()
}
if err != nil {
return
}
br := bufio.NewReader(cc)
var resp *http.Response
for {
xio.SetReadDeadline(cc, time.Now().Add(readTimeout))
resp, err = http.ReadResponse(br, req)
if err != nil {
err = wrapErr("read response", err)
return
}
if resp.StatusCode == http.StatusContinue {
resp.Write(rw)
resp.Body.Close()
continue
}
break
}
defer resp.Body.Close()
xio.SetReadDeadline(cc, time.Time{})
ro.HTTP.StatusCode = resp.StatusCode
ro.HTTP.Response.Header = resp.Header
ro.HTTP.Response.ContentLength = resp.ContentLength
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(resp, false)
log.Trace(string(dump))
}
if resp.StatusCode == http.StatusSwitchingProtocols {
h.handleUpgradeResponse(ctx, rw, cc, req, resp, ro, log)
return
}
// HTTP/1.0
if req.ProtoMajor == 1 && req.ProtoMinor == 0 {
if !resp.Close {
resp.Header.Set("Connection", "keep-alive")
}
resp.ProtoMajor = req.ProtoMajor
resp.ProtoMinor = req.ProtoMinor
}
var respBody *xhttp.Body
if bodySize := clampBodySize(h.RecorderOptions); bodySize > 0 {
respBody = xhttp.NewBody(resp.Body, bodySize)
resp.Body = respBody
}
err = resp.Write(rw)
if respBody != nil {
ro.HTTP.Response.Body = respBody.Content()
ro.HTTP.Response.ContentLength = respBody.Length()
}
if err != nil {
err = wrapErr("write response", err)
return
}
if resp.ContentLength >= 0 {
close = resp.Close
}
return
}
// wrapErr formats an error with a context message.
func wrapErr(msg string, err error) error {
return &wrappedErr{msg: msg, err: err}
}
type wrappedErr struct {
msg string
err error
}
func (e *wrappedErr) Error() string {
return e.msg + ": " + e.err.Error()
}
func (e *wrappedErr) Unwrap() error {
return e.err
}