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,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
|
||||
}
|
||||
Reference in New Issue
Block a user