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:
ginuerzh
2026-06-02 23:51:55 +08:00
parent d432d28f81
commit 81db46b725
26 changed files with 3145 additions and 467 deletions
+87
View File
@@ -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
}
+316
View File
@@ -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")
}
}
+316
View File
@@ -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)
}
+105
View File
@@ -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()
}
+122
View File
@@ -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")
+94
View File
@@ -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
}
+155
View File
@@ -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
}