81db46b725
- 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
846 lines
22 KiB
Go
846 lines
22 KiB
Go
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")
|
|
}
|
|
} |