mirror of
https://github.com/go-gost/x.git
synced 2026-10-19 16:16:32 +00:00
x: add Redis TCP sniffing with protocol-based routing
Add RESP protocol detection to the sniffing framework so plaintext Redis traffic on a port-reuse listener can be identified and routed by protocol. Sniff() now peeks 1 byte first to match RESP type bytes (*+-:$) without blocking, then falls through to the existing 5-byte TLS/HTTP/SSH detection. ParseRedisMetadata consumes exactly one RESP message via io.TeeReader and records command/key in HandlerRecorderObject.Redis. HandleRedis in both the sniffing (injected-dial) and forwarder (hop-selected) packages replays the captured first message and pipes the rest, so pipelined commands beyond the first are forwarded transparently. Wired into all 5 dispatch sites: sni, forward/local, forward/remote, http CONNECT, and relay tunnel handlers. forwarder paths route via resolveRedisNode using ProtocolSelectOption(ProtoRedis), enabling `protocol: redis` hop selection. Part of two-phase plan (2026-07-29-sniffing-redis-postgres.md); PostgreSQL deferred to phase 2.
This commit is contained in:
@@ -76,7 +76,7 @@ func (h *forwardHandler) sniffingDial(ctx context.Context, network, address stri
|
||||
// handled and (false, nil) when the caller should fall through to raw forwarding.
|
||||
func (h *forwardHandler) handleSniffedProtocol(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger, proto string) (handled bool, err error) {
|
||||
switch proto {
|
||||
case sniffing.ProtoHTTP, sniffing.ProtoTLS:
|
||||
case sniffing.ProtoHTTP, sniffing.ProtoTLS, sniffing.ProtoRedis:
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return h.sniffingDial(ctx, network, address, ro)
|
||||
}
|
||||
@@ -92,7 +92,17 @@ func (h *forwardHandler) handleSniffedProtocol(ctx context.Context, conn net.Con
|
||||
forwarder.WithLog(log),
|
||||
)
|
||||
}
|
||||
return true, sniffer.HandleTLS(ctx, conn,
|
||||
if proto == sniffing.ProtoTLS {
|
||||
return true, sniffer.HandleTLS(ctx, conn,
|
||||
forwarder.WithService(h.options.Service),
|
||||
forwarder.WithDial(dial),
|
||||
forwarder.WithHop(h.getHop()),
|
||||
forwarder.WithBypass(h.options.Bypass),
|
||||
forwarder.WithRecorderObject(ro),
|
||||
forwarder.WithLog(log),
|
||||
)
|
||||
}
|
||||
return true, sniffer.HandleRedis(ctx, conn,
|
||||
forwarder.WithService(h.options.Service),
|
||||
forwarder.WithDial(dial),
|
||||
forwarder.WithHop(h.getHop()),
|
||||
|
||||
@@ -76,7 +76,7 @@ func (h *forwardHandler) sniffingDial(ctx context.Context, network, address stri
|
||||
// handled and (false, nil) when the caller should fall through to raw forwarding.
|
||||
func (h *forwardHandler) handleSniffedProtocol(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger, proto string) (handled bool, err error) {
|
||||
switch proto {
|
||||
case sniffing.ProtoHTTP, sniffing.ProtoTLS:
|
||||
case sniffing.ProtoHTTP, sniffing.ProtoTLS, sniffing.ProtoRedis:
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return h.sniffingDial(ctx, network, address, ro)
|
||||
}
|
||||
@@ -92,7 +92,17 @@ func (h *forwardHandler) handleSniffedProtocol(ctx context.Context, conn net.Con
|
||||
forwarder.WithLog(log),
|
||||
)
|
||||
}
|
||||
return true, sniffer.HandleTLS(ctx, conn,
|
||||
if proto == sniffing.ProtoTLS {
|
||||
return true, sniffer.HandleTLS(ctx, conn,
|
||||
forwarder.WithService(h.options.Service),
|
||||
forwarder.WithDial(dial),
|
||||
forwarder.WithHop(h.getHop()),
|
||||
forwarder.WithBypass(h.options.Bypass),
|
||||
forwarder.WithRecorderObject(ro),
|
||||
forwarder.WithLog(log),
|
||||
)
|
||||
}
|
||||
return true, sniffer.HandleRedis(ctx, conn,
|
||||
forwarder.WithService(h.options.Service),
|
||||
forwarder.WithDial(dial),
|
||||
forwarder.WithHop(h.getHop()),
|
||||
|
||||
@@ -147,6 +147,13 @@ func (h *httpHandler) sniffAndHandle(ctx context.Context, conn net.Conn, cc net.
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
case sniffing.ProtoRedis:
|
||||
return true, sniffer.HandleRedis(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
|
||||
return false, nil
|
||||
|
||||
@@ -252,6 +252,14 @@ func (h *relayHandler) handleConnect(ctx context.Context, conn net.Conn, network
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
case sniffing.ProtoRedis:
|
||||
ro.Time = time.Time{}
|
||||
return sniffer.HandleRedis(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-4
@@ -98,7 +98,7 @@ func (h *sniHandler) sniffingDialTLS(ctx context.Context, network, address strin
|
||||
// connection directly (which for the SNI handler means silently dropping it).
|
||||
func (h *sniHandler) handleSniffedProtocol(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger, proto string) (handled bool, err error) {
|
||||
switch proto {
|
||||
case sniffing.ProtoHTTP, sniffing.ProtoTLS:
|
||||
case sniffing.ProtoHTTP, sniffing.ProtoTLS, sniffing.ProtoRedis:
|
||||
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return h.sniffingDial(ctx, network, address, ro)
|
||||
}
|
||||
@@ -116,11 +116,19 @@ func (h *sniHandler) handleSniffedProtocol(ctx context.Context, conn net.Conn, r
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
return true, sniffer.HandleTLS(ctx, "tcp", conn,
|
||||
if proto == sniffing.ProtoTLS {
|
||||
return true, sniffer.HandleTLS(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
}
|
||||
return true, sniffer.HandleRedis(ctx, "tcp", conn,
|
||||
sniffing.WithService(h.options.Service),
|
||||
sniffing.WithDial(dial),
|
||||
sniffing.WithDialTLS(dialTLS),
|
||||
sniffing.WithBypass(h.options.Bypass),
|
||||
sniffing.WithRecorderObject(ro),
|
||||
sniffing.WithLog(log),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/chain"
|
||||
"github.com/go-gost/core/hop"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
"github.com/go-gost/x/internal/util/sniffing"
|
||||
)
|
||||
|
||||
// HandleRedis proxies a sniffed Redis connection through a hop-selected node.
|
||||
// It parses the first RESP message for metadata recording, selects an upstream
|
||||
// node via resolveRedisNode (which passes ProtoRedis to the hop's protocol
|
||||
// selector), replays the captured bytes, and pipes traffic bidirectionally.
|
||||
func (h *Sniffer) HandleRedis(ctx context.Context, conn net.Conn, opts ...HandleOption) error {
|
||||
var ho HandleOptions
|
||||
for _, opt := range opts {
|
||||
opt(&ho)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if err := sniffing.ParseRedisMetadata(io.TeeReader(conn, buf), ho.recorderObject); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ro := ho.recorderObject
|
||||
|
||||
node, cc, err := h.dialRedis(ctx, &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 _, err := buf.WriteTo(cc); 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)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRedisNode selects an upstream node for a Redis connection via hop
|
||||
// selection with ProtocolSelectOption. Unlike resolveTLSNode, there is no
|
||||
// host to route by — routing is driven by ProtocolSelectOption(ProtoRedis)
|
||||
// and ClientIPSelectOption.
|
||||
func resolveRedisNode(ctx context.Context, ho *HandleOptions) (node *chain.Node, err error) {
|
||||
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.ProtoRedis),
|
||||
)
|
||||
}
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("node not available")
|
||||
}
|
||||
ho.recorderObject.Node = node.Name
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// dialRedis selects a node via resolveRedisNode and establishes a TCP
|
||||
// connection to the node's address.
|
||||
func (h *Sniffer) dialRedis(ctx context.Context, 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, err = resolveRedisNode(ctx, ho)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ro := ho.recorderObject
|
||||
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 += ":6379"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if _, _, splitErr := net.SplitHostPort(addr); splitErr != nil {
|
||||
addr += ":6379"
|
||||
}
|
||||
}
|
||||
ro.Host = addr
|
||||
|
||||
ho.log = ho.log.WithFields(map[string]any{
|
||||
"node": node.Name,
|
||||
"dst": fmt.Sprintf("%s/%s", addr, network),
|
||||
})
|
||||
ho.log.Debugf("find node for redis -> %s(%s)", 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
|
||||
}
|
||||
@@ -12,13 +12,26 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ProtoHTTP = "http"
|
||||
ProtoTLS = "tls"
|
||||
ProtoSSH = "ssh"
|
||||
ProtoQUIC = "quic"
|
||||
ProtoHTTP = "http"
|
||||
ProtoTLS = "tls"
|
||||
ProtoSSH = "ssh"
|
||||
ProtoQUIC = "quic"
|
||||
ProtoRedis = "redis"
|
||||
)
|
||||
|
||||
func Sniff(ctx context.Context, r *bufio.Reader) (proto string, err error) {
|
||||
// Tier 1: Peek 1 byte to detect protocols identifiable by first byte.
|
||||
// Peek(1) does not block (unless the connection has zero bytes).
|
||||
b, err := r.Peek(1)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch b[0] {
|
||||
case '*', '+', '-', ':', '$':
|
||||
return ProtoRedis, nil
|
||||
}
|
||||
|
||||
// Tier 2: Peek 5 bytes for TLS/HTTP/SSH (existing behavior).
|
||||
hdr, err := r.Peek(dissector.RecordHeaderLen)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package sniffing
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
// ParseRedisMetadata reads a single RESP message from r and populates
|
||||
// ro.Redis with the extracted command and key.
|
||||
//
|
||||
// r must provide exactly one RESP message — the caller is expected to
|
||||
// wrap the conn with io.TeeReader(conn, buf) so that the bytes are both
|
||||
// parsed and captured in buf for replay to the upstream.
|
||||
//
|
||||
// On success, ro.Redis is non-nil. On error (malformed RESP, truncated
|
||||
// message), ro.Redis is nil and the caller should fall back to raw
|
||||
// forwarding.
|
||||
func ParseRedisMetadata(r io.Reader, ro *xrecorder.HandlerRecorderObject) error {
|
||||
br := bufio.NewReader(r)
|
||||
|
||||
b, err := br.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch b {
|
||||
case '*':
|
||||
return parseRESPArray(br, ro)
|
||||
case '$':
|
||||
s, err := readBulkStringPayload(br)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ro.Redis = &xrecorder.RedisRecorderObject{Command: strings.ToUpper(s)}
|
||||
return nil
|
||||
case '+', '-':
|
||||
s, err := readRESPLine(br)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ro.Redis = &xrecorder.RedisRecorderObject{Command: strings.ToUpper(s)}
|
||||
return nil
|
||||
case ':':
|
||||
s, err := readRESPLine(br)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ro.Redis = &xrecorder.RedisRecorderObject{Command: s}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown RESP type byte: 0x%02x", b)
|
||||
}
|
||||
}
|
||||
|
||||
// readRESPLine reads until '\n' and strips the trailing "\r\n".
|
||||
func readRESPLine(br *bufio.Reader) (string, error) {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
|
||||
// readBulkStringPayload reads <len>\r\n<bytes>\r\n (the leading '$' must
|
||||
// already be consumed by the caller). Returns the string value.
|
||||
func readBulkStringPayload(br *bufio.Reader) (string, error) {
|
||||
line, err := readRESPLine(br)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
n, err := strconv.Atoi(line)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid bulk string length: %s", line)
|
||||
}
|
||||
if n < 0 {
|
||||
return "", nil // null bulk string
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(br, buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// consume trailing \r\n
|
||||
if _, err := br.Discard(2); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
// parseRESPArray parses a RESP array: <n>\r\n, then n bulk strings.
|
||||
// The leading '*' must already be consumed by the caller.
|
||||
func parseRESPArray(br *bufio.Reader, ro *xrecorder.HandlerRecorderObject) error {
|
||||
line, err := readRESPLine(br)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := strconv.Atoi(line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid RESP array length: %s", line)
|
||||
}
|
||||
if n < 0 {
|
||||
return nil // null array
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
b, err := br.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b != '$' {
|
||||
return fmt.Errorf("expected bulk string in RESP array, got byte 0x%02x", b)
|
||||
}
|
||||
s, err := readBulkStringPayload(br)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if i == 0 {
|
||||
// ponytail: first element allocated inline, second appended.
|
||||
// A nil check on the first element is enough — RESP
|
||||
// commands always have at least one element.
|
||||
ro.Redis = &xrecorder.RedisRecorderObject{Command: strings.ToUpper(s)}
|
||||
} else if i == 1 {
|
||||
ro.Redis.Key = s
|
||||
}
|
||||
}
|
||||
if ro.Redis == nil {
|
||||
ro.Redis = &xrecorder.RedisRecorderObject{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleRedis proxies a sniffed Redis connection. It parses the first RESP
|
||||
// message for metadata recording, then replays the captured bytes to the
|
||||
// upstream and pipes traffic bidirectionally.
|
||||
//
|
||||
// The network parameter is passed through to the injected dial function.
|
||||
// Bypass checks are not performed here — Redis has no host concept for
|
||||
// bypass to match against; protocol-based routing is handled by the
|
||||
// hop selector in the forwarder path.
|
||||
func (h *Sniffer) HandleRedis(ctx context.Context, network string, conn net.Conn, opts ...HandleOption) error {
|
||||
var ho HandleOptions
|
||||
for _, opt := range opts {
|
||||
opt(&ho)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if err := ParseRedisMetadata(io.TeeReader(conn, buf), ho.recorderObject); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log := ho.log
|
||||
ro := ho.recorderObject
|
||||
|
||||
dial := ho.dial
|
||||
if dial == nil {
|
||||
dial = (&net.Dialer{}).DialContext
|
||||
}
|
||||
cc, err := dial(ctx, network, ro.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()
|
||||
|
||||
if _, err := buf.WriteTo(cc); 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)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -114,6 +114,12 @@ type DNSRecorderObject struct {
|
||||
Cached bool `json:"cached"`
|
||||
}
|
||||
|
||||
// RedisRecorderObject holds the recorded data of a sniffed Redis command.
|
||||
type RedisRecorderObject struct {
|
||||
Command string `json:"command"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// HandlerRecorderObject bundles traffic metadata recorded at the handler
|
||||
// level — addresses, protocols, transferred bytes, and optional sub-records
|
||||
// for HTTP, WebSocket, TLS, and DNS traffic.
|
||||
@@ -135,6 +141,7 @@ type HandlerRecorderObject struct {
|
||||
Websocket *WebsocketRecorderObject `json:"websocket,omitempty"`
|
||||
TLS *TLSRecorderObject `json:"tls,omitempty"`
|
||||
DNS *DNSRecorderObject `json:"dns,omitempty"`
|
||||
Redis *RedisRecorderObject `json:"redis,omitempty"`
|
||||
Route string `json:"route,omitempty"`
|
||||
InputBytes uint64 `json:"inputBytes"`
|
||||
OutputBytes uint64 `json:"outputBytes"`
|
||||
|
||||
Reference in New Issue
Block a user