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

Split ~1250-line sniffer.go into sniffer_http.go (HTTP handling, dial, round-trip),
sniffer_tls.go (TLS MITM, termination), sniffer_h2.go (HTTP/2), sniffer_ws.go
(WebSocket frame copy/record), and sniffer_rewrite.go (upgrade, body rewrite).

Extract clampBodySize, normalizeHost, effectiveReadTimeout as package-level
functions to simplify testing. Add 22 tests covering pure functions, option
setters, HTTP proxy integration, WebSocket frame copy, and edge cases.
This commit is contained in:
ginuerzh
2026-05-26 22:48:27 +08:00
parent 92564a0c8c
commit aba7e2a256
7 changed files with 1930 additions and 1116 deletions
File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
package forwarder
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"time"
"github.com/go-gost/core/logger"
"github.com/go-gost/core/recorder"
xhttp "github.com/go-gost/x/internal/net/http"
xrecorder "github.com/go-gost/x/recorder"
"golang.org/x/net/http2"
)
// serveH2 handles HTTP/2 connections by reading the client preface and
// proxying requests through an http2.Transport.
func (h *Sniffer) serveH2(ctx context.Context, conn net.Conn, ho *HandleOptions) error {
const expectedBody = "SM\r\n\r\n"
buf := make([]byte, len(expectedBody))
n, err := io.ReadFull(conn, buf)
if err != nil {
return fmt.Errorf("h2: error reading client preface: %s", err)
}
if string(buf[:n]) != expectedBody {
return errors.New("h2: invalid client preface")
}
ro := ho.recorderObject
log := ho.log
ro.Time = time.Time{}
tr := &http2.Transport{
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
node, cc, err := h.dialTLS(ctx, addr, ho)
if err != nil {
return nil, err
}
log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
log.Debugf("connected to node %s(%s)", node.Name, node.Addr)
return cc, nil
},
}
defer tr.CloseIdleConnections()
(&http2.Server{}).ServeConn(conn, &http2.ServeConnOpts{
Context: ctx,
SawClientPreface: true,
Handler: &h2Handler{
transport: tr,
recorder: h.Recorder,
recorderOptions: h.RecorderOptions,
recorderObject: ro,
log: log,
},
})
return nil
}
// h2Handler is an http.Handler that proxies HTTP/2 requests through an
// http2.Transport while recording request and response metadata.
type h2Handler struct {
transport http.RoundTripper
recorder recorder.Recorder
recorderOptions *recorder.Options
recorderObject *xrecorder.HandlerRecorderObject
log logger.Logger
}
func (h *h2Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log := h.log
ro := &xrecorder.HandlerRecorderObject{}
*ro = *h.recorderObject
ro.Time = time.Now()
var err error
log.Infof("%s <-> %s", ro.RemoteAddr, r.Host)
defer func() {
ro.Duration = time.Since(ro.Time)
if err != nil {
ro.Err = err.Error()
}
if rerr := ro.Record(r.Context(), h.recorder); rerr != nil {
log.Errorf("record: %v", rerr)
}
log.WithFields(map[string]any{
"duration": time.Since(ro.Time),
}).Infof("%s >-< %s", ro.RemoteAddr, r.Host)
}()
if clientIP := xhttp.GetClientIP(r); clientIP != nil {
ro.ClientIP = clientIP.String()
}
ro.HTTP = &xrecorder.HTTPRecorderObject{
Host: r.Host,
Proto: r.Proto,
Scheme: "https",
Method: r.Method,
URI: r.RequestURI,
Request: xrecorder.HTTPRequestRecorderObject{
ContentLength: r.ContentLength,
Header: r.Header.Clone(),
},
}
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpRequest(r, false)
log.Trace(string(dump))
}
url := r.URL
url.Scheme = "https"
url.Host = r.Host
req := &http.Request{
Method: r.Method,
URL: url,
Host: r.Host,
Header: r.Header,
Body: r.Body,
ContentLength: r.ContentLength,
Trailer: r.Trailer,
}
var reqBody *xhttp.Body
if bodySize := clampBodySize(h.recorderOptions); bodySize > 0 && req.Body != nil {
reqBody = xhttp.NewBody(req.Body, bodySize)
req.Body = reqBody
}
resp, respErr := h.transport.RoundTrip(req.WithContext(r.Context()))
if reqBody != nil {
ro.HTTP.Request.Body = reqBody.Content()
ro.HTTP.Request.ContentLength = reqBody.Length()
}
err = respErr
if err != nil {
log.Error(err)
w.WriteHeader(http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
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))
}
h.setHeader(w, resp.Header)
w.WriteHeader(resp.StatusCode)
if bodySize := clampBodySize(h.recorderOptions); bodySize > 0 {
respBody := xhttp.NewBody(resp.Body, bodySize)
resp.Body = respBody
_, _ = io.Copy(w, resp.Body)
ro.HTTP.Response.Body = respBody.Content()
ro.HTTP.Response.ContentLength = respBody.Length()
} else {
_, _ = io.Copy(w, resp.Body)
}
}
func (h *h2Handler) setHeader(w http.ResponseWriter, header http.Header) {
for k, v := range header {
for i := range v {
w.Header().Add(k, v[i])
}
}
}
+414
View File
@@ -0,0 +1,414 @@
package forwarder
import (
"bufio"
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
"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"
"github.com/go-gost/x/internal/util/sniffing"
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, performs node selection via the configured hop, and forwards the
// request with HTTP keep-alive support.
func (h *Sniffer) HandleHTTP(ctx context.Context, conn net.Conn, opts ...HandleOption) error {
var ho HandleOptions
for _, opt := range opts {
opt(&ho)
}
ho.readTimeout = h.effectiveReadTimeout(&ho)
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 {
clientAddr := &net.TCPAddr{IP: clientIP}
ro.ClientAddr = clientAddr.String()
ctx = xctx.ContextWithSrcAddr(ctx, clientAddr)
}
// http/2
if req.Method == "PRI" && len(req.Header) == 0 && req.URL.Path == "*" && req.Proto == "HTTP/2.0" {
return h.serveH2(ctx, xnet.NewReadWriteConn(br, conn, conn), &ho)
}
node, cc, err := h.dial(ctx, conn, req, &ho)
if err != nil {
return err
}
defer cc.Close()
log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
log.Debugf("connected to node %s(%s)", node.Name, node.Addr)
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, node, req, &pStats, &ho)
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, node, req, &pStats, &ho); err != nil || shouldClose {
return err
}
}
}
// dial selects a node and establishes a connection for an HTTP request.
func (h *Sniffer) dial(ctx context.Context, conn net.Conn, req *http.Request, ho *HandleOptions) (node *chain.Node, cc net.Conn, err error) {
dial := ho.dial
if dial == nil {
dial = (&net.Dialer{}).DialContext
}
if node = ho.node; node != nil {
cc, err = dial(ctx, "tcp", node.Addr)
return
}
ro := ho.recorderObject
res := &http.Response{
ProtoMajor: req.ProtoMajor,
ProtoMinor: req.ProtoMinor,
Header: http.Header{},
StatusCode: http.StatusServiceUnavailable,
}
host := normalizeHost(req.Host, "80")
if host != "" {
ro.Host = host
ho.log = ho.log.WithFields(map[string]any{
"host": host,
})
if ho.bypass != nil &&
ho.bypass.Contains(ctx, "tcp", host,
bypass.WithService(ho.service),
bypass.WithPathOption(req.RequestURI)) {
ho.log.Debugf("bypass: %s %s", host, req.RequestURI)
res.StatusCode = http.StatusForbidden
ro.HTTP.StatusCode = res.StatusCode
if werr := res.Write(conn); werr != nil {
ho.log.Warnf("write bypass response: %v", werr)
}
return nil, nil, xbypass.ErrBypass
}
}
node = &chain.Node{}
if ho.hop != nil {
var clientIP net.IP
if clientAddr, _ := net.ResolveTCPAddr("tcp", ro.ClientAddr); clientAddr != nil {
clientIP = clientAddr.IP
}
node = ho.hop.Select(ctx,
hop.ClientIPSelectOption(clientIP),
hop.ProtocolSelectOption(sniffing.ProtoHTTP),
hop.HostSelectOption(host),
hop.MethodSelectOption(req.Method),
hop.PathSelectOption(req.URL.Path),
hop.QuerySelectOption(req.URL.Query()),
hop.HeaderSelectOption(req.Header),
)
}
if node == nil {
ho.log.Warnf("node for %s not found", host)
res.StatusCode = http.StatusBadGateway
ro.HTTP.StatusCode = res.StatusCode
if werr := res.Write(conn); werr != nil {
ho.log.Warnf("write error response: %v", werr)
}
return nil, nil, errors.New("node not available")
}
if node.Addr == "" {
node = &chain.Node{
Name: node.Name,
Addr: host,
}
}
ro.Host = node.Addr
ho.log = ho.log.WithFields(map[string]any{
"node": node.Name,
"dst": node.Addr,
})
ho.log.Debugf("find node for host %s -> %s(%s)", host, node.Name, node.Addr)
cc, err = dial(ctx, "tcp", node.Addr)
if err != nil {
if marker := node.Marker(); marker != nil {
marker.Mark()
}
ho.log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err)
if werr := res.Write(conn); werr != nil {
ho.log.Warnf("write error response: %v", werr)
}
return
}
if marker := node.Marker(); marker != nil {
marker.Reset()
}
cc = tlsWrapConn(cc, node.Options().TLS)
return
}
// 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, node *chain.Node, req *http.Request, pStats stats.Stats, ho *HandleOptions) (shouldClose bool, err error) {
shouldClose = true
log := ho.log
ro := &xrecorder.HandlerRecorderObject{}
*ro = *ho.recorderObject
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(),
},
}
res := &http.Response{
ProtoMajor: req.ProtoMajor,
ProtoMinor: req.ProtoMinor,
Header: http.Header{},
StatusCode: http.StatusServiceUnavailable,
}
// 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 responseHeader map[string]string
var respBodyRewrites []chain.HTTPBodyRewriteSettings
if httpSettings := node.Options().HTTP; httpSettings != nil {
if auther := httpSettings.Auther; auther != nil {
username, password, _ := req.BasicAuth()
id, ok := auther.Authenticate(ctx, username, password, auth.WithService(ho.service))
if !ok {
res.StatusCode = http.StatusUnauthorized
ro.HTTP.StatusCode = res.StatusCode
res.Header.Set("WWW-Authenticate", "Basic")
log.Warnf("node %s(%s) 401 unauthorized", node.Name, node.Addr)
res.Write(rw)
err = errors.New("unauthorized")
return
}
ctx = xctx.ContextWithClientID(ctx, xctx.ClientID(id))
}
if httpSettings.Host != "" {
req.Host = httpSettings.Host
}
for k, v := range httpSettings.RequestHeader {
req.Header.Set(k, v)
}
for _, re := range httpSettings.RewriteURL {
if re.Pattern.MatchString(req.URL.Path) {
if s := re.Pattern.ReplaceAllString(req.URL.Path, re.Replacement); s != "" {
req.URL.Path = s
break
}
}
}
responseHeader = httpSettings.ResponseHeader
respBodyRewrites = httpSettings.RewriteResponseBody
}
if bodySize := clampBodySize(h.RecorderOptions); bodySize > 0 && req.Body != nil {
reqBody := xhttp.NewBody(req.Body, bodySize)
req.Body = reqBody
err = req.Write(cc)
ro.HTTP.Request.Body = reqBody.Content()
ro.HTTP.Request.ContentLength = reqBody.Length()
} else {
err = req.Write(cc)
}
if err != nil {
res.Write(rw)
return
}
br := bufio.NewReader(cc)
var resp *http.Response
for {
xio.SetReadDeadline(cc, time.Now().Add(ho.readTimeout))
resp, err = http.ReadResponse(br, req)
if err != nil {
log.Errorf("read response: %v", err)
res.Write(rw)
return
}
if resp.StatusCode == http.StatusContinue {
resp.Write(rw)
resp.Body.Close()
continue
}
break
}
defer resp.Body.Close()
xio.SetReadDeadline(cc, time.Time{})
if len(responseHeader) > 0 {
if resp.Header == nil {
resp.Header = http.Header{}
}
for k, v := range responseHeader {
resp.Header.Set(k, v)
}
}
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
}
if !ho.httpKeepalive {
resp.Header.Set("Connection", "close")
}
if err = rewriteRespBody(resp, respBodyRewrites...); err != nil {
log.Errorf("rewrite body: %v", err)
return
}
if bodySize := clampBodySize(h.RecorderOptions); bodySize > 0 {
respBody := xhttp.NewBody(resp.Body, bodySize)
resp.Body = respBody
err = resp.Write(rw)
ro.HTTP.Response.Body = respBody.Content()
ro.HTTP.Response.ContentLength = respBody.Length()
} else {
err = resp.Write(rw)
}
if err != nil {
log.Errorf("write response: %v", err)
return
}
if resp.ContentLength >= 0 {
shouldClose = resp.Close
}
return
}
@@ -0,0 +1,92 @@
package forwarder
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/logger"
xnet "github.com/go-gost/x/internal/net"
xrecorder "github.com/go-gost/x/recorder"
"golang.org/x/net/http/httpguts"
)
func upgradeType(h http.Header) string {
if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
return ""
}
return h.Get("Upgrade")
}
func (h *Sniffer) handleUpgradeResponse(ctx context.Context, rw, cc io.ReadWriteCloser, req *http.Request, res *http.Response, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
reqUpType := upgradeType(req.Header)
resUpType := upgradeType(res.Header)
if !strings.EqualFold(reqUpType, resUpType) {
return fmt.Errorf("backend tried to switch protocol %q when %q was requested", resUpType, reqUpType)
}
res.Body = nil
if err := res.Write(rw); err != nil {
return fmt.Errorf("response write: %v", err)
}
if reqUpType == "websocket" && h.Websocket {
return h.sniffingWebsocketFrame(ctx, rw, cc, ro, log)
}
return xnet.Pipe(ctx, rw, cc)
}
func rewriteRespBody(resp *http.Response, rewrites ...chain.HTTPBodyRewriteSettings) error {
if resp == nil || len(rewrites) == 0 || resp.ContentLength <= 0 {
return nil
}
if encoding := resp.Header.Get("Content-Encoding"); encoding != "" {
return nil
}
body, err := drainBody(resp.Body)
if err != nil || body == nil {
return err
}
contentType, _, _ := strings.Cut(resp.Header.Get("Content-Type"), ";")
for _, rewrite := range rewrites {
rewriteType := rewrite.Type
if rewriteType == "" {
rewriteType = "text/html"
}
if rewriteType != "*" && !strings.Contains(rewriteType, contentType) {
continue
}
if rewrite.Pattern != nil {
body = rewrite.Pattern.ReplaceAll(body, rewrite.Replacement)
}
}
resp.Body = io.NopCloser(bytes.NewReader(body))
resp.ContentLength = int64(len(body))
return nil
}
func drainBody(b io.ReadCloser) (body []byte, err error) {
if b == nil || b == http.NoBody {
return nil, nil
}
var buf bytes.Buffer
if _, err = buf.ReadFrom(b); err != nil {
return nil, err
}
if err = b.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+719
View File
@@ -0,0 +1,719 @@
package forwarder
import (
"bufio"
"bytes"
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
"github.com/go-gost/core/recorder"
xlogger "github.com/go-gost/x/logger"
xrecorder "github.com/go-gost/x/recorder"
)
// =============================================================================
// Test Helpers (local mocks)
// =============================================================================
type noopRecorder struct{}
func (n *noopRecorder) Record(_ context.Context, _ []byte, _ ...recorder.RecordOption) error {
return nil
}
// mockBypass is a configurable bypass.Bypass for tests.
type mockBypass struct {
contains bool
whitelist bool
}
func (m *mockBypass) Contains(_ context.Context, _, _ string, _ ...bypass.Option) bool {
return m.contains
}
func (m *mockBypass) IsWhitelist() bool { return m.whitelist }
// =============================================================================
// Pure Function Tests
// =============================================================================
func TestClampBodySize(t *testing.T) {
tests := []struct {
name string
opts *recorder.Options
want int
}{
{"nil opts", nil, 0},
{"HTTPBody disabled", &recorder.Options{HTTPBody: false, MaxBodySize: 1000}, 0},
{"zero MaxBodySize defaults to defaultBodySize", &recorder.Options{HTTPBody: true, MaxBodySize: 0}, defaultBodySize},
{"negative MaxBodySize defaults", &recorder.Options{HTTPBody: true, MaxBodySize: -1}, defaultBodySize},
{"valid MaxBodySize within bounds", &recorder.Options{HTTPBody: true, MaxBodySize: 1024}, 1024},
{"MaxBodySize capped at maxBodySize", &recorder.Options{HTTPBody: true, MaxBodySize: 10 * 1024 * 1024}, maxBodySize},
{"exact maxBodySize", &recorder.Options{HTTPBody: true, MaxBodySize: maxBodySize}, maxBodySize},
{"exact defaultBodySize", &recorder.Options{HTTPBody: true, MaxBodySize: defaultBodySize}, defaultBodySize},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := clampBodySize(tt.opts); got != tt.want {
t.Errorf("clampBodySize() = %d, want %d", got, tt.want)
}
})
}
}
func TestNormalizeHost(t *testing.T) {
tests := []struct {
name string
host string
defaultPort string
want string
}{
{"empty host", "", "443", ""},
{"host with port", "example.com:8080", "443", "example.com:8080"},
{"host without port, default 80", "example.com", "80", "example.com:80"},
{"host without port, default 443", "example.com", "443", "example.com:443"},
{"IPv4 with port", "127.0.0.1:9090", "80", "127.0.0.1:9090"},
{"IPv4 without port", "127.0.0.1", "80", "127.0.0.1:80"},
{"IPv6 bracketed with port", "[::1]:8080", "443", "[::1]:8080"},
{"IPv6 bracketed without port", "[::1]", "443", "[::1]:443"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeHost(tt.host, tt.defaultPort)
if got != tt.want {
t.Errorf("normalizeHost(%q, %q) = %q, want %q", tt.host, tt.defaultPort, got, tt.want)
}
})
}
}
func TestEffectiveReadTimeout(t *testing.T) {
tests := []struct {
name string
snifferTO time.Duration
hoTO time.Duration
want time.Duration
}{
{"both zero defaults to DefaultReadTimeout", 0, 0, DefaultReadTimeout},
{"sniffer timeout only", 10 * time.Second, 0, 10 * time.Second},
{"ho timeout only", 0, 5 * time.Second, 5 * time.Second},
{"ho timeout takes precedence", 10 * time.Second, 3 * time.Second, 3 * time.Second},
{"sniffer timeout takes precedence when ho is zero", 15 * time.Second, 0, 15 * time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Sniffer{ReadTimeout: tt.snifferTO}
ho := &HandleOptions{readTimeout: tt.hoTO}
got := h.effectiveReadTimeout(ho)
if got != tt.want {
t.Errorf("effectiveReadTimeout() = %v, want %v", got, tt.want)
}
})
}
}
func TestUpgradeType(t *testing.T) {
tests := []struct {
name string
header http.Header
want string
}{
{
"websocket upgrade",
http.Header{"Connection": {"Upgrade"}, "Upgrade": {"websocket"}},
"websocket",
},
{
"h2c upgrade",
http.Header{"Connection": {"Upgrade"}, "Upgrade": {"h2c"}},
"h2c",
},
{
"no upgrade header",
http.Header{"Connection": {"keep-alive"}},
"",
},
{
"empty header",
http.Header{},
"",
},
{
"connection has upgrade but no upgrade header",
http.Header{"Connection": {"Upgrade"}},
"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := upgradeType(tt.header); got != tt.want {
t.Errorf("upgradeType() = %q, want %q", got, tt.want)
}
})
}
}
func TestDrainBody(t *testing.T) {
t.Run("nil body", func(t *testing.T) {
got, err := drainBody(nil)
if err != nil || got != nil {
t.Errorf("drainBody(nil) = (%v, %v), want (nil, nil)", got, err)
}
})
t.Run("http.NoBody", func(t *testing.T) {
got, err := drainBody(http.NoBody)
if err != nil || got != nil {
t.Errorf("drainBody(NoBody) = (%v, %v), want (nil, nil)", got, err)
}
})
t.Run("normal body", func(t *testing.T) {
body := io.NopCloser(strings.NewReader("hello world"))
got, err := drainBody(body)
if err != nil {
t.Fatal(err)
}
if string(got) != "hello world" {
t.Errorf("drainBody() = %q, want %q", string(got), "hello world")
}
})
t.Run("empty body", func(t *testing.T) {
body := io.NopCloser(strings.NewReader(""))
got, err := drainBody(body)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("drainBody() = %q, want empty", string(got))
}
})
}
// =============================================================================
// Rewrite Response Body Tests
// =============================================================================
func TestRewriteRespBody(t *testing.T) {
t.Run("nil response", func(t *testing.T) {
if err := rewriteRespBody(nil); err != nil {
t.Errorf("rewriteRespBody(nil) = %v, want nil", err)
}
})
t.Run("no rewrites", func(t *testing.T) {
resp := &http.Response{
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")),
}
_ = rewriteRespBody(resp)
// Body unchanged
got, _ := io.ReadAll(resp.Body)
if string(got) != "original" {
t.Errorf("body = %q, want %q", string(got), "original")
}
})
t.Run("zero content length", func(t *testing.T) {
resp := &http.Response{
ContentLength: 0,
Body: io.NopCloser(strings.NewReader("original")),
}
_ = rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile("."),
Type: "*",
Replacement: []byte("replaced"),
})
got, _ := io.ReadAll(resp.Body)
// Content-Length was 0 (unknown), so no rewrite applied
if string(got) != "original" {
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
}
})
t.Run("with content encoding", func(t *testing.T) {
resp := &http.Response{
Header: http.Header{"Content-Encoding": {"gzip"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")),
}
_ = rewriteRespBody(resp, chain.HTTPBodyRewriteSettings{
Type: "*",
Replacement: []byte("replaced"),
})
got, _ := io.ReadAll(resp.Body)
// Content-Encoding present, rewrite skipped
if string(got) != "original" {
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
}
})
}
func TestRewriteRespBodyContentTypeFilter(t *testing.T) {
// Helper to create a rewrite rule that replaces everything.
makeRewrite := func(rewriteType string, replacement string) chain.HTTPBodyRewriteSettings {
return chain.HTTPBodyRewriteSettings{
Pattern: regexp.MustCompile(".*"),
Type: rewriteType,
Replacement: []byte(replacement),
}
}
t.Run("exact type match text/html", func(t *testing.T) {
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/html; charset=utf-8"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")),
}
_ = rewriteRespBody(resp, makeRewrite("text/html", "replaced"))
got, _ := io.ReadAll(resp.Body)
if string(got) != "replaced" {
t.Errorf("body = %q, want %q", string(got), "replaced")
}
})
t.Run("wildcard type rewrites anything", func(t *testing.T) {
resp := &http.Response{
Header: http.Header{"Content-Type": {"application/json"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("hello")),
}
_ = rewriteRespBody(resp, makeRewrite("*", "world"))
got, _ := io.ReadAll(resp.Body)
if string(got) != "world" {
t.Errorf("body = %q, want %q", string(got), "world")
}
})
t.Run("mismatched type is skipped", func(t *testing.T) {
resp := &http.Response{
Header: http.Header{"Content-Type": {"text/plain"}},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")),
}
_ = rewriteRespBody(resp, makeRewrite("text/html", "replaced"))
got, _ := io.ReadAll(resp.Body)
if string(got) != "original" {
t.Errorf("body = %q, want %q (unchanged)", string(got), "original")
}
})
t.Run("empty content type defaults to text/html", func(t *testing.T) {
resp := &http.Response{
Header: http.Header{},
ContentLength: 100,
Body: io.NopCloser(strings.NewReader("original")),
}
// Empty type defaults to "text/html", and response has no Content-Type -> "" containing "text/html"? No.
// strings.Contains("text/html", "") is always true, so rewrite should happen.
_ = rewriteRespBody(resp, makeRewrite("", "replaced"))
got, _ := io.ReadAll(resp.Body)
if string(got) != "replaced" {
t.Errorf("body = %q, want %q (should be rewritten)", string(got), "replaced")
}
})
}
// =============================================================================
// TLS Wrap Tests
// =============================================================================
func TestTLSWrapConn_NilSettings(t *testing.T) {
cc := &net.TCPConn{} // will never be used as a real connection
if got := tlsWrapConn(cc, nil); got != cc {
t.Errorf("tlsWrapConn(nil settings) should return original conn unchanged")
}
}
// =============================================================================
// HandleHTTP Integration Tests
// =============================================================================
func TestHandleHTTP_BasicProxy(t *testing.T) {
// Set up an HTTP test server that acts as the upstream.
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK from upstream"))
}))
defer upstream.Close()
h := &Sniffer{
ReadTimeout: 5 * time.Second,
Recorder: &noopRecorder{},
}
ro := &xrecorder.HandlerRecorderObject{}
// Use net.Pipe and serve the client request in a goroutine.
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
defer serverConn.Close()
errCh := make(chan error, 1)
go func() {
errCh <- h.HandleHTTP(context.Background(), serverConn,
WithService("test-svc"),
WithDial(func(ctx context.Context, network, address string) (net.Conn, error) {
return net.Dial("tcp", upstream.Listener.Addr().String())
}),
WithRecorderObject(ro),
WithLog(xlogger.Nop()),
)
}()
// Write an HTTP request to the client side of the pipe.
req, _ := http.NewRequest("GET", "http://example.com/", nil)
if err := req.Write(clientConn); err != nil {
t.Fatal(err)
}
// Read the response back.
resp, err := http.ReadResponse(bufio.NewReader(clientConn), req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
clientConn.Close()
if err := <-errCh; err != nil {
t.Logf("HandleHTTP returned: %v", err)
}
// Verify recorder object was populated.
if ro.SrcAddr == "" {
t.Error("SrcAddr should be populated")
}
if ro.DstAddr == "" {
t.Error("DstAddr should be populated")
}
}
func TestHandleHTTP_HTTP2Detection(t *testing.T) {
// HTTP/2 connection preface should be detected and handled separately.
h := &Sniffer{
ReadTimeout: 5 * time.Second,
Recorder: &noopRecorder{},
}
ro := &xrecorder.HandlerRecorderObject{
RemoteAddr: "127.0.0.1:12345",
}
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
defer serverConn.Close()
errCh := make(chan error, 1)
go func() {
errCh <- h.HandleHTTP(context.Background(), serverConn,
WithService("test-svc"),
WithRecorderObject(ro),
WithLog(xlogger.Nop()),
)
}()
// Write HTTP/2 upgrade request
clientConn.Write([]byte("PRI * HTTP/2.0\r\n\r\n"))
// The handler will now try to read the SM\r\n\r\n preface, which we don't send.
// It should return an error about invalid client preface.
clientConn.Close()
err := <-errCh
if err == nil {
t.Log("HandleHTTP returned nil (expected error from incomplete h2 preface)")
}
}
// =============================================================================
// HandleOptions Tests
// =============================================================================
func TestWithService(t *testing.T) {
opts := &HandleOptions{}
WithService("mysvc")(opts)
if opts.service != "mysvc" {
t.Errorf("service = %q, want %q", opts.service, "mysvc")
}
}
func TestWithHTTPKeepalive(t *testing.T) {
opts := &HandleOptions{}
WithHTTPKeepalive(true)(opts)
if !opts.httpKeepalive {
t.Errorf("httpKeepalive = false, want true")
}
}
func TestWithNode(t *testing.T) {
opts := &HandleOptions{}
node := &chain.Node{Name: "test-node", Addr: "127.0.0.1:8080"}
WithNode(node)(opts)
if opts.node != node {
t.Errorf("node not set")
}
}
func TestWithHop(t *testing.T) {
opts := &HandleOptions{}
mh := &mockHop{}
WithHop(mh)(opts)
if opts.hop != mh {
t.Errorf("hop not set")
}
}
func TestWithBypass(t *testing.T) {
opts := &HandleOptions{}
bp := &mockBypass{contains: true}
WithBypass(bp)(opts)
if opts.bypass != bp {
t.Errorf("bypass not set")
}
}
func TestWithRecorderObject(t *testing.T) {
opts := &HandleOptions{}
ro := &xrecorder.HandlerRecorderObject{}
WithRecorderObject(ro)(opts)
if opts.recorderObject != ro {
t.Errorf("recorderObject not set")
}
}
func TestWithLog(t *testing.T) {
opts := &HandleOptions{}
log := xlogger.Nop()
WithLog(log)(opts)
if opts.log != log {
t.Errorf("log not set")
}
}
// =============================================================================
// mockHop for testing
// =============================================================================
type mockHop struct {
nodes []*chain.Node
}
func (m *mockHop) Select(ctx context.Context, opts ...hop.SelectOption) *chain.Node {
if len(m.nodes) > 0 {
return m.nodes[0]
}
return nil
}
// Ensure mockHop implements hop.Hop.
var _ hop.Hop = (*mockHop)(nil)
// =============================================================================
// HandleHTTP Edge Case Tests
// =============================================================================
func TestHandleHTTP_DialError(t *testing.T) {
h := &Sniffer{
ReadTimeout: 5 * time.Second,
Recorder: &noopRecorder{},
}
ro := &xrecorder.HandlerRecorderObject{}
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
defer serverConn.Close()
errCh := make(chan error, 1)
go func() {
errCh <- h.HandleHTTP(context.Background(), serverConn,
WithDial(func(ctx context.Context, network, address string) (net.Conn, error) {
return nil, io.ErrUnexpectedEOF
}),
WithRecorderObject(ro),
WithLog(xlogger.Nop()),
)
}()
// Write a valid HTTP request, then read the error response to avoid pipe deadlock.
req, _ := http.NewRequest("GET", "http://example.com/", nil)
req.Write(clientConn)
// Read the error response to unblock the server-side write.
br := bufio.NewReader(clientConn)
resp, respErr := http.ReadResponse(br, req)
if respErr != nil {
t.Errorf("reading error response: %v", respErr)
} else {
resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusServiceUnavailable)
}
}
err := <-errCh
if err == nil {
t.Error("expected error from failed dial, got nil")
}
}
func TestHandleHTTP_HTTP10Request(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("HTTP/1.0 OK"))
}))
defer upstream.Close()
h := &Sniffer{
ReadTimeout: 5 * time.Second,
Recorder: &noopRecorder{},
}
ro := &xrecorder.HandlerRecorderObject{}
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
defer serverConn.Close()
errCh := make(chan error, 1)
go func() {
errCh <- h.HandleHTTP(context.Background(), serverConn,
WithDial(func(ctx context.Context, network, address string) (net.Conn, error) {
return net.Dial("tcp", upstream.Listener.Addr().String())
}),
WithRecorderObject(ro),
WithLog(xlogger.Nop()),
)
}()
// Write an HTTP/1.0 request
clientConn.Write([]byte("GET / HTTP/1.0\r\nHost: example.com\r\nConnection: keep-alive\r\n\r\n"))
resp, err := http.ReadResponse(bufio.NewReader(clientConn), nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
clientConn.Close()
<-errCh
}
// =============================================================================
// WebSocket Frame Test
// =============================================================================
func TestCopyWebsocketFrame_Basic(t *testing.T) {
h := &Sniffer{
Recorder: &noopRecorder{},
RecorderOptions: &recorder.Options{HTTPBody: false},
}
// Build a minimal WebSocket text frame: fin=true, opcode=text(1), masked, payload="hi"
payload := []byte("hi")
mask := []byte{0xAA, 0xBB, 0xCC, 0xDD}
maskedPayload := make([]byte, len(payload))
for i := range payload {
maskedPayload[i] = payload[i] ^ mask[i%4]
}
var frame bytes.Buffer
frame.WriteByte(0x81) // FIN + text opcode
frame.WriteByte(0x82) // MASK + len=2
frame.Write(mask) // mask key
frame.Write(maskedPayload) // masked payload
w := &bytes.Buffer{}
ro := &xrecorder.HandlerRecorderObject{}
err := h.copyWebsocketFrame(w, &frame, &bytes.Buffer{}, "client", ro)
if err != nil {
t.Fatal(err)
}
if ro.Websocket == nil {
t.Fatal("Websocket recorder object should be populated")
}
if ro.Websocket.OpCode != 1 {
t.Errorf("opcode = %d, want 1 (text)", ro.Websocket.OpCode)
}
if ro.Websocket.Masked != true {
t.Error("client frame should be marked as masked")
}
// Client direction: InputBytes should be non-zero.
if ro.InputBytes == 0 {
t.Error("InputBytes should be non-zero for client direction")
}
}
func TestCopyWebsocketFrame_WithBodyRecording(t *testing.T) {
h := &Sniffer{
Recorder: &noopRecorder{},
RecorderOptions: &recorder.Options{HTTPBody: true, MaxBodySize: 1024},
}
payload := []byte("hello ws")
// Build a server frame (unmasked) with FIN + text opcode.
var frame bytes.Buffer
frame.WriteByte(0x81) // FIN + text
frame.WriteByte(byte(len(payload))) // MASK=0, len
// No mask for server frames
frame.Write(payload)
w := &bytes.Buffer{}
ro := &xrecorder.HandlerRecorderObject{}
err := h.copyWebsocketFrame(w, &frame, &bytes.Buffer{}, "server", ro)
if err != nil {
t.Fatal(err)
}
if ro.Websocket == nil {
t.Fatal("Websocket recorder object should be populated")
}
if string(ro.Websocket.Payload) != "hello ws" {
t.Errorf("payload = %q, want %q", ro.Websocket.Payload, "hello ws")
}
if ro.Websocket.Masked != false {
t.Error("server frame should be marked as unmasked")
}
// Server frames: OutputBytes should be non-zero.
if ro.OutputBytes == 0 {
t.Error("OutputBytes should be non-zero for server direction")
}
}
// =============================================================================
// normalizeHost Edge Cases
// =============================================================================
func TestNormalizeHost_EdgeCases(t *testing.T) {
// IPv6 with brackets already has port
got := normalizeHost("[::1]:8080", "443")
if got != "[::1]:8080" {
t.Errorf("got %q, want %q", got, "[::1]:8080")
}
// Hostname with port
got = normalizeHost("localhost:3000", "80")
if got != "localhost:3000" {
t.Errorf("got %q, want %q", got, "localhost:3000")
}
}
+320
View File
@@ -0,0 +1,320 @@
package forwarder
import (
"bytes"
"context"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"time"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/chain"
"github.com/go-gost/core/hop"
dissector "github.com/go-gost/tls-dissector"
xbypass "github.com/go-gost/x/bypass"
xio "github.com/go-gost/x/internal/io"
xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/internal/util/sniffing"
tls_util "github.com/go-gost/x/internal/util/tls"
xrecorder "github.com/go-gost/x/recorder"
"crypto/x509"
)
// HandleTLS sniffs and proxies a TLS connection. It parses the ClientHello
// for SNI-based routing, optionally performs MITM TLS termination for HTTP
// content inspection, and records TLS handshake metadata.
func (h *Sniffer) HandleTLS(ctx context.Context, conn net.Conn, opts ...HandleOption) error {
var ho HandleOptions
for _, opt := range opts {
opt(&ho)
}
ho.readTimeout = h.effectiveReadTimeout(&ho)
buf := new(bytes.Buffer)
clientHello, err := dissector.ParseClientHello(io.TeeReader(conn, buf))
if err != nil {
return err
}
ro := ho.recorderObject
ro.TLS = &xrecorder.TLSRecorderObject{
ServerName: clientHello.ServerName,
ClientHello: hex.EncodeToString(buf.Bytes()),
}
if len(clientHello.SupportedProtos) > 0 {
ro.TLS.Proto = clientHello.SupportedProtos[0]
}
host := normalizeHost(clientHello.ServerName, "443")
if host != "" {
ro.Host = host
if ho.bypass != nil && ho.bypass.Contains(ctx, "tcp", host, bypass.WithService(ho.service)) {
return xbypass.ErrBypass
}
}
node, cc, err := h.dialTLS(ctx, host, &ho)
if err != nil {
return err
}
defer cc.Close()
ho.node = node
log := ho.log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
log.Debugf("connected to node %s(%s)", node.Name, node.Addr)
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
if h.Certificate != nil && h.PrivateKey != nil &&
len(clientHello.SupportedProtos) > 0 && (clientHello.SupportedProtos[0] == "h2" || clientHello.SupportedProtos[0] == "http/1.1") {
if host == "" {
host = ro.Host
}
if h.MitmBypass == nil || !h.MitmBypass.Contains(ctx, "tcp", host) {
return h.terminateTLS(ctx, xnet.NewReadWriteConn(io.MultiReader(buf, conn), conn, conn), cc, clientHello, &ho)
}
}
if _, err := buf.WriteTo(cc); err != nil {
return err
}
xio.SetReadDeadline(cc, time.Now().Add(ho.readTimeout))
serverHello, serverHelloErr := dissector.ParseServerHello(io.TeeReader(cc, buf))
xio.SetReadDeadline(cc, time.Time{})
if serverHello != nil {
ro.TLS.CipherSuite = tls_util.CipherSuite(serverHello.CipherSuite).String()
ro.TLS.CompressionMethod = serverHello.CompressionMethod
if serverHello.Proto != "" {
ro.TLS.Proto = serverHello.Proto
}
if serverHello.Version > 0 {
ro.TLS.Version = tls_util.Version(serverHello.Version).String()
}
}
if buf.Len() > 0 {
ro.TLS.ServerHello = hex.EncodeToString(buf.Bytes())
}
if _, err := buf.WriteTo(conn); err != nil {
return err
}
log.Infof("%s <-> %s", ro.RemoteAddr, ro.Host)
xnet.Pipe(ctx, conn, cc)
log.WithFields(map[string]any{
"duration": time.Since(ro.Time),
}).Infof("%s >-< %s", ro.RemoteAddr, ro.Host)
// If server hello parsing failed, surface the error after proxy completes.
if serverHelloErr != nil {
return serverHelloErr
}
return nil
}
// dialTLS selects a node and establishes a TLS connection.
func (h *Sniffer) dialTLS(ctx context.Context, host string, ho *HandleOptions) (node *chain.Node, cc net.Conn, err error) {
dial := ho.dial
if dial == nil {
dial = (&net.Dialer{}).DialContext
}
if node = ho.node; node != nil {
cc, err = dial(ctx, "tcp", node.Addr)
return
}
node = &chain.Node{}
ro := ho.recorderObject
if ho.hop != nil {
var clientIP net.IP
if clientAddr, _ := net.ResolveTCPAddr("tcp", ro.ClientAddr); clientAddr != nil {
clientIP = clientAddr.IP
}
node = ho.hop.Select(ctx,
hop.ClientIPSelectOption(clientIP),
hop.HostSelectOption(host),
hop.ProtocolSelectOption(sniffing.ProtoTLS),
)
}
if node == nil {
err = errors.New("node not available")
return
}
if node.Addr == "" {
node = &chain.Node{
Name: node.Name,
Addr: host,
}
}
addr := node.Addr
network := "tcp"
if opts := node.Options(); opts != nil {
switch opts.Network {
case "unix":
network = opts.Network
default:
if _, _, splitErr := net.SplitHostPort(addr); splitErr != nil {
addr += ":443"
}
}
} else {
// No options — ensure port is present.
if _, _, splitErr := net.SplitHostPort(addr); splitErr != nil {
addr += ":443"
}
}
ro.Host = addr
ho.log = ho.log.WithFields(map[string]any{
"host": host,
"node": node.Name,
"dst": fmt.Sprintf("%s/%s", addr, network),
})
ho.log.Debugf("find node for host %s -> %s(%s)", host, node.Name, addr)
cc, err = dial(ctx, network, addr)
if err != nil {
if marker := node.Marker(); marker != nil {
marker.Mark()
}
ho.log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err)
return
}
if marker := node.Marker(); marker != nil {
marker.Reset()
}
cc = tlsWrapConn(cc, node.Options().TLS)
return
}
// terminateTLS performs MITM TLS termination: handshakes with the upstream
// server as a client, then with the downstream client as a server using a
// dynamically generated certificate. The decrypted traffic is then handled
// as HTTP.
func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHello *dissector.ClientHelloInfo, ho *HandleOptions) error {
ro := ho.recorderObject
log := ho.log
nextProtos := clientHello.SupportedProtos
if h.NegotiatedProtocol != "" {
nextProtos = []string{h.NegotiatedProtocol}
}
cfg := &tls.Config{
ServerName: clientHello.ServerName,
NextProtos: nextProtos,
CipherSuites: clientHello.CipherSuites,
}
if cfg.ServerName == "" {
cfg.InsecureSkipVerify = true
}
clientConn := tls.Client(cc, cfg)
if err := clientConn.HandshakeContext(ctx); err != nil {
return err
}
cs := clientConn.ConnectionState()
ro.TLS.CipherSuite = tls_util.CipherSuite(cs.CipherSuite).String()
ro.TLS.Proto = cs.NegotiatedProtocol
ro.TLS.Version = tls_util.Version(cs.Version).String()
host := cfg.ServerName
if host == "" {
if len(cs.PeerCertificates) > 0 {
host = cs.PeerCertificates[0].Subject.CommonName
}
if host == "" {
host = ro.Host
}
}
if splitHost, _, _ := net.SplitHostPort(host); splitHost != "" {
host = splitHost
}
negotiatedProtocol := cs.NegotiatedProtocol
if h.NegotiatedProtocol != "" {
negotiatedProtocol = h.NegotiatedProtocol
}
nextProtos = nil
if negotiatedProtocol != "" {
nextProtos = []string{negotiatedProtocol}
}
// cache the tls server handshake record.
wb := &bytes.Buffer{}
conn = xnet.NewReadWriteConn(conn, io.MultiWriter(wb, conn), conn)
serverConn := tls.Server(conn, &tls.Config{
NextProtos: nextProtos,
GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
certPool := h.CertPool
if certPool == nil {
certPool = DefaultCertPool
}
serverName := chi.ServerName
if serverName == "" {
serverName = host
}
cert, cerr := certPool.Get(serverName)
if cert != nil {
pool := x509.NewCertPool()
pool.AddCert(h.Certificate)
if _, cerr = cert.Verify(x509.VerifyOptions{
DNSName: serverName,
Roots: pool,
}); cerr != nil {
log.Warnf("verify cached certificate for %s: %v", serverName, cerr)
cert = nil
}
}
if cert == nil {
cert, cerr = tls_util.GenerateCertificate(serverName, 7*24*time.Hour, h.Certificate, h.PrivateKey)
certPool.Put(serverName, cert)
}
if cerr != nil {
return nil, cerr
}
return &tls.Certificate{
Certificate: [][]byte{cert.Raw},
PrivateKey: h.PrivateKey,
}, nil
},
})
handshakeErr := serverConn.HandshakeContext(ctx)
if record, _ := dissector.ReadRecord(wb); record != nil {
wb.Reset()
record.WriteTo(wb)
ro.TLS.ServerHello = hex.EncodeToString(wb.Bytes())
}
if handshakeErr != nil {
return handshakeErr
}
opts := []HandleOption{
WithDial(func(ctx context.Context, network, address string) (net.Conn, error) {
return clientConn, nil
}),
WithHTTPKeepalive(true),
WithNode(ho.node),
WithRecorderObject(ro),
WithLog(log),
}
return h.HandleHTTP(ctx, serverConn, opts...)
}
+147
View File
@@ -0,0 +1,147 @@
package forwarder
import (
"bytes"
"context"
"io"
"math"
"time"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/internal/util/sniffing"
ws_util "github.com/go-gost/x/internal/util/ws"
xrecorder "github.com/go-gost/x/recorder"
"golang.org/x/time/rate"
)
// sniffingWebsocketFrame copies WebSocket frames between rw and cc while
// recording frame metadata. It runs two goroutines for bidirectional copy
// and waits for the first error.
func (h *Sniffer) sniffingWebsocketFrame(ctx context.Context, rw, cc io.ReadWriteCloser, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
errc := make(chan error, 2)
sampleRate := h.WebsocketSampleRate
if sampleRate == 0 {
sampleRate = sniffing.DefaultSampleRate
}
if sampleRate < 0 {
sampleRate = math.MaxFloat64
}
go func() {
ro2 := &xrecorder.HandlerRecorderObject{}
*ro2 = *ro
ro := ro2
limiter := rate.NewLimiter(rate.Limit(sampleRate), int(sampleRate))
buf := &bytes.Buffer{}
for {
start := time.Now()
if err := h.copyWebsocketFrame(cc, rw, buf, "client", ro); err != nil {
errc <- err
return
}
if limiter.Allow() {
ro.Duration = time.Since(start)
ro.Time = time.Now()
if err := ro.Record(ctx, h.Recorder); err != nil {
log.Errorf("record: %v", err)
}
}
}
}()
go func() {
ro2 := &xrecorder.HandlerRecorderObject{}
*ro2 = *ro
ro := ro2
limiter := rate.NewLimiter(rate.Limit(sampleRate), int(sampleRate))
buf := &bytes.Buffer{}
for {
start := time.Now()
if err := h.copyWebsocketFrame(rw, cc, buf, "server", ro); err != nil {
errc <- err
return
}
if limiter.Allow() {
ro.Duration = time.Since(start)
ro.Time = time.Now()
if err := ro.Record(ctx, h.Recorder); err != nil {
log.Errorf("record: %v", err)
}
}
}
}()
err := <-errc
// Close both connections to unblock the other goroutine.
rw.Close()
cc.Close()
<-errc // wait for the other goroutine to finish
return err
}
// copyWebsocketFrame reads one WebSocket frame from r, records its metadata,
// and writes it to w.
func (h *Sniffer) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Buffer, from string, ro *xrecorder.HandlerRecorderObject) (err error) {
fr := ws_util.Frame{}
if _, err = fr.ReadFrom(r); err != nil {
return err
}
ws := &xrecorder.WebsocketRecorderObject{
From: from,
Fin: fr.Header.Fin,
Rsv1: fr.Header.Rsv1,
Rsv2: fr.Header.Rsv2,
Rsv3: fr.Header.Rsv3,
OpCode: int(fr.Header.OpCode),
Masked: fr.Header.Masked,
MaskKey: fr.Header.MaskKey,
Length: fr.Header.PayloadLength,
}
if bodySize := clampBodySize(h.RecorderOptions); bodySize > 0 {
buf.Reset()
if _, err := io.Copy(buf, io.LimitReader(fr.Data, int64(bodySize))); err != nil {
return err
}
ws.Payload = buf.Bytes()
ro.Websocket = ws
length := uint64(fr.Header.Length()) + uint64(fr.Header.PayloadLength)
if from == "client" {
ro.InputBytes = length
ro.OutputBytes = 0
} else {
ro.InputBytes = 0
ro.OutputBytes = length
}
fr.Data = io.MultiReader(bytes.NewReader(buf.Bytes()), fr.Data)
} else {
ro.Websocket = ws
length := uint64(fr.Header.Length()) + uint64(fr.Header.PayloadLength)
if from == "client" {
ro.InputBytes = length
ro.OutputBytes = 0
} else {
ro.InputBytes = 0
ro.OutputBytes = length
}
}
if _, err := fr.WriteTo(w); err != nil {
return err
}
return nil
}