Files
ginuerzh 0954e57dcb fix(redirect/tcp): forward protocols the sniffer does not handle
Since 3.3.0 a transparent red service with sniffing enabled closed every
connection whose protocol Sniff does not classify as HTTP or TLS. SSH,
Redis, and any raw TCP — plus server-speaks-first protocols such as SMTP,
where Sniff returns "" after sniffing.timeout — took no matching branch
of the switch on proto, so sniffErr stayed nil and the handler returned
nil, closing the connection via the deferred conn.Close(). No dial was
attempted, so the upstream node never saw the connection, and nothing was
logged above info.

This traces to c23a2f1, which introduced the error-returning switch in
order to replay sniffed bytes for the empty-SNI case (#891). Before it,
the switch had no error variable and simply fell out into raw
forwarding, so every unclassified protocol was proxied as-is.

sniffing.fallback is not a usable workaround: it also changes dial() so
that a failure to reach the sniffed host re-dials the original IP, where
domain-based hop/node bypass rules no longer match. That can route a
domain through a node its bypass excludes.

Mark the two handled protocols explicitly and let everything else fall
through to raw forwarding unconditionally, restoring the 3.2.x
behaviour. sniffing.fallback keeps its intended meaning: it governs only
what happens after HTTP/TLS handling itself fails (#891).

Closes #913
2026-10-11 10:15:11 +00:00

348 lines
9.9 KiB
Go

package redirect
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"io"
"net"
"strings"
"time"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/handler"
md "github.com/go-gost/core/metadata"
"github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
xbypass "github.com/go-gost/x/bypass"
xctx "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
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"
rate_limiter "github.com/go-gost/x/limiter/rate"
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"
"github.com/go-gost/x/registry"
)
func init() {
registry.HandlerRegistry().Register("red", NewHandler)
registry.HandlerRegistry().Register("redir", NewHandler)
registry.HandlerRegistry().Register("redirect", NewHandler)
}
type redirectHandler struct {
md metadata
options handler.Options
recorder recorder.RecorderObject
sessionRecorder *xrecorder.SessionRecorder
certPool tls_util.CertPool
}
func NewHandler(opts ...handler.Option) handler.Handler {
options := handler.Options{}
for _, opt := range opts {
opt(&options)
}
return &redirectHandler{
options: options,
}
}
func (h *redirectHandler) Init(md md.Metadata) (err error) {
if err = h.parseMetadata(md); err != nil {
return
}
for _, ro := range h.options.Recorders {
if ro.Record == xrecorder.RecorderServiceHandler {
h.recorder = ro
break
}
}
h.sessionRecorder = xrecorder.NewSessionRecorder(h.recorder.Recorder, xrecorder.SessionRecorderOptions{
Period: h.md.recorderPeriod,
})
if h.md.certificate != nil && h.md.privateKey != nil {
h.certPool = tls_util.NewMemoryCertPool()
}
return
}
func (h *redirectHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.HandleOption) (err error) {
defer conn.Close()
start := time.Now()
ro := &xrecorder.HandlerRecorderObject{
Network: "tcp",
Service: h.options.Service,
RemoteAddr: conn.RemoteAddr().String(),
LocalAddr: conn.LocalAddr().String(),
SID: xctx.SidFromContext(ctx).String(),
Time: start,
}
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
ro.ClientAddr = srcAddr.String()
}
log := h.options.Logger.WithFields(map[string]any{
"network": ro.Network,
"remote": conn.RemoteAddr().String(),
"local": conn.LocalAddr().String(),
"client": ro.ClientAddr,
"sid": ro.SID,
})
log.Infof("%s <> %s", conn.RemoteAddr(), conn.LocalAddr())
pStats := xstats.Stats{}
conn = stats_wrapper.WrapConn(conn, &pStats)
session := h.sessionRecorder.NewSession(ctx, &pStats)
ctx = ictx.ContextWithSession(ctx, session)
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(start)
if err := session.Finish(ctx, *ro); err != nil {
log.Errorf("record: %v", err)
}
log.WithFields(map[string]any{
"duration": time.Since(start),
"inputBytes": ro.InputBytes,
"outputBytes": ro.OutputBytes,
}).Infof("%s >< %s", conn.RemoteAddr(), conn.LocalAddr())
}()
if !h.checkRateLimit(conn.RemoteAddr()) {
return rate_limiter.ErrRateLimit
}
var dstAddr net.Addr
if h.md.tproxy {
dstAddr = conn.LocalAddr()
} else {
dstAddr, err = h.getOriginalDstAddr(conn)
if err != nil {
log.Error(err)
return
}
}
ro.Host = dstAddr.String()
ro.DstAddr = dstAddr.String()
log = log.WithFields(map[string]any{
"dst": dstAddr.String(),
"host": dstAddr.String(),
})
// Check bypass on dstAddr before sniffing — the sniffer only matches the
// sniffed req.Host, so IP/CIDR rules miss connections carrying a hostname.
// Skip in whitelist mode: a domain-only allowlist never matches the bare
// destination IP, and rejecting here would kill sniffed-host decisions
// (e.g. example.com in the whitelist) before sniffing can run.
if h.options.Bypass != nil &&
!h.options.Bypass.IsWhitelist() &&
h.options.Bypass.Contains(ctx, dstAddr.Network(), dstAddr.String(), bypass.WithService(h.options.Service)) {
log.Debug("bypass: ", dstAddr)
return xbypass.ErrBypass
}
if h.md.sniffing {
if h.md.sniffingTimeout > 0 {
conn.SetReadDeadline(time.Now().Add(h.md.sniffingTimeout))
}
br := bufio.NewReader(conn)
proto, _ := sniffing.Sniff(ctx, br)
ro.Proto = proto
if h.md.sniffingTimeout > 0 {
conn.SetReadDeadline(time.Time{})
}
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
var cc net.Conn
var err error
if address != "" {
host, _, _ := net.SplitHostPort(address)
if host == "" {
host = address
}
_, port, _ := net.SplitHostPort(dstAddr.String())
address = net.JoinHostPort(strings.Trim(host, "[]"), port)
ro.Host = address
var buf bytes.Buffer
cc, err = h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", address)
ro.Route = buf.String()
if err != nil && !h.md.sniffingFallback {
return nil, err
}
}
if cc == nil {
if h.options.Bypass != nil && h.options.Bypass.Contains(ctx, "tcp", dstAddr.String(), bypass.WithService(h.options.Service)) {
return nil, xbypass.ErrBypass
}
var buf bytes.Buffer
cc, err = h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", dstAddr.String())
ro.Route = buf.String()
ro.Host = dstAddr.String()
}
return cc, err
}
// Dial the original dst instead of the sniffed host; sniffing's
// hostname bypass and recording still run beforehand.
if h.md.sniffingDialOriginalDst {
dial = func(ctx context.Context, network, address string) (net.Conn, error) {
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "tcp", dstAddr.String())
ro.Route = buf.String()
return cc, err
}
}
dialTLS := func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
return dial(ctx, network, address)
}
sniffer := &sniffing.Sniffer{
Websocket: h.md.sniffingWebsocket,
WebsocketSampleRate: h.md.sniffingWebsocketSampleRate,
Recorder: h.recorder.Recorder,
SessionRecorder: h.sessionRecorder,
RecorderOptions: h.recorder.Options,
Certificate: h.md.certificate,
PrivateKey: h.md.privateKey,
NegotiatedProtocol: h.md.alpn,
CertPool: h.certPool,
MitmBypass: h.md.mitmBypass,
ReadTimeout: h.md.readTimeout,
}
// Capture bytes consumed by the sniffer so we can replay
// them when sniffingFallback is enabled.
origConn := conn
capture := new(bytes.Buffer)
sniffConn := xnet.NewReadWriteConn(io.TeeReader(br, capture), origConn, origConn)
var sniffErr error
var handled bool
switch proto {
case sniffing.ProtoHTTP:
handled = true
sniffErr = sniffer.HandleHTTP(ctx, "tcp", sniffConn,
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
if sniffErr == nil {
ro.Time = time.Time{}
return nil
}
case sniffing.ProtoTLS:
handled = true
sniffErr = sniffer.HandleTLS(ctx, ro.Network, sniffConn,
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithBypass(h.options.Bypass),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
if sniffErr == nil {
return nil
}
}
// A protocol the sniffer does not classify (ssh, redis, or "" when
// nothing arrives within sniffing.timeout) has no error to report and
// nothing to fall back from, so it always reaches raw forwarding to the
// original dstAddr — the way every non-HTTP/TLS protocol was handled
// before sniffing gained a failure path. sniffing.fallback only governs
// what happens after HTTP/TLS handling itself fails (e.g. an empty SNI,
// gost#891).
if !handled {
log.Debugf("sniffing: protocol %q not handled, forwarding raw", proto)
} else if h.md.sniffingFallback {
log.Debugf("sniffing(%s) failed, falling back: %v", proto, sniffErr)
} else {
return sniffErr
}
conn = xnet.NewReadWriteConn(io.MultiReader(capture, br), origConn, origConn)
// fall through to raw forwarding below
}
// Re-check bypass on dstAddr before raw forwarding. In whitelist mode the
// pre-sniffing check above is skipped, so this is what drops connections
// whose sniffed host didn't match (sniffingFallback) or that weren't
// sniffed at all (sniffing disabled).
if h.options.Bypass != nil &&
h.options.Bypass.Contains(ctx, dstAddr.Network(), dstAddr.String(), bypass.WithService(h.options.Service)) {
log.Debug("bypass: ", dstAddr)
return xbypass.ErrBypass
}
log.Debugf("%s >> %s", conn.RemoteAddr(), dstAddr)
var buf bytes.Buffer
cc, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), dstAddr.Network(), dstAddr.String())
ro.Route = buf.String()
if err != nil {
log.Error(err)
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()
session.Start(*ro)
t := time.Now()
log.Infof("%s <-> %s", conn.RemoteAddr(), dstAddr)
// xnet.Transport(conn, cc)
xnet.Pipe(ctx, conn, cc)
log.WithFields(map[string]any{
"duration": time.Since(t),
}).Infof("%s >-< %s", conn.RemoteAddr(), dstAddr)
return nil
}
func (h *redirectHandler) checkRateLimit(addr net.Addr) bool {
if h.options.RateLimiter == nil {
return true
}
host, _, _ := net.SplitHostPort(addr.String())
if limiter := h.options.RateLimiter.Limiter(host); limiter != nil {
return limiter.Allow(1)
}
return true
}
func (h *redirectHandler) Close() error { return h.sessionRecorder.Close() }