fix(forwarder): ReadTimeout race, goroutine leak, PeerCertificates panic, stale error return

- Use local readTimeout copy in HandleOptions instead of mutating shared
  Sniffer.ReadTimeout, eliminating a data race under concurrent calls
- Close both connections in sniffingWebsocketFrame when one direction
  errors, preventing indefinite goroutine blockage
- Add bounds check on PeerCertificates before indexing in terminateTLS
- Discard non-fatal ParseServerHello error instead of returning it after
  successful xnet.Pipe
- Log res.Write errors in bypass/bad-gateway/connect-failure paths
- Add doc comments to all 15 exported symbols
This commit is contained in:
ginuerzh
2026-05-26 21:58:30 +08:00
parent fb0ee8ead4
commit 92564a0c8c
+57 -21
View File
@@ -43,18 +43,18 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
const ( // DefaultReadTimeout is the default timeout for reading data from connections.
DefaultReadTimeout = 30 * time.Second const DefaultReadTimeout = 30 * time.Second
)
var ( // DefaultCertPool is the default in-memory certificate pool used for TLS MITM.
DefaultCertPool = tls_util.NewMemoryCertPool() var DefaultCertPool = tls_util.NewMemoryCertPool()
)
// HandleOptions holds configuration options for sniffing handlers.
type HandleOptions struct { type HandleOptions struct {
service string service string
dial func(ctx context.Context, network, address string) (net.Conn, error) dial func(ctx context.Context, network, address string) (net.Conn, error)
httpKeepalive bool httpKeepalive bool
readTimeout time.Duration
node *chain.Node node *chain.Node
hop hop.Hop hop hop.Hop
bypass bypass.Bypass bypass bypass.Bypass
@@ -62,56 +62,69 @@ type HandleOptions struct {
log logger.Logger log logger.Logger
} }
// HandleOption configures HandleOptions for sniffing handlers.
type HandleOption func(opts *HandleOptions) type HandleOption func(opts *HandleOptions)
// WithService sets the service name for bypass and selection lookups.
func WithService(service string) HandleOption { func WithService(service string) HandleOption {
return func(opts *HandleOptions) { return func(opts *HandleOptions) {
opts.service = service opts.service = service
} }
} }
// WithDial sets the dial function used to establish upstream connections.
func WithDial(dial func(ctx context.Context, network, address string) (net.Conn, error)) HandleOption { func WithDial(dial func(ctx context.Context, network, address string) (net.Conn, error)) HandleOption {
return func(opts *HandleOptions) { return func(opts *HandleOptions) {
opts.dial = dial opts.dial = dial
} }
} }
// WithHTTPKeepalive enables or disables HTTP keep-alive on the upstream connection.
func WithHTTPKeepalive(keepalive bool) HandleOption { func WithHTTPKeepalive(keepalive bool) HandleOption {
return func(opts *HandleOptions) { return func(opts *HandleOptions) {
opts.httpKeepalive = keepalive opts.httpKeepalive = keepalive
} }
} }
// WithNode sets a pre-resolved chain node to connect to, bypassing hop selection.
func WithNode(node *chain.Node) HandleOption { func WithNode(node *chain.Node) HandleOption {
return func(opts *HandleOptions) { return func(opts *HandleOptions) {
opts.node = node opts.node = node
} }
} }
// WithHop sets the hop used for node selection when routing requests.
func WithHop(hop hop.Hop) HandleOption { func WithHop(hop hop.Hop) HandleOption {
return func(opts *HandleOptions) { return func(opts *HandleOptions) {
opts.hop = hop opts.hop = hop
} }
} }
// WithBypass sets the bypass rules for filtering requests by host.
func WithBypass(bypass bypass.Bypass) HandleOption { func WithBypass(bypass bypass.Bypass) HandleOption {
return func(opts *HandleOptions) { return func(opts *HandleOptions) {
opts.bypass = bypass opts.bypass = bypass
} }
} }
// WithRecorderObject sets the recorder object for capturing traffic metadata.
func WithRecorderObject(ro *xrecorder.HandlerRecorderObject) HandleOption { func WithRecorderObject(ro *xrecorder.HandlerRecorderObject) HandleOption {
return func(opts *HandleOptions) { return func(opts *HandleOptions) {
opts.recorderObject = ro opts.recorderObject = ro
} }
} }
// WithLog sets the logger for the handler.
func WithLog(log logger.Logger) HandleOption { func WithLog(log logger.Logger) HandleOption {
return func(opts *HandleOptions) { return func(opts *HandleOptions) {
opts.log = log opts.log = log
} }
} }
// Sniffer handles HTTP and TLS traffic sniffing, recording, and MITM TLS
// termination for protocol-aware forwarding. It can intercept HTTP requests,
// perform hop/node selection, apply bypass rules, rewrite URLs and response
// bodies, and terminate TLS for content inspection.
type Sniffer struct { type Sniffer struct {
Websocket bool Websocket bool
WebsocketSampleRate float64 WebsocketSampleRate float64
@@ -129,14 +142,18 @@ type Sniffer struct {
ReadTimeout time.Duration ReadTimeout time.Duration
} }
// 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 { func (h *Sniffer) HandleHTTP(ctx context.Context, conn net.Conn, opts ...HandleOption) error {
var ho HandleOptions var ho HandleOptions
for _, opt := range opts { for _, opt := range opts {
opt(&ho) opt(&ho)
} }
if h.ReadTimeout <= 0 { ho.readTimeout = h.ReadTimeout
h.ReadTimeout = DefaultReadTimeout if ho.readTimeout <= 0 {
ho.readTimeout = DefaultReadTimeout
} }
pStats := xstats.Stats{} pStats := xstats.Stats{}
@@ -255,7 +272,9 @@ func (h *Sniffer) dial(ctx context.Context, conn net.Conn, req *http.Request, ho
ho.log.Debugf("bypass: %s %s", host, req.RequestURI) ho.log.Debugf("bypass: %s %s", host, req.RequestURI)
res.StatusCode = http.StatusForbidden res.StatusCode = http.StatusForbidden
ro.HTTP.StatusCode = res.StatusCode ro.HTTP.StatusCode = res.StatusCode
res.Write(conn) if err := res.Write(conn); err != nil {
ho.log.Warnf("write bypass response: %v", err)
}
return nil, nil, xbypass.ErrBypass return nil, nil, xbypass.ErrBypass
} }
} }
@@ -281,7 +300,9 @@ func (h *Sniffer) dial(ctx context.Context, conn net.Conn, req *http.Request, ho
ho.log.Warnf("node for %s not found", host) ho.log.Warnf("node for %s not found", host)
res.StatusCode = http.StatusBadGateway res.StatusCode = http.StatusBadGateway
ro.HTTP.StatusCode = res.StatusCode ro.HTTP.StatusCode = res.StatusCode
res.Write(conn) if err := res.Write(conn); err != nil {
ho.log.Warnf("write error response: %v", err)
}
return nil, nil, errors.New("node not available") return nil, nil, errors.New("node not available")
} }
if node.Addr == "" { if node.Addr == "" {
@@ -306,7 +327,9 @@ func (h *Sniffer) dial(ctx context.Context, conn net.Conn, req *http.Request, ho
marker.Mark() marker.Mark()
} }
ho.log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err) ho.log.Warnf("connect to node %s(%s) failed: %v", node.Name, node.Addr, err)
res.Write(conn) if werr := res.Write(conn); werr != nil {
ho.log.Warnf("write error response: %v", werr)
}
return return
} }
if marker := node.Marker(); marker != nil { if marker := node.Marker(); marker != nil {
@@ -499,7 +522,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
br := bufio.NewReader(cc) br := bufio.NewReader(cc)
var resp *http.Response var resp *http.Response
for { for {
xio.SetReadDeadline(cc, time.Now().Add(h.ReadTimeout)) xio.SetReadDeadline(cc, time.Now().Add(ho.readTimeout))
resp, err = http.ReadResponse(br, req) resp, err = http.ReadResponse(br, req)
if err != nil { if err != nil {
log.Errorf("read response: %v", err) log.Errorf("read response: %v", err)
@@ -617,8 +640,8 @@ func (h *Sniffer) handleUpgradeResponse(ctx context.Context, rw, cc io.ReadWrite
return xnet.Pipe(ctx, rw, cc) return xnet.Pipe(ctx, rw, cc)
} }
func (h *Sniffer) sniffingWebsocketFrame(ctx context.Context, rw, cc io.ReadWriter, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error { func (h *Sniffer) sniffingWebsocketFrame(ctx context.Context, rw, cc io.ReadWriteCloser, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
errc := make(chan error, 1) errc := make(chan error, 2)
sampleRate := h.WebsocketSampleRate sampleRate := h.WebsocketSampleRate
if sampleRate == 0 { if sampleRate == 0 {
@@ -680,8 +703,12 @@ func (h *Sniffer) sniffingWebsocketFrame(ctx context.Context, rw, cc io.ReadWrit
} }
}() }()
<-errc err := <-errc
return nil // Close both connections to unblock the other goroutine.
rw.Close()
cc.Close()
<-errc // wait for the other goroutine to finish
return err
} }
func (h *Sniffer) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Buffer, from string, ro *xrecorder.HandlerRecorderObject) (err error) { func (h *Sniffer) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Buffer, from string, ro *xrecorder.HandlerRecorderObject) (err error) {
@@ -783,14 +810,18 @@ func drainBody(b io.ReadCloser) (body []byte, err error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// 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 { func (h *Sniffer) HandleTLS(ctx context.Context, conn net.Conn, opts ...HandleOption) error {
var ho HandleOptions var ho HandleOptions
for _, opt := range opts { for _, opt := range opts {
opt(&ho) opt(&ho)
} }
if h.ReadTimeout <= 0 { ho.readTimeout = h.ReadTimeout
h.ReadTimeout = DefaultReadTimeout if ho.readTimeout <= 0 {
ho.readTimeout = DefaultReadTimeout
} }
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
@@ -849,8 +880,8 @@ func (h *Sniffer) HandleTLS(ctx context.Context, conn net.Conn, opts ...HandleOp
return err return err
} }
xio.SetReadDeadline(cc, time.Now().Add(h.ReadTimeout)) xio.SetReadDeadline(cc, time.Now().Add(ho.readTimeout))
serverHello, err := dissector.ParseServerHello(io.TeeReader(cc, buf)) serverHello, _ := dissector.ParseServerHello(io.TeeReader(cc, buf))
xio.SetReadDeadline(cc, time.Time{}) xio.SetReadDeadline(cc, time.Time{})
if serverHello != nil { if serverHello != nil {
@@ -999,7 +1030,10 @@ func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHel
host := cfg.ServerName host := cfg.ServerName
if host == "" { if host == "" {
if host = cs.PeerCertificates[0].Subject.CommonName; host == "" { if len(cs.PeerCertificates) > 0 {
host = cs.PeerCertificates[0].Subject.CommonName
}
if host == "" {
host = ro.Host host = ro.Host
} }
} }
@@ -1079,6 +1113,8 @@ func (h *Sniffer) terminateTLS(ctx context.Context, conn, cc net.Conn, clientHel
return h.HandleHTTP(ctx, serverConn, opts...) return h.HandleHTTP(ctx, serverConn, opts...)
} }
// h2Handler is an http.Handler that proxies HTTP/2 requests through an
// http2.Transport while recording request and response metadata.
type h2Handler struct { type h2Handler struct {
transport http.RoundTripper transport http.RoundTripper
recorder recorder.Recorder recorder recorder.Recorder