refactor(handler/tunnel): code review fixes and entrypoint subpackage extraction
- Fix dead-code branch in bind.go host assignment (always use endpoint hash) - Return descriptive error on bypass match in connect.go (was masking as success) - Update bypass test in connect_test.go for new error behavior - Extract entrypoint subpackage from monolithic entrypoint.go (6 files) - Fix observeStats event-loss bug (break -> fallthrough on retry success) - Add 47 unit tests across handler, connect, bind, metadata packages - Add architecture doc comments to all key files - Build and vet clean, 173 tests pass with -race
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/ingress"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/recorder"
|
||||
"github.com/go-gost/core/sd"
|
||||
)
|
||||
|
||||
// Config carries dependencies from the tunnel handler into the entrypoint.
|
||||
//
|
||||
// All fields must be set by the caller before calling New. The DialFunc
|
||||
// bridges the package boundary to avoid an import cycle.
|
||||
type Config struct {
|
||||
// Node is the local tunnel node ID (from tunnelHandler.id).
|
||||
Node string
|
||||
// Service is the parent handler's service name, used for ingress lookups,
|
||||
// logging, and TLS certificate selection.
|
||||
Service string
|
||||
Ingress ingress.Ingress
|
||||
SD sd.SD
|
||||
Logger logger.Logger
|
||||
Recorder recorder.RecorderObject
|
||||
SniffingWebsocket bool
|
||||
WebsocketSampleRate float64
|
||||
// ReadTimeout is applied as SetReadDeadline on upstream connections
|
||||
// before sniffing HTTP/TLS reads. Also used as
|
||||
// http.Transport.ResponseHeaderTimeout. 0 or negative defaults to 15s.
|
||||
ReadTimeout time.Duration
|
||||
// ProxyProtocol is the PROXY protocol version (1 or 2) for the listener.
|
||||
ProxyProtocol int
|
||||
// KeepAlive enables HTTP keep-alive for the transport. Disabled by default.
|
||||
KeepAlive bool
|
||||
// Compression enables HTTP transport-level compression. Disabled by default.
|
||||
Compression bool
|
||||
}
|
||||
|
||||
// DialFunc is the function signature for establishing a tunnel connection.
|
||||
// Implemented by the parent package (tunnel) to avoid import cycles.
|
||||
//
|
||||
// ctx carries recorder objects and loggers for the tunnel connection.
|
||||
// network is always "tcp" for entrypoint connections.
|
||||
// tid is the string representation of the target tunnel ID.
|
||||
type DialFunc func(ctx DialContext, network, tid string) (conn net.Conn, node, cid string, err error)
|
||||
|
||||
// DialContext carries metadata for a dial request.
|
||||
// This interface satisfies the context.Context Value() contract,
|
||||
// allowing the parent package to pass a real context.Context which is
|
||||
// recovered via type assertion in the DialFunc implementation.
|
||||
type DialContext interface {
|
||||
Value(any) any
|
||||
}
|
||||
|
||||
// New creates a new Entrypoint with the given config and dial function.
|
||||
//
|
||||
// The config fields are copied into the Entrypoint struct. The dial function
|
||||
// is stored as-is — nil functions cause dial failures at runtime, not at
|
||||
// construction time.
|
||||
//
|
||||
// The HTTP transport is initialized immediately with ep.dial as its
|
||||
// DialContext; responses time out after cfg.ReadTimeout (default 15s).
|
||||
func New(cfg *Config, dialFn DialFunc) *Entrypoint {
|
||||
ep := &Entrypoint{
|
||||
node: cfg.Node,
|
||||
service: cfg.Service,
|
||||
ingress: cfg.Ingress,
|
||||
sd: cfg.SD,
|
||||
log: cfg.Logger,
|
||||
recorder: cfg.Recorder,
|
||||
sniffingWebsocket: cfg.SniffingWebsocket,
|
||||
websocketSampleRate: cfg.WebsocketSampleRate,
|
||||
readTimeout: cfg.ReadTimeout,
|
||||
dialFn: dialFn,
|
||||
}
|
||||
ep.transport = &http.Transport{
|
||||
DialContext: ep.dial,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
ResponseHeaderTimeout: cfg.ReadTimeout,
|
||||
DisableKeepAlives: !cfg.KeepAlive,
|
||||
DisableCompression: !cfg.Compression,
|
||||
}
|
||||
return ep
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Package entrypoint implements a public tunnel entry point that accepts external
|
||||
// connections and routes them through the tunnel network to internal services
|
||||
// behind NAT/firewall.
|
||||
//
|
||||
// Protocol dispatch is done by peeking at the first byte of the connection:
|
||||
//
|
||||
// relay.Version1 (0x52 'R') → relay protocol (handleConnect)
|
||||
// dissector.Handshake (0x16) → TLS passthrough (handleTLS)
|
||||
// otherwise → HTTP proxy (handleHTTP)
|
||||
//
|
||||
// The entrypoint is constructed by the parent tunnel package via Config and a
|
||||
// DialFunc. It operates as a standalone service with its own TCP listener,
|
||||
// decoder wrapper, and protocol-specific handling — independent of the parent's
|
||||
// relay-handshake path.
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/ingress"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
"github.com/go-gost/core/recorder"
|
||||
"github.com/go-gost/core/sd"
|
||||
"github.com/go-gost/relay"
|
||||
dissector "github.com/go-gost/tls-dissector"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
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"
|
||||
)
|
||||
|
||||
// entrypoint errors — mirror of tunnel.ErrTunnelRoute and tunnel.ErrPrivateTunnel
|
||||
// to avoid import cycles. The parent package's dial function returns these.
|
||||
var (
|
||||
errNoRoute = fmt.Errorf("no route to host")
|
||||
errPrivateTunnel = fmt.Errorf("private tunnel")
|
||||
)
|
||||
|
||||
// Entrypoint is a public tunnel entry point that accepts external connections
|
||||
// and routes them through the tunnel network.
|
||||
//
|
||||
// Protocol dispatch is done by peeking at the first byte of the connection:
|
||||
// - relay.Version1 (0x52 'R') → relay protocol (handleConnect)
|
||||
// - dissector.Handshake (0x16) → TLS passthrough (handleTLS)
|
||||
// - otherwise → HTTP proxy (handleHTTP)
|
||||
//
|
||||
// For HTTP/TLS paths, the dial flow is:
|
||||
//
|
||||
// ep.Dial() → ingress lookup (host → tunnelID) → dialFn()
|
||||
// → tunnel connection
|
||||
//
|
||||
// The relay protocol path handles its own tunnel ID extraction from the
|
||||
// relay request frame.
|
||||
type Entrypoint struct {
|
||||
// node is the local tunnel node ID (from tunnelHandler.id).
|
||||
node string
|
||||
// service is the parent handler's service name, used for ingress lookups.
|
||||
service string
|
||||
ingress ingress.Ingress
|
||||
sd sd.SD
|
||||
log logger.Logger
|
||||
recorder recorder.RecorderObject
|
||||
transport http.RoundTripper
|
||||
|
||||
// sniffingWebsocket enables WebSocket frame-level recording.
|
||||
sniffingWebsocket bool
|
||||
// websocketSampleRate controls the rate-limiter for WebSocket frame
|
||||
// recording samples. 0 defaults to sniffing.DefaultSampleRate.
|
||||
websocketSampleRate float64
|
||||
|
||||
// readTimeout is applied as SetReadDeadline on the upstream connection
|
||||
// before sniffing HTTP/TLS reads. It mirrors entryPointReadTimeout
|
||||
// from the handler metadata.
|
||||
readTimeout time.Duration
|
||||
|
||||
// dialFn is the function for establishing a tunnel connection,
|
||||
// provided by the parent package.
|
||||
dialFn DialFunc
|
||||
}
|
||||
|
||||
// Handle processes an incoming connection on the entrypoint listener.
|
||||
//
|
||||
// The first byte is peeked to dispatch protocol handling:
|
||||
// - relay.Version1 (0x52 'R') → ep.handleConnect (relay protocol)
|
||||
// - dissector.Handshake (0x16) → ep.handleTLS (TLS passthrough)
|
||||
// - otherwise → ep.handleHTTP (HTTP forward proxy)
|
||||
//
|
||||
// Recording instrumentation (stats, HTTP/TLS metadata) wraps the connection
|
||||
// and is finalized in the deferred recorder callback.
|
||||
func (ep *Entrypoint) Handle(ctx context.Context, conn net.Conn) (err error) {
|
||||
defer conn.Close()
|
||||
|
||||
ro := &xrecorder.HandlerRecorderObject{
|
||||
Network: "tcp",
|
||||
Node: ep.node,
|
||||
Service: ep.service,
|
||||
RemoteAddr: conn.RemoteAddr().String(),
|
||||
LocalAddr: conn.LocalAddr().String(),
|
||||
SID: xctx.SidFromContext(ctx).String(),
|
||||
Time: time.Now(),
|
||||
}
|
||||
|
||||
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
|
||||
ro.ClientAddr = srcAddr.String()
|
||||
}
|
||||
|
||||
log := ep.log.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)
|
||||
|
||||
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 err := ro.Record(ctx, ep.recorder.Recorder); err != nil {
|
||||
log.Errorf("record: %v", err)
|
||||
}
|
||||
|
||||
log.WithFields(map[string]any{
|
||||
"duration": time.Since(ro.Time),
|
||||
"inputBytes": ro.InputBytes,
|
||||
"outputBytes": ro.OutputBytes,
|
||||
}).Infof("%s >< %s", conn.RemoteAddr(), conn.LocalAddr())
|
||||
}()
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
v, err := br.Peek(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||
if v[0] == relay.Version1 {
|
||||
return ep.handleConnect(ctx, conn, ro, log)
|
||||
}
|
||||
if v[0] == dissector.Handshake {
|
||||
return ep.handleTLS(ctx, conn, ro, log)
|
||||
}
|
||||
return ep.handleHTTP(ctx, conn, ro, log)
|
||||
}
|
||||
|
||||
// dial resolves the host to a tunnel ID via ingress rules, then establishes
|
||||
// a connection through the tunnel network using the provided dial function.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Look up addr in the ingress table to find the target tunnel ID.
|
||||
// 2. If the tunnel ID is zero, return errNoRoute.
|
||||
// 3. If the tunnel is private ($-prefixed), return errPrivateTunnel.
|
||||
// 4. Call ep.dialFn(ctx, "tcp", tunnelID.String()) to open a tunnel stream.
|
||||
// 5. If the connected node is the local node, write StatusOK + address
|
||||
// features (src, dst) to the stream — the internal client uses these
|
||||
// addresses to know where to connect.
|
||||
// 6. If the connected node is remote, just set ro.Redirect — the remote
|
||||
// node handles relay framing on its own.
|
||||
//
|
||||
// Returns the tunnel stream as conn, or an error.
|
||||
func (ep *Entrypoint) dial(ctx context.Context, network, addr string) (conn net.Conn, err error) {
|
||||
var tunnelID relay.TunnelID
|
||||
if ep.ingress != nil {
|
||||
if rule := ep.ingress.GetRule(ctx, addr, ingress.WithService(ep.service)); rule != nil {
|
||||
tunnelID = parseTunnelID(rule.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
log := ictx.LoggerFromContext(ctx)
|
||||
if log == nil {
|
||||
log = ep.log
|
||||
}
|
||||
log.Debugf("dial: new connection to host %s", addr)
|
||||
|
||||
if tunnelID.IsZero() {
|
||||
return nil, fmt.Errorf("%w %s", errNoRoute, addr)
|
||||
}
|
||||
|
||||
if ro := ictx.RecorderObjectFromContext(ctx); ro != nil {
|
||||
ro.ClientID = tunnelID.String()
|
||||
}
|
||||
|
||||
if tunnelID.IsPrivate() {
|
||||
return nil, fmt.Errorf("%w: tunnel %s is private for host %s", errPrivateTunnel, tunnelID, addr)
|
||||
}
|
||||
|
||||
log = log.WithFields(map[string]any{
|
||||
"tunnel": tunnelID.String(),
|
||||
})
|
||||
|
||||
if ep.dialFn == nil {
|
||||
return nil, fmt.Errorf("tunnel not available")
|
||||
}
|
||||
|
||||
cc, node, cid, err := ep.dialFn(ctx, "tcp", tunnelID.String())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Debugf("dial: connected to host %s, tunnel: %s, connector: %s", addr, tunnelID, cid)
|
||||
|
||||
ro := ictx.RecorderObjectFromContext(ctx)
|
||||
if node == ep.node {
|
||||
if ro != nil {
|
||||
ro.Redirect = ""
|
||||
}
|
||||
|
||||
var clientAddr string
|
||||
if addr := xctx.SrcAddrFromContext(ctx); addr != nil {
|
||||
clientAddr = addr.String()
|
||||
}
|
||||
var features []relay.Feature
|
||||
af := &relay.AddrFeature{}
|
||||
af.ParseFrom(string(clientAddr))
|
||||
features = append(features, af) // src address
|
||||
|
||||
af = &relay.AddrFeature{}
|
||||
af.ParseFrom(addr)
|
||||
features = append(features, af) // dst address
|
||||
|
||||
if _, err = (&relay.Response{
|
||||
Version: relay.Version1,
|
||||
Status: relay.StatusOK,
|
||||
Features: features,
|
||||
}).WriteTo(cc); err != nil {
|
||||
cc.Close()
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if ro != nil {
|
||||
ro.Redirect = node
|
||||
}
|
||||
}
|
||||
|
||||
conn = cc
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// parseTunnelID parses a tunnel ID from a string.
|
||||
// If s is empty or contains an invalid UUID, the returned tunnel ID is zero
|
||||
// (callers must check IsZero). A leading '$' prefix marks the tunnel as private.
|
||||
//
|
||||
// This is a copy of tunnel.ParseTunnelID that avoids importing google/uuid.
|
||||
// It uses a local parseUUID instead of uuid.Parse.
|
||||
func parseTunnelID(s string) (tid relay.TunnelID) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
private := false
|
||||
if s[0] == '$' {
|
||||
private = true
|
||||
s = s[1:]
|
||||
}
|
||||
u, err := parseUUID(s)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var raw [16]byte
|
||||
copy(raw[:], u[:])
|
||||
|
||||
if private {
|
||||
return relay.NewPrivateTunnelID(raw[:])
|
||||
}
|
||||
return relay.NewTunnelID(raw[:])
|
||||
}
|
||||
|
||||
// parseUUID is a simplified UUID parser that avoids importing google/uuid.
|
||||
// It parses the standard 8-4-4-4-12 hex format (36 characters) and returns
|
||||
// the 16 raw bytes. An error is returned for invalid length, missing hyphens,
|
||||
// or non-hex characters.
|
||||
func parseUUID(s string) ([]byte, error) {
|
||||
if len(s) != 36 {
|
||||
return nil, fmt.Errorf("invalid UUID length")
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
for i, j := 0, 0; i < 36; i++ {
|
||||
if i == 8 || i == 13 || i == 18 || i == 23 {
|
||||
if s[i] != '-' {
|
||||
return nil, fmt.Errorf("invalid UUID format")
|
||||
}
|
||||
continue
|
||||
}
|
||||
var v byte
|
||||
switch {
|
||||
case s[i] >= '0' && s[i] <= '9':
|
||||
v = s[i] - '0'
|
||||
case s[i] >= 'a' && s[i] <= 'f':
|
||||
v = s[i] - 'a' + 10
|
||||
case s[i] >= 'A' && s[i] <= 'F':
|
||||
v = s[i] - 'A' + 10
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid UUID character")
|
||||
}
|
||||
if j%2 == 0 {
|
||||
b[j/2] = v << 4
|
||||
} else {
|
||||
b[j/2] |= v
|
||||
}
|
||||
j++
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,846 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/ingress"
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/relay"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
xstats "github.com/go-gost/x/observer/stats"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
// test helpers
|
||||
|
||||
type nopLogger struct {
|
||||
logger.Logger
|
||||
}
|
||||
|
||||
func (nopLogger) Debugf(format string, args ...any) {}
|
||||
func (nopLogger) Infof(format string, args ...any) {}
|
||||
func (nopLogger) Warnf(format string, args ...any) {}
|
||||
func (nopLogger) Errorf(format string, args ...any) {}
|
||||
func (nopLogger) Tracef(format string, args ...any) {}
|
||||
func (nopLogger) Debug(args ...any) {}
|
||||
func (nopLogger) Info(args ...any) {}
|
||||
func (nopLogger) Warn(args ...any) {}
|
||||
func (nopLogger) Error(args ...any) {}
|
||||
func (nopLogger) Trace(args ...any) {}
|
||||
func (nopLogger) Fatal(args ...any) {}
|
||||
func (nopLogger) Fatalf(format string, args ...any) {}
|
||||
func (nopLogger) GetLevel() logger.LogLevel { return logger.ErrorLevel }
|
||||
func (nopLogger) IsLevelEnabled(level logger.LogLevel) bool { return false }
|
||||
func (nopLogger) WithFields(fields map[string]any) logger.Logger { return nopLogger{} }
|
||||
|
||||
func testLogger() logger.Logger { return nopLogger{} }
|
||||
|
||||
// mockIngress implements ingress.Ingress with immediate rule lookup.
|
||||
type mockIngress struct {
|
||||
rules map[string]*ingress.Rule
|
||||
}
|
||||
|
||||
func newMockIngress(rules []*ingress.Rule) *mockIngress {
|
||||
m := &mockIngress{rules: make(map[string]*ingress.Rule)}
|
||||
for _, r := range rules {
|
||||
m.rules[r.Hostname] = r
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockIngress) GetRule(ctx context.Context, host string, opts ...ingress.Option) *ingress.Rule {
|
||||
// Strip port like real ingress does
|
||||
if h, _, err := net.SplitHostPort(host); err == nil && h != "" {
|
||||
host = h
|
||||
}
|
||||
return m.rules[host]
|
||||
}
|
||||
|
||||
func (m *mockIngress) SetRule(ctx context.Context, rule *ingress.Rule, opts ...ingress.Option) bool {
|
||||
m.rules[rule.Hostname] = rule
|
||||
return true
|
||||
}
|
||||
|
||||
func newTestConfig() *Config {
|
||||
return &Config{
|
||||
Node: "test-node",
|
||||
Service: "test-service",
|
||||
Logger: testLogger(),
|
||||
Ingress: newMockIngress(nil),
|
||||
ReadTimeout: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
type fakeDialFn struct {
|
||||
conn net.Conn
|
||||
node string
|
||||
cid string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDialFn) dial(ctx DialContext, network, tid string) (net.Conn, string, string, error) {
|
||||
if f.err != nil {
|
||||
return nil, "", "", f.err
|
||||
}
|
||||
if f.conn == nil {
|
||||
return nil, "", "", fmt.Errorf("no connection")
|
||||
}
|
||||
return f.conn, f.node, f.cid, nil
|
||||
}
|
||||
|
||||
type fakeConn struct {
|
||||
net.Conn
|
||||
readBuf []byte
|
||||
offset int
|
||||
writeBuf bytes.Buffer
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *fakeConn) Read(b []byte) (n int, err error) {
|
||||
if c.offset >= len(c.readBuf) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n = copy(b, c.readBuf[c.offset:])
|
||||
c.offset += n
|
||||
return
|
||||
}
|
||||
|
||||
func (c *fakeConn) Write(b []byte) (n int, err error) {
|
||||
return c.writeBuf.Write(b)
|
||||
}
|
||||
|
||||
func (c *fakeConn) Close() error {
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeConn) RemoteAddr() net.Addr {
|
||||
return &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 12345}
|
||||
}
|
||||
|
||||
func (c *fakeConn) LocalAddr() net.Addr {
|
||||
return &net.TCPAddr{IP: net.ParseIP("10.0.0.2"), Port: 8080}
|
||||
}
|
||||
|
||||
func (c *fakeConn) SetReadDeadline(t time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pipeConn is a simple in-memory pipe to simulate bidirectional connections.
|
||||
// It embeds readBuf for tests that need to inject data.
|
||||
type pipeConn struct {
|
||||
readBuf []byte
|
||||
offset int
|
||||
reader *io.PipeReader
|
||||
writer *io.PipeWriter
|
||||
closed bool
|
||||
remote net.Addr
|
||||
local net.Addr
|
||||
}
|
||||
|
||||
func (c *pipeConn) Read(b []byte) (n int, err error) {
|
||||
if c.readBuf != nil && c.offset < len(c.readBuf) {
|
||||
n = copy(b, c.readBuf[c.offset:])
|
||||
c.offset += n
|
||||
return n, nil
|
||||
}
|
||||
if c.reader == nil {
|
||||
return 0, io.EOF
|
||||
}
|
||||
return c.reader.Read(b)
|
||||
}
|
||||
|
||||
func (c *pipeConn) Write(b []byte) (n int, err error) {
|
||||
if c.writer == nil {
|
||||
return len(b), nil
|
||||
}
|
||||
return c.writer.Write(b)
|
||||
}
|
||||
|
||||
func (c *pipeConn) Close() error {
|
||||
if !c.closed {
|
||||
c.closed = true
|
||||
if c.reader != nil {
|
||||
c.reader.Close()
|
||||
}
|
||||
if c.writer != nil {
|
||||
c.writer.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *pipeConn) RemoteAddr() net.Addr {
|
||||
if c.remote != nil {
|
||||
return c.remote
|
||||
}
|
||||
return &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 12345}
|
||||
}
|
||||
|
||||
func (c *pipeConn) LocalAddr() net.Addr {
|
||||
if c.local != nil {
|
||||
return c.local
|
||||
}
|
||||
return &net.TCPAddr{IP: net.ParseIP("10.0.0.2"), Port: 8080}
|
||||
}
|
||||
|
||||
func (c *pipeConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (c *pipeConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
func (c *pipeConn) SetDeadline(t time.Time) error { return nil }
|
||||
|
||||
func newPipePair() (*pipeConn, *pipeConn) {
|
||||
pr1, pw1 := io.Pipe()
|
||||
pr2, pw2 := io.Pipe()
|
||||
a := &pipeConn{reader: pr1, writer: pw2}
|
||||
b := &pipeConn{reader: pr2, writer: pw1}
|
||||
return a, b
|
||||
}
|
||||
|
||||
// buildRelayConnectRequest builds a relay connect request for the entrypoint's
|
||||
// handleConnect path.
|
||||
func buildRelayConnectRequest(t *testing.T, tid relay.TunnelID, src, dst string) []byte {
|
||||
t.Helper()
|
||||
req := relay.Request{
|
||||
Version: relay.Version1,
|
||||
Cmd: relay.CmdConnect,
|
||||
}
|
||||
if src != "" {
|
||||
af := &relay.AddrFeature{}
|
||||
af.ParseFrom(src)
|
||||
req.Features = append(req.Features, af)
|
||||
}
|
||||
if dst != "" {
|
||||
af := &relay.AddrFeature{}
|
||||
af.ParseFrom(dst)
|
||||
req.Features = append(req.Features, af)
|
||||
}
|
||||
req.Features = append(req.Features, &relay.TunnelFeature{ID: tid})
|
||||
var buf bytes.Buffer
|
||||
_, err := req.WriteTo(&buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
// parseUUID tests
|
||||
|
||||
func TestParseUUID(t *testing.T) {
|
||||
t.Run("valid uuid", func(t *testing.T) {
|
||||
b, err := parseUUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(b) != 16 {
|
||||
t.Errorf("expected 16 bytes, got %d", len(b))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid length", func(t *testing.T) {
|
||||
_, err := parseUUID("short")
|
||||
if err == nil {
|
||||
t.Error("expected error for short string")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing hyphens", func(t *testing.T) {
|
||||
_, err := parseUUID("6ba7b8109dad11d180b400c04fd430c8")
|
||||
if err == nil {
|
||||
t.Error("expected error without hyphens")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-hex character", func(t *testing.T) {
|
||||
_, err := parseUUID("6ba7b810-9dad-11d1-80b4-00c04fd430zz")
|
||||
if err == nil {
|
||||
t.Error("expected error for non-hex characters")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uppercase hex", func(t *testing.T) {
|
||||
b, err := parseUUID("6BA7B810-9DAD-11D1-80B4-00C04FD430C8")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(b) != 16 {
|
||||
t.Errorf("expected 16 bytes, got %d", len(b))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// parseTunnelID tests
|
||||
|
||||
func TestParseTunnelID(t *testing.T) {
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
tid := parseTunnelID("")
|
||||
if !tid.IsZero() {
|
||||
t.Error("expected zero tunnel ID for empty string")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid uuid", func(t *testing.T) {
|
||||
tid := parseTunnelID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
|
||||
if tid.IsZero() {
|
||||
t.Error("expected non-zero tunnel ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("private tunnel ($ prefix)", func(t *testing.T) {
|
||||
tid := parseTunnelID("$6ba7b810-9dad-11d1-80b4-00c04fd430c8")
|
||||
if !tid.IsPrivate() {
|
||||
t.Error("expected private tunnel ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid uuid returns zero", func(t *testing.T) {
|
||||
tid := parseTunnelID("not-a-uuid-at-all")
|
||||
if !tid.IsZero() {
|
||||
t.Error("expected zero tunnel ID for invalid input")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid uuid with $ prefix", func(t *testing.T) {
|
||||
tid := parseTunnelID("$not-a-uuid-at-all")
|
||||
if !tid.IsZero() {
|
||||
t.Error("expected zero tunnel ID for invalid input")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// New tests
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Run("creates entrypoint with transport", func(t *testing.T) {
|
||||
dialFn := func(ctx DialContext, network, tid string) (net.Conn, string, string, error) {
|
||||
return nil, "", "", nil
|
||||
}
|
||||
ep := New(newTestConfig(), dialFn)
|
||||
if ep == nil {
|
||||
t.Fatal("expected non-nil entrypoint")
|
||||
}
|
||||
if ep.transport == nil {
|
||||
t.Error("expected non-nil transport")
|
||||
}
|
||||
if ep.node != "test-node" {
|
||||
t.Errorf("expected node test-node, got %s", ep.node)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Handle tests
|
||||
|
||||
func TestHandle_PeekError(t *testing.T) {
|
||||
dialFn := func(ctx DialContext, network, tid string) (net.Conn, string, string, error) {
|
||||
return nil, "", "", nil
|
||||
}
|
||||
ep := New(newTestConfig(), dialFn)
|
||||
|
||||
// Empty connection should fail peek
|
||||
conn := &fakeConn{readBuf: []byte{}}
|
||||
err := ep.Handle(context.Background(), conn)
|
||||
if err == nil {
|
||||
t.Error("expected error from empty connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandle_HTTP_Path(t *testing.T) {
|
||||
ing := newMockIngress([]*ingress.Rule{
|
||||
{Hostname: "example.com", Endpoint: "6ba7b810-9dad-11d1-80b4-00c04fd430c8"},
|
||||
})
|
||||
cfg := newTestConfig()
|
||||
cfg.Ingress = ing
|
||||
|
||||
client, server := newPipePair()
|
||||
dialFn := func(ctx DialContext, network, tid string) (net.Conn, string, string, error) {
|
||||
return client, "test-node", "cid1", nil
|
||||
}
|
||||
ep := New(cfg, dialFn)
|
||||
|
||||
httpReq := "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
conn := &fakeConn{readBuf: []byte(httpReq)}
|
||||
|
||||
go func() {
|
||||
// Server side: read the relay StatusOK response with address features
|
||||
resp := relay.Response{}
|
||||
_, err := resp.ReadFrom(server)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
server.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"))
|
||||
server.Close()
|
||||
}()
|
||||
|
||||
err := ep.Handle(context.Background(), conn)
|
||||
// Pipe will close when the server side closes; Handle may return nil or EOF
|
||||
t.Logf("Handle HTTP returned: %v", err)
|
||||
}
|
||||
|
||||
func TestHandle_Relay_Path(t *testing.T) {
|
||||
dialFn := func(ctx DialContext, network, tid string) (net.Conn, string, string, error) {
|
||||
client, server := newPipePair()
|
||||
go func() {
|
||||
// Read the StatusOK on the mux-stream (reply from public node)
|
||||
resp := relay.Response{}
|
||||
resp.ReadFrom(server)
|
||||
server.Close()
|
||||
}()
|
||||
return client, "test-node", "cid1", nil
|
||||
}
|
||||
|
||||
tid := relay.NewTunnelID(func() []byte {
|
||||
u, _ := parseUUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
|
||||
return u
|
||||
}())
|
||||
|
||||
ep := New(newTestConfig(), dialFn)
|
||||
|
||||
relayData := buildRelayConnectRequest(t, tid, "10.0.0.1:12345", "192.168.1.1:80")
|
||||
conn := &fakeConn{readBuf: relayData}
|
||||
|
||||
err := ep.Handle(context.Background(), conn)
|
||||
// Expected: relay path processes, pipes, then returns
|
||||
t.Logf("Handle relay path returned: %v", err)
|
||||
}
|
||||
|
||||
func TestHandle_Relay_NoTunnelID(t *testing.T) {
|
||||
dialFn := func(ctx DialContext, network, tid string) (net.Conn, string, string, error) {
|
||||
return nil, "", "", nil
|
||||
}
|
||||
ep := New(newTestConfig(), dialFn)
|
||||
|
||||
// Relay request without tunnel feature
|
||||
req := relay.Request{
|
||||
Version: relay.Version1,
|
||||
Cmd: relay.CmdConnect,
|
||||
Features: []relay.Feature{
|
||||
&relay.AddrFeature{Host: "10.0.0.1", Port: 12345},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
req.WriteTo(&buf)
|
||||
|
||||
conn := &fakeConn{readBuf: buf.Bytes()}
|
||||
err := ep.Handle(context.Background(), conn)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing tunnel ID")
|
||||
}
|
||||
}
|
||||
|
||||
// dial tests
|
||||
|
||||
func TestDial_NoIngress(t *testing.T) {
|
||||
ep := New(newTestConfig(), nil)
|
||||
_, err := ep.dial(context.Background(), "tcp", "example.com:80")
|
||||
if err == nil {
|
||||
t.Error("expected error for host not in ingress")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no route to host") {
|
||||
t.Errorf("expected 'no route to host', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_PrivateTunnel(t *testing.T) {
|
||||
ing := newMockIngress([]*ingress.Rule{
|
||||
{Hostname: "internal.example.com", Endpoint: "$6ba7b810-9dad-11d1-80b4-00c04fd430c8"},
|
||||
})
|
||||
cfg := newTestConfig()
|
||||
cfg.Ingress = ing
|
||||
ep := New(cfg, nil)
|
||||
|
||||
_, err := ep.dial(context.Background(), "tcp", "internal.example.com:80")
|
||||
if err == nil {
|
||||
t.Error("expected error for private tunnel")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "private tunnel") {
|
||||
t.Errorf("expected 'private tunnel', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_NilDialFn(t *testing.T) {
|
||||
ing := newMockIngress([]*ingress.Rule{
|
||||
{Hostname: "example.com", Endpoint: "6ba7b810-9dad-11d1-80b4-00c04fd430c8"},
|
||||
})
|
||||
cfg := newTestConfig()
|
||||
cfg.Ingress = ing
|
||||
ep := New(cfg, nil)
|
||||
|
||||
_, err := ep.dial(context.Background(), "tcp", "example.com:80")
|
||||
if err == nil {
|
||||
t.Error("expected error for nil dialFn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_DialFnError(t *testing.T) {
|
||||
ing := newMockIngress([]*ingress.Rule{
|
||||
{Hostname: "example.com", Endpoint: "6ba7b810-9dad-11d1-80b4-00c04fd430c8"},
|
||||
})
|
||||
cfg := newTestConfig()
|
||||
cfg.Ingress = ing
|
||||
ep := New(cfg, func(ctx DialContext, network, tid string) (net.Conn, string, string, error) {
|
||||
return nil, "", "", fmt.Errorf("dial failed")
|
||||
})
|
||||
|
||||
_, err := ep.dial(context.Background(), "tcp", "example.com:80")
|
||||
if err == nil {
|
||||
t.Error("expected error from dialFn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_RemoteNode(t *testing.T) {
|
||||
ing := newMockIngress([]*ingress.Rule{
|
||||
{Hostname: "example.com", Endpoint: "6ba7b810-9dad-11d1-80b4-00c04fd430c8"},
|
||||
})
|
||||
cfg := newTestConfig()
|
||||
cfg.Ingress = ing
|
||||
client, _ := net.Pipe()
|
||||
ep := New(cfg, func(ctx DialContext, network, tid string) (net.Conn, string, string, error) {
|
||||
return client, "remote-node", "cid1", nil
|
||||
})
|
||||
|
||||
conn, err := ep.dial(context.Background(), "tcp", "example.com:80")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Error("expected non-nil connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDial_LocalNode(t *testing.T) {
|
||||
ing := newMockIngress([]*ingress.Rule{
|
||||
{Hostname: "example.com", Endpoint: "6ba7b810-9dad-11d1-80b4-00c04fd430c8"},
|
||||
})
|
||||
cfg := newTestConfig()
|
||||
cfg.Ingress = ing
|
||||
client, server := net.Pipe()
|
||||
ep := New(cfg, func(ctx DialContext, network, tid string) (net.Conn, string, string, error) {
|
||||
return client, "test-node", "cid1", nil
|
||||
})
|
||||
|
||||
// For local node, expect relay StatusOK + address features written to stream
|
||||
go func() {
|
||||
resp := relay.Response{}
|
||||
_, err := resp.ReadFrom(server)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
server.Close()
|
||||
}()
|
||||
|
||||
// Create a context with a source address so the local-node path
|
||||
// doesn't fail on ParseFrom("")
|
||||
ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 12345})
|
||||
conn, err := ep.dial(ctx, "tcp", "example.com:80")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Error("expected non-nil connection")
|
||||
}
|
||||
}
|
||||
|
||||
// handleConnect tests
|
||||
|
||||
func TestHandleConnect_DialFnError(t *testing.T) {
|
||||
dialFn := func(ctx DialContext, network, tid string) (net.Conn, string, string, error) {
|
||||
return nil, "", "", fmt.Errorf("dial error")
|
||||
}
|
||||
ep := New(newTestConfig(), dialFn)
|
||||
|
||||
tid := relay.NewTunnelID(func() []byte {
|
||||
u, _ := parseUUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
|
||||
return u
|
||||
}())
|
||||
|
||||
conn := &fakeConn{readBuf: buildRelayConnectRequest(t, tid, "10.0.0.1:12345", "")}
|
||||
|
||||
// Use handleConnect directly (not via Handle dispatch)
|
||||
err := ep.handleConnect(context.Background(), conn, &xrecorder.HandlerRecorderObject{}, testLogger())
|
||||
if err == nil {
|
||||
t.Error("expected error from dial failure")
|
||||
}
|
||||
}
|
||||
|
||||
// handleHTTP tests
|
||||
|
||||
func TestHandleHTTP_BadRequest(t *testing.T) {
|
||||
ep := New(newTestConfig(), nil)
|
||||
|
||||
// Bad HTTP data
|
||||
conn := &fakeConn{readBuf: []byte("NOT HTTP\r\n")}
|
||||
|
||||
err := ep.handleHTTP(context.Background(), conn, &xrecorder.HandlerRecorderObject{}, testLogger())
|
||||
if err == nil {
|
||||
t.Error("expected error for bad HTTP request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPRoundTrip_LoopDetection(t *testing.T) {
|
||||
ep := New(newTestConfig(), nil)
|
||||
ep.node = "test-node"
|
||||
|
||||
// Request with our own node in Gost-Forwarded-Node header
|
||||
req, _ := http.NewRequest("GET", "http://example.com/", nil)
|
||||
req.Header.Set("Gost-Forwarded-Node", "test-node")
|
||||
|
||||
rw := &pipeConn{}
|
||||
ro := &xrecorder.HandlerRecorderObject{
|
||||
HTTP: &xrecorder.HTTPRecorderObject{},
|
||||
}
|
||||
pStats := xstats.Stats{}
|
||||
|
||||
err := ep.httpRoundTrip(context.Background(), rw, req, ro, &pStats, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ro.HTTP.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", ro.HTTP.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPRoundTrip_NoRoute(t *testing.T) {
|
||||
dialFn := func(ctx DialContext, network, tid string) (net.Conn, string, string, error) {
|
||||
return nil, "", "", errNoRoute
|
||||
}
|
||||
ep := New(newTestConfig(), dialFn)
|
||||
|
||||
req, _ := http.NewRequest("GET", "http://unknown.example.com/", nil)
|
||||
rw := &fakeConn{}
|
||||
ro := &xrecorder.HandlerRecorderObject{
|
||||
HTTP: &xrecorder.HTTPRecorderObject{},
|
||||
}
|
||||
pStats := xstats.Stats{}
|
||||
|
||||
err := ep.httpRoundTrip(context.Background(), rw, req, ro, &pStats, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
resp, err := http.ReadResponse(bufio.NewReader(&rw.writeBuf), req)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("expected 502, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeType(t *testing.T) {
|
||||
t.Run("no upgrade header", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
if upgradeType(h) != "" {
|
||||
t.Error("expected empty for no upgrade header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("has upgrade", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Connection", "Upgrade")
|
||||
h.Set("Upgrade", "websocket")
|
||||
if upgradeType(h) != "websocket" {
|
||||
t.Errorf("expected 'websocket', got '%s'", upgradeType(h))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upgrade with multiple connection tokens", func(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Connection", "keep-alive, Upgrade")
|
||||
h.Set("Upgrade", "websocket")
|
||||
if upgradeType(h) != "websocket" {
|
||||
t.Errorf("expected 'websocket', got '%s'", upgradeType(h))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleUpgradeResponse_MismatchedUpgrade(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "http://example.com/", nil)
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
|
||||
res := &http.Response{
|
||||
StatusCode: http.StatusSwitchingProtocols,
|
||||
Header: http.Header{},
|
||||
Body: &pipeConn{},
|
||||
}
|
||||
res.Header.Set("Connection", "Upgrade")
|
||||
res.Header.Set("Upgrade", "h2c") // mismatch
|
||||
|
||||
ep := New(newTestConfig(), nil)
|
||||
err := ep.handleUpgradeResponse(context.Background(), &pipeConn{}, req, res, &xrecorder.HandlerRecorderObject{}, testLogger())
|
||||
if err == nil {
|
||||
t.Error("expected error for mismatched upgrade protocol")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpgradeResponse_NonWritableBody(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "http://example.com/", nil)
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
|
||||
res := &http.Response{
|
||||
StatusCode: http.StatusSwitchingProtocols,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader("not writable")),
|
||||
}
|
||||
res.Header.Set("Connection", "Upgrade")
|
||||
res.Header.Set("Upgrade", "websocket")
|
||||
|
||||
ep := New(newTestConfig(), nil)
|
||||
err := ep.handleUpgradeResponse(context.Background(), &pipeConn{}, req, res, &xrecorder.HandlerRecorderObject{}, testLogger())
|
||||
if err == nil {
|
||||
t.Error("expected error for non-writable body")
|
||||
}
|
||||
}
|
||||
|
||||
// eplistener tests
|
||||
|
||||
func TestTCPListener_Init_Accept_Close(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tcpLn := NewTCPListener(ln)
|
||||
addr := tcpLn.Addr()
|
||||
if addr == nil {
|
||||
t.Error("expected non-nil addr")
|
||||
}
|
||||
|
||||
err = tcpLn.Init(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
err = tcpLn.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// entrypointHandler tests
|
||||
|
||||
func TestNewEntrypointHandler(t *testing.T) {
|
||||
ep := New(newTestConfig(), nil)
|
||||
h := NewHandler(ep)
|
||||
if h == nil {
|
||||
t.Fatal("expected non-nil handler")
|
||||
}
|
||||
|
||||
err := h.Init(nil)
|
||||
if err != nil {
|
||||
t.Errorf("Init should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// copyWebsocketFrame tests
|
||||
|
||||
func TestCopyWebsocketFrame_NoRecorder(t *testing.T) {
|
||||
ep := New(newTestConfig(), nil)
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
// Build a minimal WebSocket frame (FIN=1, opcode=1 (text), masked, length=5)
|
||||
frame := wsFrame(t, true, 1, true, []byte("hello"))
|
||||
r := bytes.NewReader(frame)
|
||||
w := &bytes.Buffer{}
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
err := ep.copyWebsocketFrame(w, r, buf, "client", ro)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify output matches input
|
||||
if !bytes.Equal(frame, w.Bytes()) {
|
||||
t.Error("output does not match input")
|
||||
}
|
||||
}
|
||||
|
||||
// wsFrame builds a minimal unmasked WebSocket frame.
|
||||
func wsFrame(t *testing.T, fin bool, opCode byte, masked bool, payload []byte) []byte {
|
||||
t.Helper()
|
||||
var header byte
|
||||
if fin {
|
||||
header |= 0x80
|
||||
}
|
||||
header |= opCode & 0x0f
|
||||
|
||||
var maskBit byte
|
||||
if masked {
|
||||
maskBit = 0x80
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte(header)
|
||||
|
||||
if len(payload) < 126 {
|
||||
buf.WriteByte(byte(len(payload)) | maskBit)
|
||||
} else if len(payload) < 65536 {
|
||||
buf.WriteByte(126 | maskBit)
|
||||
buf.WriteByte(byte(len(payload) >> 8))
|
||||
buf.WriteByte(byte(len(payload)))
|
||||
} else {
|
||||
buf.WriteByte(127 | maskBit)
|
||||
for i := 7; i >= 0; i-- {
|
||||
buf.WriteByte(byte(uint64(len(payload)) >> (8 * i)))
|
||||
}
|
||||
}
|
||||
|
||||
if masked {
|
||||
maskKey := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
buf.Write(maskKey)
|
||||
for i, p := range payload {
|
||||
buf.WriteByte(p ^ maskKey[i%4])
|
||||
}
|
||||
} else {
|
||||
buf.Write(payload)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// sniffingWebsocketFrame test
|
||||
func TestEntrypoint_WebsocketSniffingCodePaths(t *testing.T) {
|
||||
// Verify that the copyWebsocketFrame function handles both client and server
|
||||
// directions and properly tracks InputBytes/OutputBytes.
|
||||
buf := &bytes.Buffer{}
|
||||
r := bytes.NewReader(wsFrame(t, true, 1, false, []byte("hello")))
|
||||
w := &bytes.Buffer{}
|
||||
ro := &xrecorder.HandlerRecorderObject{}
|
||||
|
||||
ep := New(newTestConfig(), nil)
|
||||
err := ep.copyWebsocketFrame(w, r, buf, "client", ro)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ro.InputBytes == 0 {
|
||||
t.Error("expected non-zero InputBytes for client direction")
|
||||
}
|
||||
if ro.OutputBytes != 0 {
|
||||
t.Error("expected 0 OutputBytes for client direction")
|
||||
}
|
||||
|
||||
// Server direction
|
||||
r2 := bytes.NewReader(wsFrame(t, true, 1, false, []byte("world")))
|
||||
w2 := &bytes.Buffer{}
|
||||
ro2 := &xrecorder.HandlerRecorderObject{}
|
||||
err = ep.copyWebsocketFrame(w2, r2, buf, "server", ro2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ro2.OutputBytes == 0 {
|
||||
t.Error("expected non-zero OutputBytes for server direction")
|
||||
}
|
||||
if ro2.InputBytes != 0 {
|
||||
t.Error("expected 0 InputBytes for server direction")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/core/observer/stats"
|
||||
xctx "github.com/go-gost/x/ctx"
|
||||
ictx "github.com/go-gost/x/internal/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"
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
// HTTP header names used for tunnel loop detection and session tracking.
|
||||
const (
|
||||
// httpHeaderSID carries the session ID from the external request
|
||||
// through the tunnel to the internal service.
|
||||
httpHeaderSID = "Gost-Sid"
|
||||
// httpHeaderForwardedNode carries the node ID chain from each
|
||||
// entrypoint the request has traversed. Used for loop detection:
|
||||
// if the header already contains our node, the request is looping
|
||||
// and is rejected with 503.
|
||||
httpHeaderForwardedNode = "Gost-Forwarded-Node"
|
||||
)
|
||||
|
||||
// handleHTTP processes an HTTP request arriving at the entrypoint.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Read HTTP request from the public connection.
|
||||
// 2. Record request metadata in ro.HTTP.
|
||||
// 3. httpRoundTrip: shallow-copy ro, check forwarding loop,
|
||||
// build http.Request, call ep.transport.RoundTrip() which
|
||||
// uses ep.dial() as DialContext — that resolves ingress rules
|
||||
// to a tunnelID, calls Dialer.Dial(), and writes relay address
|
||||
// features into the mux stream.
|
||||
// 4. Handle WebSocket upgrade via handleUpgradeResponse (sniffing).
|
||||
// 5. Continue in a loop for keep-alive: read next request, repeat.
|
||||
//
|
||||
// HTTP request body recording: if recorder options specify HTTPBody,
|
||||
// the request body is wrapped in a xhttp.Body for capture.
|
||||
func (ep *Entrypoint) handleHTTP(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger) (err error) {
|
||||
pStats := xstats.Stats{}
|
||||
conn = stats_wrapper.WrapConn(conn, &pStats)
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if log.IsLevelEnabled(logger.TraceLevel) {
|
||||
dump, _ := httputil.DumpRequest(req, false)
|
||||
log.Trace(string(dump))
|
||||
}
|
||||
|
||||
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(),
|
||||
},
|
||||
}
|
||||
|
||||
ro.Time = time.Time{}
|
||||
|
||||
if err := ep.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), req, ro, &pStats, log); err != nil {
|
||||
log.Error(err)
|
||||
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 err := ep.httpRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), req, ro, &pStats, log); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ep *Entrypoint) httpRoundTrip(ctx context.Context, rw io.ReadWriteCloser, req *http.Request, ro *xrecorder.HandlerRecorderObject, pStats stats.Stats, log logger.Logger) (err error) {
|
||||
ro2 := &xrecorder.HandlerRecorderObject{}
|
||||
*ro2 = *ro
|
||||
ro = ro2
|
||||
|
||||
if sid := req.Header.Get(httpHeaderSID); sid != "" {
|
||||
ro.SID = sid
|
||||
} else {
|
||||
req.Header.Set(httpHeaderSID, ro.SID)
|
||||
}
|
||||
|
||||
// Loop detection: if Gost-Forwarded-Node contains our own node ID,
|
||||
// the request has already passed through this entrypoint and is looping.
|
||||
for _, node := range strings.Split(req.Header.Get(httpHeaderForwardedNode), ",") {
|
||||
if strings.TrimSpace(node) == ep.node {
|
||||
log.Warn("forwarding loop detected, rejecting request")
|
||||
res := &http.Response{
|
||||
ProtoMajor: req.ProtoMajor,
|
||||
ProtoMinor: req.ProtoMinor,
|
||||
Header: http.Header{},
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
ro.HTTP.StatusCode = res.StatusCode
|
||||
res.Write(rw)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set(httpHeaderForwardedNode, ro.Node)
|
||||
|
||||
host := req.Host
|
||||
if _, port, _ := net.SplitHostPort(host); port == "" {
|
||||
host = net.JoinHostPort(strings.Trim(host, "[]"), "80")
|
||||
}
|
||||
ro.Host = host
|
||||
ro.Time = time.Now()
|
||||
|
||||
log = log.WithFields(map[string]any{
|
||||
"host": host,
|
||||
})
|
||||
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 err := ro.Record(ctx, ep.recorder.Recorder); err != nil {
|
||||
log.Errorf("record: %v", err)
|
||||
}
|
||||
|
||||
log.WithFields(map[string]any{
|
||||
"duration": time.Since(ro.Time),
|
||||
"inputBytes": ro.InputBytes,
|
||||
"outputBytes": ro.OutputBytes,
|
||||
}).Infof("%s >-< %s", ro.RemoteAddr, req.Host)
|
||||
}()
|
||||
|
||||
if req.URL.Scheme == "" {
|
||||
req.URL.Scheme = "http"
|
||||
}
|
||||
if req.URL.Host == "" {
|
||||
req.URL.Host = 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,
|
||||
}
|
||||
ro.HTTP.StatusCode = res.StatusCode
|
||||
|
||||
var reqBody *xhttp.Body
|
||||
if opts := ep.recorder.Options; opts != nil && opts.HTTPBody {
|
||||
if req.Body != nil {
|
||||
bodySize := opts.MaxBodySize
|
||||
if bodySize <= 0 {
|
||||
bodySize = sniffing.DefaultBodySize
|
||||
}
|
||||
if bodySize > sniffing.MaxBodySize {
|
||||
bodySize = sniffing.MaxBodySize
|
||||
}
|
||||
reqBody = xhttp.NewBody(req.Body, bodySize)
|
||||
req.Body = reqBody
|
||||
}
|
||||
}
|
||||
|
||||
if clientIP := xhttp.GetClientIP(req); clientIP != nil {
|
||||
ro.ClientIP = clientIP.String()
|
||||
ctx = xctx.ContextWithSrcAddr(ctx, (&net.TCPAddr{IP: clientIP}))
|
||||
}
|
||||
|
||||
ctx = ictx.ContextWithRecorderObject(ctx, ro)
|
||||
ctx = ictx.ContextWithLogger(ctx, log)
|
||||
|
||||
resp, err := ep.transport.RoundTrip(req.WithContext(ctx))
|
||||
|
||||
if reqBody != nil {
|
||||
ro.HTTP.Request.Body = reqBody.Content()
|
||||
ro.HTTP.Request.ContentLength = reqBody.Length()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, errNoRoute) || errors.Is(err, errPrivateTunnel) {
|
||||
res.StatusCode = http.StatusBadGateway
|
||||
ro.HTTP.StatusCode = http.StatusBadGateway
|
||||
}
|
||||
res.Write(rw)
|
||||
return nil
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusSwitchingProtocols {
|
||||
return ep.handleUpgradeResponse(ctx, rw, req, resp, ro, log)
|
||||
}
|
||||
|
||||
var respBody *xhttp.Body
|
||||
if opts := ep.recorder.Options; opts != nil && opts.HTTPBody {
|
||||
bodySize := opts.MaxBodySize
|
||||
if bodySize <= 0 {
|
||||
bodySize = sniffing.DefaultBodySize
|
||||
}
|
||||
if bodySize > sniffing.MaxBodySize {
|
||||
bodySize = sniffing.MaxBodySize
|
||||
}
|
||||
respBody = xhttp.NewBody(resp.Body, bodySize)
|
||||
resp.Body = respBody
|
||||
}
|
||||
|
||||
err = resp.Write(rw)
|
||||
|
||||
if respBody != nil {
|
||||
ro.HTTP.Response.Body = respBody.Content()
|
||||
ro.HTTP.Response.ContentLength = respBody.Length()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("write response: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// upgradeType returns the upgrade protocol from an HTTP header set,
|
||||
// or empty string if the request/response does not include an Upgrade.
|
||||
func upgradeType(h http.Header) string {
|
||||
if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
|
||||
return ""
|
||||
}
|
||||
return h.Get("Upgrade")
|
||||
}
|
||||
|
||||
// handleUpgradeResponse handles an HTTP upgrade (101 Switching Protocols).
|
||||
//
|
||||
// It validates that the upgrade protocol matches between request and response,
|
||||
// writes the response to the client, and then pipes the raw connection.
|
||||
// For WebSocket upgrades with sniffing enabled, it delegates to
|
||||
// sniffingWebsocketFrame for frame-level recording.
|
||||
func (ep *Entrypoint) handleUpgradeResponse(ctx context.Context, rw 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)
|
||||
}
|
||||
|
||||
backConn, ok := res.Body.(io.ReadWriteCloser)
|
||||
if !ok {
|
||||
return fmt.Errorf("internal error: 101 switching protocols response with non-writable body")
|
||||
}
|
||||
defer backConn.Close()
|
||||
|
||||
res.Body = nil
|
||||
if err := res.Write(rw); err != nil {
|
||||
return fmt.Errorf("response write: %v", err)
|
||||
}
|
||||
|
||||
if reqUpType == "websocket" && ep.sniffingWebsocket {
|
||||
return ep.sniffingWebsocketFrame(ctx, rw, backConn, ro, log)
|
||||
}
|
||||
|
||||
return xnet.Pipe(ctx, rw, backConn)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/handler"
|
||||
"github.com/go-gost/core/listener"
|
||||
md "github.com/go-gost/core/metadata"
|
||||
admission "github.com/go-gost/x/admission/wrapper"
|
||||
"github.com/go-gost/x/internal/net/proxyproto"
|
||||
climiter "github.com/go-gost/x/limiter/conn/wrapper"
|
||||
metrics "github.com/go-gost/x/metrics/wrapper"
|
||||
)
|
||||
|
||||
// entrypointHandler wraps an Entrypoint as a handler.Handler for use with
|
||||
// the service framework (xservice.NewService).
|
||||
//
|
||||
// The handler simply delegates Handle() to ep.Handle() — all protocol
|
||||
// dispatch, ingress resolution, and tunnel dialing are handled inside
|
||||
// the Entrypoint.
|
||||
type entrypointHandler struct {
|
||||
ep *Entrypoint
|
||||
}
|
||||
|
||||
// NewHandler wraps an Entrypoint as a handler.Handler.
|
||||
//
|
||||
// The returned handler's Init is a no-op (the Entrypoint is fully
|
||||
// initialized by New()). Handle delegates to ep.Handle.
|
||||
func NewHandler(ep *Entrypoint) handler.Handler {
|
||||
return &entrypointHandler{ep: ep}
|
||||
}
|
||||
|
||||
func (h *entrypointHandler) Init(md md.Metadata) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (h *entrypointHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.HandleOption) error {
|
||||
return h.ep.Handle(ctx, conn)
|
||||
}
|
||||
|
||||
// tcpListener wraps a raw net.Listener with GOST listener wrappers in
|
||||
// the standard order: proxyproto → metrics → admission → conn limiter.
|
||||
//
|
||||
// The listener wrappers are applied in Init(), not at construction time,
|
||||
// to match the GOST service lifecycle pattern.
|
||||
type tcpListener struct {
|
||||
ln net.Listener
|
||||
options listener.Options
|
||||
}
|
||||
|
||||
// NewTCPListener creates a new TCP listener wrapper around a raw net.Listener.
|
||||
//
|
||||
// Imports the proxyproto, metrics, admission, and conn-limiter wrappers
|
||||
// in the standard GOST listener order (see architecture docs). Options
|
||||
// carry the service name, proxy protocol version, and optional admission/
|
||||
// conn-limit config.
|
||||
func NewTCPListener(ln net.Listener, opts ...listener.Option) listener.Listener {
|
||||
options := listener.Options{}
|
||||
for _, opt := range opts {
|
||||
opt(&options)
|
||||
}
|
||||
return &tcpListener{
|
||||
ln: ln,
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// Init wraps the raw listener with GOST middleware layers:
|
||||
// 1. proxyproto.WrapListener — PROXY protocol v1/v2 support
|
||||
// 2. metrics.WrapListener — per-connection metrics
|
||||
// 3. admission.WrapListener — IP admission control
|
||||
// 4. climiter.WrapListener — concurrent connection limiting
|
||||
//
|
||||
// This ordering matches the GOST convention: metrics/observability
|
||||
// outermost so dead-on-arrival connections are still counted.
|
||||
func (l *tcpListener) Init(md md.Metadata) (err error) {
|
||||
ln := l.ln
|
||||
ln = proxyproto.WrapListener(l.options.ProxyProtocol, ln, 10*time.Second)
|
||||
ln = metrics.WrapListener(l.options.Service, ln)
|
||||
ln = admission.WrapListener(l.options.Service, l.options.Admission, ln)
|
||||
ln = climiter.WrapListener(l.options.ConnLimiter, ln)
|
||||
l.ln = ln
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Accept accepts the next connection from the wrapped listener.
|
||||
// Implements listener.Listener.
|
||||
func (l *tcpListener) Accept() (conn net.Conn, err error) {
|
||||
return l.ln.Accept()
|
||||
}
|
||||
|
||||
// Addr returns the listener's network address.
|
||||
// Implements listener.Listener.
|
||||
func (l *tcpListener) Addr() net.Addr {
|
||||
return l.ln.Addr()
|
||||
}
|
||||
|
||||
// Close closes the wrapped listener.
|
||||
// Implements listener.Listener.
|
||||
func (l *tcpListener) Close() error {
|
||||
return l.ln.Close()
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
"github.com/go-gost/relay"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
// handleConnect (entrypoint relay) processes a relay-protocol connection
|
||||
// arriving at the entrypoint.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Read relay.Request (extracts src/dst address, tunnelID, network).
|
||||
// 2. Dialer.Dial() → pool.Get() → GetConn() → mux.OpenStream().
|
||||
// 3. Write StatusOK response to the public connection.
|
||||
// 4. Write relay.Response with src/dst address features to the mux stream.
|
||||
// 5. Pipe(publicConn, muxStream).
|
||||
//
|
||||
// Unlike tunnelHandler.handleConnect, this path does not use ingress routing
|
||||
// or bypass checks — the relay request already contains the tunnel ID.
|
||||
// Also unlike handleConnect, there is no local-vs-remote framing difference:
|
||||
// the entrypoint always writes StatusOK + address features regardless of node.
|
||||
func (ep *Entrypoint) handleConnect(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger) (err error) {
|
||||
req := relay.Request{}
|
||||
if _, err := req.ReadFrom(conn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := relay.Response{
|
||||
Version: relay.Version1,
|
||||
Status: relay.StatusOK,
|
||||
}
|
||||
|
||||
var srcAddr, dstAddr string
|
||||
network := "tcp"
|
||||
var tunnelID relay.TunnelID
|
||||
for _, f := range req.Features {
|
||||
switch f.Type() {
|
||||
case relay.FeatureAddr:
|
||||
if feature, _ := f.(*relay.AddrFeature); feature != nil {
|
||||
v := net.JoinHostPort(feature.Host, strconv.Itoa(int(feature.Port)))
|
||||
if srcAddr != "" {
|
||||
dstAddr = v
|
||||
} else {
|
||||
srcAddr = v
|
||||
}
|
||||
}
|
||||
case relay.FeatureTunnel:
|
||||
if feature, _ := f.(*relay.TunnelFeature); feature != nil {
|
||||
tunnelID = relay.NewTunnelID(feature.ID[:])
|
||||
}
|
||||
case relay.FeatureNetwork:
|
||||
if feature, _ := f.(*relay.NetworkFeature); feature != nil {
|
||||
network = feature.Network.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tunnelID.IsZero() {
|
||||
resp.Status = relay.StatusBadRequest
|
||||
resp.WriteTo(conn)
|
||||
return errBadTunnelID
|
||||
}
|
||||
|
||||
ro.ClientID = tunnelID.String()
|
||||
|
||||
cc, _, cid, err := ep.dialFn(ctx, network, tunnelID.String())
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
resp.Status = relay.StatusServiceUnavailable
|
||||
resp.WriteTo(conn)
|
||||
return err
|
||||
}
|
||||
defer cc.Close()
|
||||
|
||||
log.Debugf("new connection to tunnel: %s, connector: %s", tunnelID, cid)
|
||||
|
||||
if _, err := resp.WriteTo(conn); err != nil {
|
||||
log.Error(err)
|
||||
return err
|
||||
}
|
||||
|
||||
features := relay.Response{
|
||||
Version: relay.Version1,
|
||||
Status: relay.StatusOK,
|
||||
}
|
||||
|
||||
af := &relay.AddrFeature{}
|
||||
af.ParseFrom(srcAddr)
|
||||
features.Features = append(features.Features, af) // src address
|
||||
|
||||
af = &relay.AddrFeature{}
|
||||
af.ParseFrom(dstAddr)
|
||||
features.Features = append(features.Features, af) // dst address
|
||||
|
||||
if _, err := features.WriteTo(cc); err != nil {
|
||||
log.Error(err)
|
||||
cc.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
t := time.Now()
|
||||
log.Debugf("%s <-> %s", conn.RemoteAddr(), cc.RemoteAddr())
|
||||
// xnet.Transport(conn, cc)
|
||||
xnet.Pipe(ctx, conn, cc)
|
||||
log.WithFields(map[string]any{
|
||||
"duration": time.Since(t),
|
||||
}).Debugf("%s >-< %s", conn.RemoteAddr(), cc.RemoteAddr())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// errBadTunnelID is returned when a relay request arrives at the entrypoint
|
||||
// without a valid tunnel feature (zero tunnel ID).
|
||||
var errBadTunnelID = fmt.Errorf("invalid tunnel ID")
|
||||
@@ -0,0 +1,94 @@
|
||||
package entrypoint
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gost/core/logger"
|
||||
dissector "github.com/go-gost/tls-dissector"
|
||||
ictx "github.com/go-gost/x/internal/ctx"
|
||||
xio "github.com/go-gost/x/internal/io"
|
||||
xnet "github.com/go-gost/x/internal/net"
|
||||
tls_util "github.com/go-gost/x/internal/util/tls"
|
||||
xrecorder "github.com/go-gost/x/recorder"
|
||||
)
|
||||
|
||||
// handleTLS processes a TLS connection arriving at the entrypoint.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Parse ClientHello (tee-reads first bytes for recording).
|
||||
// 2. Extract SNI hostname → ingress lookup → tunnelID.
|
||||
// 3. ep.dial() → Dialer.Dial() → mux stream (or SD TCP connection).
|
||||
// 4. Write buffered ClientHello bytes to the mux stream.
|
||||
// 5. Parse ServerHello from the mux stream (for TLS recording).
|
||||
// 6. Write ServerHello bytes back to the public connection.
|
||||
// 7. Pipe(publicConn, muxStream) — bidirectional TLS passthrough.
|
||||
func (ep *Entrypoint) handleTLS(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
|
||||
buf := new(bytes.Buffer)
|
||||
clientHello, err := dissector.ParseClientHello(io.TeeReader(conn, buf))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ro.TLS = &xrecorder.TLSRecorderObject{
|
||||
ServerName: clientHello.ServerName,
|
||||
ClientHello: hex.EncodeToString(buf.Bytes()),
|
||||
}
|
||||
if len(clientHello.SupportedProtos) > 0 {
|
||||
ro.TLS.Proto = clientHello.SupportedProtos[0]
|
||||
}
|
||||
|
||||
host := clientHello.ServerName
|
||||
if host != "" {
|
||||
if _, _, err := net.SplitHostPort(host); err != nil {
|
||||
host = net.JoinHostPort(strings.Trim(host, "[]"), "443")
|
||||
}
|
||||
ro.Host = host
|
||||
}
|
||||
|
||||
// ctx = xctx.ContextWithClientAddr(ctx, xctx.ClientAddr(ro.RemoteAddr))
|
||||
ctx = ictx.ContextWithRecorderObject(ctx, ro)
|
||||
ctx = ictx.ContextWithLogger(ctx, log)
|
||||
|
||||
cc, err := ep.dial(ctx, "tcp", host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cc.Close()
|
||||
|
||||
if _, err := buf.WriteTo(cc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
xio.SetReadDeadline(cc, time.Now().Add(ep.readTimeout))
|
||||
serverHello, err := 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
|
||||
}
|
||||
|
||||
// xnet.Transport(conn, cc)
|
||||
xnet.Pipe(ctx, conn, cc)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package entrypoint
|
||||
|
||||
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 bidirectionally with
|
||||
// optional frame-level recording.
|
||||
//
|
||||
// Two goroutines run concurrently — one for client→server frames, one for
|
||||
// server→client frames. Each frame is recorded when the sample rate limiter
|
||||
// allows. HTTP metadata in ro is cleared (ro.HTTP = nil) to prevent leaking
|
||||
// HTTP request/response headers into WebSocket frame records.
|
||||
//
|
||||
// The function returns when either direction encounters an error (closing
|
||||
// both sides).
|
||||
func (ep *Entrypoint) sniffingWebsocketFrame(ctx context.Context, rw, cc io.ReadWriteCloser, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
|
||||
errc := make(chan error, 2)
|
||||
|
||||
sampleRate := ep.websocketSampleRate
|
||||
if sampleRate == 0 {
|
||||
sampleRate = sniffing.DefaultSampleRate
|
||||
}
|
||||
if sampleRate < 0 {
|
||||
sampleRate = math.MaxFloat64
|
||||
}
|
||||
|
||||
go func() {
|
||||
ro2 := &xrecorder.HandlerRecorderObject{}
|
||||
*ro2 = *ro
|
||||
ro := ro2
|
||||
ro.HTTP = nil // WebSocket frames — don't leak HTTP metadata into the record
|
||||
|
||||
limiter := rate.NewLimiter(rate.Limit(sampleRate), int(sampleRate))
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
for {
|
||||
start := time.Now()
|
||||
|
||||
if err := ep.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, ep.recorder.Recorder); err != nil {
|
||||
log.Errorf("record: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
ro2 := &xrecorder.HandlerRecorderObject{}
|
||||
*ro2 = *ro
|
||||
ro := ro2
|
||||
ro.HTTP = nil // WebSocket frames — don't leak HTTP metadata into the record
|
||||
|
||||
limiter := rate.NewLimiter(rate.Limit(sampleRate), int(sampleRate))
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
for {
|
||||
start := time.Now()
|
||||
|
||||
if err := ep.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, ep.recorder.Recorder); err != nil {
|
||||
log.Errorf("record: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-errc
|
||||
rw.Close()
|
||||
cc.Close()
|
||||
<-errc
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyWebsocketFrame reads one WebSocket frame from r, records it in ro,
|
||||
// and writes it to w. The frame payload is optionally buffered for recording
|
||||
// when recorder options specify HTTPBody capture.
|
||||
//
|
||||
// The from parameter identifies the direction ("client" or "server") and
|
||||
// determines which byte-counter (InputBytes vs OutputBytes) is incremented
|
||||
// in the recorder object.
|
||||
func (ep *Entrypoint) 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 opts := ep.recorder.Options; opts != nil && opts.HTTPBody {
|
||||
bodySize := opts.MaxBodySize
|
||||
if bodySize <= 0 {
|
||||
bodySize = sniffing.DefaultBodySize
|
||||
}
|
||||
if bodySize > sniffing.MaxBodySize {
|
||||
bodySize = sniffing.MaxBodySize
|
||||
}
|
||||
|
||||
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)
|
||||
if _, err := fr.WriteTo(w); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user