Files
x/internal/util/forwarder/sniffer_http.go
T
ginuerzh 3646c23398 refactor(forwarder): extract resolveHTTPNode and resolveTLSNode helpers, add tests
Pull node-resolution logic out of dial/dialTLS into pure resolveHTTPNode
and resolveTLSNode helpers. This separates node selection (bypass, hop,
fallback) from connection establishment, making both independently
testable. Add 21 new tests covering bypass, hop selection, nil-hop,
addr fallback, upgrade response type mismatch, websocket frames,
response body rewriting, and h2 preface validation.
2026-05-26 23:15:15 +08:00

427 lines
11 KiB
Go

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()
ho.log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
log = ho.log
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
}
}
}
// resolveHTTPNode selects a node for an HTTP request by applying bypass rules
// and hop selection. It returns the selected node or an error response.
func resolveHTTPNode(ctx context.Context, host string, req *http.Request, ho *HandleOptions) (node *chain.Node, res *http.Response, err error) {
res = &http.Response{
ProtoMajor: req.ProtoMajor,
ProtoMinor: req.ProtoMinor,
Header: http.Header{},
StatusCode: http.StatusServiceUnavailable,
}
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
return nil, res, xbypass.ErrBypass
}
node = &chain.Node{}
if ho.hop != nil {
var clientIP net.IP
if clientAddr, _ := net.ResolveTCPAddr("tcp", ho.recorderObject.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
return nil, res, errors.New("node not available")
}
if node.Addr == "" {
node = &chain.Node{
Name: node.Name,
Addr: host,
}
}
return node, nil, nil
}
// dial selects a node, establishes a connection, and sends the request upstream.
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
host := normalizeHost(req.Host, "80")
if host != "" {
ro.Host = host
ho.log = ho.log.WithFields(map[string]any{
"host": host,
})
}
node, res, resolveErr := resolveHTTPNode(ctx, host, req, ho)
if resolveErr != nil {
ro.HTTP.StatusCode = res.StatusCode
if werr := res.Write(conn); werr != nil {
ho.log.Warnf("write error response: %v", werr)
}
return nil, nil, resolveErr
}
// Prepare an error response for potential connection failures.
res = &http.Response{
ProtoMajor: req.ProtoMajor,
ProtoMinor: req.ProtoMinor,
Header: http.Header{},
StatusCode: http.StatusServiceUnavailable,
}
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
}