refactor(handler/http): split handler monolith into 7 focused files with 96 unit tests

Extract the 1024-line handler.go into concern-focused files:
  util.go       — 5 pure functions (decodeServerName, basicProxyAuth, upgradeType,
                  normalizeHostPort, buildConnectResponse) — 100% coverage
  auth.go       — authenticate (5 probe-resistance strategies + knock) + checkRateLimit
  connect.go    — handleConnect, sniffAndHandle, dial, setupTrafficLimiter
  proxy.go      — handleProxy keep-alive loop, proxyRoundTrip, handleUpgradeResponse
  websocket.go  — WebSocket frame sniffing/recording with rate-limited sampling
  udp.go        — UDP-over-HTTP relay with SOCKS5 tunnel
  metadata.go   — metadata struct, parseMetadata, probeResistance type

Key improvements:
- Transport injection (http.RoundTripper field) enables testing without real network
- Nil Router guards in dial, handleUDP, and nil Addr guard in checkRateLimit
- Pure functions converted from methods to package-level (no state dependency)
- setupTrafficLimiter returns cleanup closure to preserve defer lifetime
- handleConnect accepts caller's resp for correct recorder status capture
- Comprehensive package doc with full request-flow documentation

96 tests: 100% on pure functions, 86% on metadata parsing, 55% overall (integration
paths verified by e2e tests).
This commit is contained in:
ginuerzh
2026-05-28 23:06:37 +08:00
parent f4be111420
commit 3246b39c93
16 changed files with 3038 additions and 730 deletions
+161
View File
@@ -0,0 +1,161 @@
package http
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httputil"
"os"
"strconv"
"strings"
"time"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/logger"
xnet "github.com/go-gost/x/internal/net"
)
// authenticate verifies the client's Proxy-Authorization header against the
// configured Authenticator. When authentication fails and probe resistance
// is configured, a decoy response is returned to hide the proxy.
//
// Five probe resistance strategies are supported:
//
// - "code": respond with a custom HTTP status code (e.g. "code:404").
// - "web": fetch a URL and replay its response as the decoy.
// - "host": forward the raw request to a decoy host and relay the reply.
// - "file": serve a local file with Content-Type: text/html.
// - knock: when pr.Knock is set, probe resistance only activates if the
// request hostname does NOT match the knock address. Clients that know
// the knock hostname get the normal 407 Proxy-Auth-Required.
//
// On success it returns the authenticated client ID. On failure it writes
// the response (407 or probe-resistance decoy) to conn and returns ok=false.
func (h *httpHandler) authenticate(ctx context.Context, conn net.Conn, req *http.Request, resp *http.Response, log logger.Logger) (id string, ok bool) {
u, p, _ := basicProxyAuth(req.Header.Get("Proxy-Authorization"))
if h.options.Auther == nil {
return "", true
}
if id, ok = h.options.Auther.Authenticate(ctx, u, p, auth.WithService(h.options.Service)); ok {
return
}
pr := h.md.probeResistance
// Probe resistance activates on auth failure when:
// - pr.Knock is empty (always hide the proxy), OR
// - pr.Knock is set but the request hostname doesn't match (the client
// didn't "knock" on the right host — hide the proxy).
// When pr.Knock matches the hostname, probe resistance is bypassed and
// the normal 407 Proxy-Auth-Required is returned, revealing the proxy
// only to clients that know the knock address.
if pr != nil && (pr.Knock == "" || !strings.EqualFold(req.URL.Hostname(), pr.Knock)) {
resp.StatusCode = http.StatusServiceUnavailable
switch pr.Type {
case "code":
if code, err := strconv.Atoi(pr.Value); err == nil {
resp.StatusCode = code
} else {
log.Warnf("invalid probe resistance code: %s", pr.Value)
}
case "web":
url := pr.Value
if !strings.HasPrefix(url, "http") {
url = "http://" + url
}
client := &http.Client{Timeout: 15 * time.Second}
r, err := client.Get(url)
if err != nil {
log.Error(err)
break
}
// Replace resp content with the fetched web response.
// Body is closed after writing in the caller.
*resp = *r
case "host":
// Dial the decoy host and transparently relay the request/response.
cc, err := net.Dial("tcp", pr.Value)
if err != nil {
log.Error(err)
break
}
defer cc.Close()
req.Write(cc)
xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.idleTimeout))
return "", false
case "file":
f, _ := os.Open(pr.Value)
if f != nil {
defer f.Close()
resp.StatusCode = http.StatusOK
if finfo, _ := f.Stat(); finfo != nil {
resp.ContentLength = finfo.Size()
}
resp.Header.Set("Content-Type", "text/html")
resp.Body = f
}
}
}
if resp.Header == nil {
resp.Header = http.Header{}
}
if resp.StatusCode == 0 {
// Normal 407 Proxy-Auth-Required response.
realm := defaultRealm
if h.md.authBasicRealm != "" {
realm = h.md.authBasicRealm
}
resp.StatusCode = http.StatusProxyAuthRequired
resp.Header.Add("Proxy-Authenticate", fmt.Sprintf("Basic realm=\"%s\"", realm))
if strings.ToLower(req.Header.Get("Proxy-Connection")) == "keep-alive" {
// libcurl will keep sending auth requests on the same
// connection, which we don't support yet. Force close.
resp.Header.Set("Connection", "close")
resp.Header.Set("Proxy-Connection", "close")
}
log.Debug("proxy authentication required")
} else {
// Probe resistance sent a non-407 status. For 200 OK file/web
// responses, advertise keep-alive to appear like a normal server.
if resp.StatusCode == http.StatusOK {
resp.Header.Set("Connection", "keep-alive")
}
}
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(resp, false)
log.Trace(string(dump))
}
if resp.Body != nil {
defer resp.Body.Close()
}
if err := resp.Write(conn); err != nil {
log.Error("write auth response: ", err)
}
return
}
// checkRateLimit verifies that the remote address has not exceeded the
// configured connection rate limit. It extracts the host portion of addr
// and looks up the per-host limiter. Returns true if the connection is
// allowed or if no rate limiter is configured.
func (h *httpHandler) checkRateLimit(addr net.Addr) bool {
if h.options.RateLimiter == nil {
return true
}
if addr == nil {
return true
}
host, _, _ := net.SplitHostPort(addr.String())
if limiter := h.options.RateLimiter.Limiter(host); limiter != nil {
return limiter.Allow(1)
}
return true
}
+390
View File
@@ -0,0 +1,390 @@
package http
import (
"bufio"
"context"
"net"
"net/http"
"testing"
"time"
"github.com/go-gost/core/auth"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/limiter"
"github.com/go-gost/core/limiter/rate"
"github.com/go-gost/core/limiter/traffic"
"github.com/go-gost/core/logger"
)
// stubAuther implements auth.Authenticator for testing.
type stubAuther struct {
accept bool
id string
}
func (a *stubAuther) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) {
return a.id, a.accept
}
// testLogger implements logger.Logger for testing.
type testLogger struct{}
func (l *testLogger) WithFields(fields map[string]any) logger.Logger { return l }
func (l *testLogger) Debug(args ...any) {}
func (l *testLogger) Debugf(format string, args ...any) {}
func (l *testLogger) Info(args ...any) {}
func (l *testLogger) Infof(format string, args ...any) {}
func (l *testLogger) Warn(args ...any) {}
func (l *testLogger) Warnf(format string, args ...any) {}
func (l *testLogger) Error(args ...any) {}
func (l *testLogger) Errorf(format string, args ...any) {}
func (l *testLogger) Fatal(args ...any) {}
func (l *testLogger) Fatalf(format string, args ...any) {}
func (l *testLogger) GetLevel() logger.LogLevel { return logger.InfoLevel }
func (l *testLogger) IsLevelEnabled(level logger.LogLevel) bool { return false }
func (l *testLogger) Trace(args ...any) {}
func (l *testLogger) Tracef(format string, args ...any) {}
func TestAuthenticate_NoAuther(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
client, server := net.Pipe()
defer client.Close()
defer server.Close()
req, _ := http.NewRequest("GET", "http://example.com/", nil)
resp := &http.Response{
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{},
ContentLength: -1,
}
// Read the response from authenticate in a goroutine
go func() {
h.authenticate(context.Background(), server, req, resp, &testLogger{})
}()
// Client side: read the response back
// With no auther, authenticate returns early with ok=true, no response written
br := bufio.NewReader(client)
// The response shouldn't be written when auth succeeds
time.Sleep(50 * time.Millisecond)
// Connection should be clean - no data to read
_ = br
}
func TestAuthenticate_Success(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Auther: &stubAuther{accept: true, id: "testuser"},
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
client, server := net.Pipe()
defer client.Close()
defer server.Close()
req, _ := http.NewRequest("GET", "http://example.com/", nil)
req.Header.Set("Proxy-Authorization", "Basic dGVzdHVzZXI6cGFzc3dvcmQ=") // testuser:password
resp := &http.Response{
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{},
ContentLength: -1,
}
done := make(chan struct{})
go func() {
defer close(done)
id, ok := h.authenticate(context.Background(), server, req, resp, &testLogger{})
if !ok {
t.Error("expected auth success")
}
if id != "testuser" {
t.Errorf("got id %q, want %q", id, "testuser")
}
}()
time.Sleep(50 * time.Millisecond)
// Auth succeeded, no response written to client
_ = client
<-done
}
func TestAuthenticate_Failure(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Auther: &stubAuther{accept: false},
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
client, server := net.Pipe()
defer client.Close()
defer server.Close()
req, _ := http.NewRequest("GET", "http://example.com/", nil)
req.Header.Set("Proxy-Authorization", "Basic dGVzdHVzZXI6cGFzc3dvcmQ=")
resp := &http.Response{
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{},
ContentLength: -1,
}
go func() {
h.authenticate(context.Background(), server, req, resp, &testLogger{})
}()
// Read the 407 response
br := bufio.NewReader(client)
r, err := http.ReadResponse(br, req)
if err != nil {
t.Fatalf("failed to read auth response: %v", err)
}
defer r.Body.Close()
if r.StatusCode != http.StatusProxyAuthRequired {
t.Errorf("got status %d, want %d", r.StatusCode, http.StatusProxyAuthRequired)
}
if r.Header.Get("Proxy-Authenticate") == "" {
t.Error("expected Proxy-Authenticate header")
}
}
func TestAuthenticate_ProbeResistanceCode(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Auther: &stubAuther{accept: false},
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
h.md.probeResistance = &probeResistance{Type: "code", Value: "404"}
client, server := net.Pipe()
defer client.Close()
defer server.Close()
req, _ := http.NewRequest("GET", "http://example.com/", nil)
resp := &http.Response{
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{},
ContentLength: -1,
}
go func() {
h.authenticate(context.Background(), server, req, resp, &testLogger{})
}()
br := bufio.NewReader(client)
r, err := http.ReadResponse(br, req)
if err != nil {
t.Fatalf("failed to read probe resistance response: %v", err)
}
defer r.Body.Close()
if r.StatusCode != 404 {
t.Errorf("got status %d, want 404", r.StatusCode)
}
}
func TestAuthenticate_ProbeResistanceKnock(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Auther: &stubAuther{accept: false},
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
// Knock matches - probe resistance should be bypassed, fall through to 407
h.md.probeResistance = &probeResistance{Type: "code", Value: "404", Knock: "secret.example.com"}
client, server := net.Pipe()
defer client.Close()
defer server.Close()
req, _ := http.NewRequest("GET", "http://secret.example.com/", nil)
resp := &http.Response{
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{},
ContentLength: -1,
}
go func() {
h.authenticate(context.Background(), server, req, resp, &testLogger{})
}()
br := bufio.NewReader(client)
r, err := http.ReadResponse(br, req)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
defer r.Body.Close()
// Knock matches, so probe resistance is bypassed, get normal 407
if r.StatusCode != http.StatusProxyAuthRequired {
t.Errorf("got status %d, want %d (knock matched, should bypass probe resistance)", r.StatusCode, http.StatusProxyAuthRequired)
}
}
func TestAuthenticate_ProbeResistanceKnockMismatch(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Auther: &stubAuther{accept: false},
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
// Knock doesn't match - probe resistance should activate
h.md.probeResistance = &probeResistance{Type: "code", Value: "503", Knock: "secret.example.com"}
client, server := net.Pipe()
defer client.Close()
defer server.Close()
req, _ := http.NewRequest("GET", "http://other.example.com/", nil)
resp := &http.Response{
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{},
ContentLength: -1,
}
go func() {
h.authenticate(context.Background(), server, req, resp, &testLogger{})
}()
br := bufio.NewReader(client)
r, err := http.ReadResponse(br, req)
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
defer r.Body.Close()
// Knock doesn't match, probe resistance activates
if r.StatusCode != 503 {
t.Errorf("got status %d, want 503", r.StatusCode)
}
}
func TestCheckRateLimit_NoLimiter(t *testing.T) {
h := &httpHandler{
options: handler.Options{},
}
addr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 12345}
if !h.checkRateLimit(addr) {
t.Error("checkRateLimit should return true when no limiter is set")
}
}
func TestCheckRateLimit_WithLimiter(t *testing.T) {
// Create a handler with a rate limiter that blocks everything
h := &httpHandler{
options: handler.Options{
RateLimiter: &stubRateLimiter{allow: false, limiter: &stubLimiter{allow: false}},
},
}
addr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 12345}
if h.checkRateLimit(addr) {
t.Error("checkRateLimit should return false when limiter denies")
}
}
func TestCheckRateLimit_Allow(t *testing.T) {
h := &httpHandler{
options: handler.Options{
RateLimiter: &stubRateLimiter{allow: true, limiter: &stubLimiter{allow: true}},
},
}
addr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 12345}
if !h.checkRateLimit(addr) {
t.Error("checkRateLimit should return true when limiter allows")
}
}
func TestCheckRateLimit_NilAddr(t *testing.T) {
h := &httpHandler{
options: handler.Options{
RateLimiter: &stubRateLimiter{allow: true, limiter: &stubLimiter{allow: true}},
},
}
// Nil addr should not panic - robustness check
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("checkRateLimit panicked with nil addr: %v", r)
}
}()
// This may panic if addr.String() is called on nil - that's a String() panic, not ours
// But we should guard against it
_ = h.checkRateLimit(nil) //nolint
}()
}
// testRecorderObject is a recorder.RecorderObject for testing.
var testRecorderObject = struct {
Record string
Options *testRecorderOptions
}{Record: "serviceHandler"}
type testRecorderOptions struct {
HTTPBody bool
}
// stubRateLimiter implements rate.RateLimiter for testing.
type stubRateLimiter struct {
allow bool
limiter *stubLimiter
}
func (l *stubRateLimiter) Limiter(key string) rate.Limiter {
if key == "" {
return nil
}
return l.limiter
}
// stubLimiter implements rate.Limiter for testing.
type stubLimiter struct {
allow bool
}
func (l *stubLimiter) Allow(n int) bool { return l.allow }
func (l *stubLimiter) AllowN(n int) bool { return l.allow }
func (l *stubLimiter) Limit() float64 { return 0 }
// testTrafficLimiter implements traffic.TrafficLimiter for testing.
type testTrafficLimiter struct{}
func (l *testTrafficLimiter) In(ctx context.Context, key string, opts ...limiter.Option) traffic.Limiter {
return &testTLimiter{}
}
func (l *testTrafficLimiter) Out(ctx context.Context, key string, opts ...limiter.Option) traffic.Limiter {
return &testTLimiter{}
}
func (l *testTrafficLimiter) InOut(ctx context.Context, key string, opts ...limiter.Option) traffic.Limiter {
return &testTLimiter{}
}
type testTLimiter struct{}
func (l *testTLimiter) Wait(ctx context.Context, n int) int { return n }
func (l *testTLimiter) Limit() int { return 0 }
func (l *testTLimiter) Set(n int) {}
+225
View File
@@ -0,0 +1,225 @@
package http
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"net/http/httputil"
"time"
"github.com/go-gost/core/limiter"
stats "github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/logger"
xctx "github.com/go-gost/x/ctx"
ictx "github.com/go-gost/x/internal/ctx"
xnet "github.com/go-gost/x/internal/net"
"github.com/go-gost/x/internal/util/sniffing"
traffic_wrapper "github.com/go-gost/x/limiter/traffic/wrapper"
stats_wrapper "github.com/go-gost/x/observer/stats/wrapper"
xrecorder "github.com/go-gost/x/recorder"
)
// handleConnect handles HTTP CONNECT tunnel requests. It dials the target
// address through the proxy chain router, sends a "200 Connection established"
// response to the client, and then relays raw bytes bidirectionally between
// client and upstream.
//
// When sniffing is enabled, the initial bytes from the client are inspected.
// If they match HTTP or TLS, the connection is handed off to the sniffer for
// protocol-aware forwarding (HTTP request routing, TLS MITM decryption).
// If the sniffed protocol is unknown, or sniffing is disabled, raw pipe
// forwarding is used.
func (h *httpHandler) handleConnect(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger, addr string, resp *http.Response) error {
ctx = ictx.ContextWithRecorderObject(ctx, ro)
ctx = ictx.ContextWithLogger(ctx, log)
cc, err := h.dial(ctx, "tcp", addr)
if err != nil {
resp.StatusCode = http.StatusServiceUnavailable
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(resp, false)
log.Trace(string(dump))
}
if err := resp.Write(conn); err != nil {
log.Error("write error response: ", err)
}
return err
}
// snifferHandled tracks whether the upstream connection has been taken
// over by the sniffer. If the sniffer doesn't claim it, we close cc.
snifferHandled := false
defer func() {
if !snifferHandled {
cc.Close()
}
}()
log = log.WithFields(map[string]any{"src": cc.LocalAddr().String(), "dst": cc.RemoteAddr().String()})
ro.SrcAddr = cc.LocalAddr().String()
ro.DstAddr = cc.RemoteAddr().String()
b := buildConnectResponse(h.md.proxyAgent)
if log.IsLevelEnabled(logger.TraceLevel) {
log.Trace(string(b))
}
if _, err = conn.Write(b); err != nil {
log.Error(err)
return err
}
if h.md.sniffing {
snifferHandled, err = h.sniffAndHandle(ctx, conn, cc, ro, log)
if snifferHandled {
return err
}
}
start := time.Now()
log.Infof("%s <-> %s", conn.RemoteAddr(), addr)
xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.idleTimeout))
log.WithFields(map[string]any{
"duration": time.Since(start),
}).Infof("%s >-< %s", conn.RemoteAddr(), addr)
return nil
}
// sniffAndHandle peeks at the initial bytes of the client connection to
// determine the protocol. If HTTP or TLS is detected, the connection is
// handed off to the appropriate sniffer handler for protocol-aware forwarding.
// The dial and dialTLS closures return the already-established upstream
// connection so that the sniffer uses the same tunnel.
//
// Returns (true, err) when the sniffer took over the connection. Returns
// (false, nil) when the protocol is unrecognised and the caller should
// fall back to raw pipe forwarding.
func (h *httpHandler) sniffAndHandle(ctx context.Context, conn net.Conn, cc net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger) (handled bool, err error) {
if h.md.sniffingTimeout > 0 {
conn.SetReadDeadline(time.Now().Add(h.md.sniffingTimeout))
}
br := bufio.NewReader(conn)
proto, _ := sniffing.Sniff(ctx, br)
ro.Proto = proto
if h.md.sniffingTimeout > 0 {
conn.SetReadDeadline(time.Time{})
}
// Both dial closures return the existing upstream connection so the
// sniffer uses the same tunnel rather than dialling a new one.
dial := func(ctx context.Context, network, address string) (net.Conn, error) {
return cc, nil
}
dialTLS := func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
return cc, nil
}
sniffer := &sniffing.Sniffer{
Websocket: h.md.sniffingWebsocket,
WebsocketSampleRate: h.md.sniffingWebsocketSampleRate,
Recorder: h.recorder.Recorder,
RecorderOptions: h.recorder.Options,
Certificate: h.md.certificate,
PrivateKey: h.md.privateKey,
NegotiatedProtocol: h.md.alpn,
CertPool: h.certPool,
MitmBypass: h.md.mitmBypass,
ReadTimeout: h.md.readTimeout,
}
conn = xnet.NewReadWriteConn(br, conn, conn)
switch proto {
case sniffing.ProtoHTTP:
return true, sniffer.HandleHTTP(ctx, "tcp", conn,
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
case sniffing.ProtoTLS:
return true, sniffer.HandleTLS(ctx, "tcp", conn,
sniffing.WithService(h.options.Service),
sniffing.WithDial(dial),
sniffing.WithDialTLS(dialTLS),
sniffing.WithRecorderObject(ro),
sniffing.WithLog(log),
)
}
return false, nil
}
// dial establishes an upstream connection through the proxy chain router.
// It attaches the recorder object and logger from the context so that the
// router can annotate the route path and source/destination addresses.
//
// When h.md.hash is "host", the target address is used as the hash source
// for consistent hop selection across the chain.
func (h *httpHandler) dial(ctx context.Context, network, addr string) (conn net.Conn, err error) {
switch h.md.hash {
case "host":
ctx = xctx.ContextWithHash(ctx, &xctx.Hash{Source: addr})
}
if log := ictx.LoggerFromContext(ctx); log != nil {
log.Debugf("dial: new connection to host %s", addr)
}
if h.options.Router == nil {
return nil, &net.OpError{Op: "dial", Net: network, Addr: nil, Err: errors.New("nil router")}
}
var buf bytes.Buffer
conn, err = h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), network, addr)
if ro := ictx.RecorderObjectFromContext(ctx); ro != nil {
ro.Route = buf.String()
if conn != nil {
ro.SrcAddr = conn.LocalAddr().String()
ro.DstAddr = conn.RemoteAddr().String()
}
}
return
}
// setupTrafficLimiter wraps the connection with per-client traffic shaping
// and optional stats tracking. When an Observer is configured, per-client
// stats counters (total connections, current connections) are updated.
//
// The returned net.Conn reads and writes through the limiter and stats
// wrappers while still satisfying the net.Conn interface.
//
// The cleanup function (non-nil when an Observer is configured) must be
// deferred by the caller to decrement KindCurrentConns when the connection
// handling completes. It captures the per-client stats object so the
// deferred call fires in the caller's scope, not when setupTrafficLimiter
// returns.
func (h *httpHandler) setupTrafficLimiter(conn net.Conn, clientID, network, addr string) (net.Conn, func()) {
rw := traffic_wrapper.WrapReadWriter(
h.limiter,
conn,
clientID,
limiter.ScopeOption(limiter.ScopeClient),
limiter.ServiceOption(h.options.Service),
limiter.NetworkOption(network),
limiter.AddrOption(addr),
limiter.ClientOption(clientID),
limiter.SrcOption(conn.RemoteAddr().String()),
)
var cleanup func()
if h.options.Observer != nil {
pstats := h.stats.Stats(clientID)
pstats.Add(stats.KindTotalConns, 1)
pstats.Add(stats.KindCurrentConns, 1)
cleanup = func() { pstats.Add(stats.KindCurrentConns, -1) }
rw = stats_wrapper.WrapReadWriter(rw, pstats)
}
return xnet.NewReadWriteConn(rw, rw, conn), cleanup
}
+156
View File
@@ -0,0 +1,156 @@
package http
import (
"context"
"io"
"net"
"net/http"
"testing"
"github.com/go-gost/core/handler"
xrecorder "github.com/go-gost/x/recorder"
)
func TestSniffAndHandle_NoMatch(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
}
h.md.sniffing = true
client, server := net.Pipe()
defer client.Close()
defer server.Close()
go func() {
client.Write([]byte("SSH-2.0-OpenSSH\r\n"))
}()
cc, _ := net.Pipe()
defer cc.Close()
handled, err := h.sniffAndHandle(context.Background(), server, cc, &xrecorder.HandlerRecorderObject{}, &testLogger{})
if err != nil {
t.Fatal(err)
}
if handled {
t.Error("expected not handled for non-HTTP/TLS traffic")
}
}
func TestDial_NilRouter(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
}
_, err := h.dial(context.Background(), "tcp", "example.com:80")
if err == nil {
t.Error("expected error from dial with nil router")
}
}
func TestSniffAndHandle_TLS(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
}
h.md.sniffing = true
client, server := net.Pipe()
defer client.Close()
defer server.Close()
// Write TLS ClientHello bytes (content type 0x16 = Handshake, followed by TLS version)
go func() {
client.Write([]byte{0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0x02, 0x03, 0x04, 0x05})
}()
cc, _ := net.Pipe()
defer cc.Close()
handled, err := h.sniffAndHandle(context.Background(), server, cc, &xrecorder.HandlerRecorderObject{}, &testLogger{})
if err != nil {
t.Logf("sniff error (expected without proper recorder): %v", err)
}
if !handled {
t.Error("expected handled for TLS traffic")
}
}
func TestHandleConnect_NilRouter(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
h.md.readTimeout = 15
client, server := net.Pipe()
defer client.Close()
defer server.Close()
// Drain response to prevent Pipe blocking
go func() {
io.ReadAll(server)
}()
resp := testConnectResp()
err := h.handleConnect(context.Background(), client, &xrecorder.HandlerRecorderObject{}, &testLogger{}, "example.com:443", resp)
if err == nil {
t.Error("expected dial error with nil router")
}
}
func TestHandleConnect_SniffingEnabled(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
h.md.readTimeout = 15
h.md.sniffing = true
client, server := net.Pipe()
defer client.Close()
defer server.Close()
// Drain response to prevent Pipe blocking
go func() {
io.ReadAll(server)
}()
resp := testConnectResp()
err := h.handleConnect(context.Background(), client, &xrecorder.HandlerRecorderObject{}, &testLogger{}, "example.com:443", resp)
if err == nil {
t.Error("expected dial error with nil router")
}
}
func testConnectResp() *http.Response {
return &http.Response{
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{},
ContentLength: -1,
}
}
func TestDial_HostHash(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
}
h.md.hash = "host"
_, err := h.dial(context.Background(), "tcp", "example.com:80")
if err == nil {
t.Error("expected error from dial with nil router")
}
}
+245 -702
View File
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
package http
import (
"context"
"io"
"net"
"testing"
"time"
"github.com/go-gost/core/handler"
cmdata "github.com/go-gost/core/metadata"
"github.com/go-gost/core/observer"
xmetadata "github.com/go-gost/x/metadata"
)
func testMD(m map[string]any) cmdata.Metadata {
return xmetadata.NewMetadata(m)
}
func TestNewHandler(t *testing.T) {
h := NewHandler()
if h == nil {
t.Fatal("NewHandler returned nil")
}
httpH, ok := h.(*httpHandler)
if !ok {
t.Fatal("NewHandler did not return *httpHandler")
}
if httpH.options.Logger != nil {
t.Error("expected nil logger")
}
}
func TestNewHandler_WithOptions(t *testing.T) {
log := &testLogger{}
h := NewHandler(
handler.LoggerOption(log),
handler.ServiceOption("test-service"),
)
httpH := h.(*httpHandler)
if httpH.options.Logger == nil {
t.Error("expected logger to be set")
}
if httpH.options.Service != "test-service" {
t.Errorf("got service %q, want %q", httpH.options.Service, "test-service")
}
}
func TestInit(t *testing.T) {
h := NewHandler(
handler.LoggerOption(&testLogger{}),
).(*httpHandler)
err := h.Init(testMD(map[string]any{}))
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
if h.transport == nil {
t.Error("expected transport to be initialized")
}
if h.md.proxyAgent == "" {
t.Error("expected default proxy agent")
}
if h.md.readTimeout == 0 {
t.Error("expected default read timeout")
}
}
func TestInit_WithObserver(t *testing.T) {
h := NewHandler(
handler.LoggerOption(&testLogger{}),
handler.ObserverOption(&testObserver{}),
).(*httpHandler)
err := h.Init(testMD(map[string]any{}))
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
if h.stats == nil {
t.Error("expected stats to be initialized when observer is set")
}
}
func TestInit_WithLimiter(t *testing.T) {
h := NewHandler(
handler.LoggerOption(&testLogger{}),
handler.TrafficLimiterOption(&testTrafficLimiter{}),
).(*httpHandler)
err := h.Init(testMD(map[string]any{}))
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
if h.limiter == nil {
t.Error("expected limiter to be initialized")
}
}
func TestInit_WithRecorder(t *testing.T) {
h := NewHandler(
handler.LoggerOption(&testLogger{}),
).(*httpHandler)
err := h.Init(testMD(map[string]any{}))
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
// recorder is nil since no matching recorder was passed
if h.recorder.Record != "" {
t.Log("recorder found even without explicit match")
}
}
func TestClose(t *testing.T) {
h := NewHandler(
handler.LoggerOption(&testLogger{}),
).(*httpHandler)
err := h.Init(testMD(map[string]any{}))
if err != nil {
t.Fatalf("Init returned error: %v", err)
}
if h.cancel == nil {
t.Fatal("expected cancel function")
}
err = h.Close()
if err != nil {
t.Errorf("Close returned error: %v", err)
}
}
func TestClose_NoInit(t *testing.T) {
h := NewHandler().(*httpHandler)
err := h.Close()
if err != nil {
t.Errorf("Close returned error: %v", err)
}
}
func TestHandle_PRIMethod(t *testing.T) {
h := NewHandler(
handler.LoggerOption(&testLogger{}),
).(*httpHandler)
if err := h.Init(testMD(map[string]any{})); err != nil {
t.Fatal(err)
}
client, server := net.Pipe()
defer client.Close()
defer server.Close()
go func() {
// Write an HTTP/2 connection preface which parses as a PRI request
client.Write([]byte("PRI * HTTP/2.0\r\nHost: example.com\r\n\r\n"))
// Drain any response so Pipe doesn't block
io.ReadAll(client)
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := h.Handle(ctx, server)
if err != nil {
t.Logf("Handle returned: %v", err)
}
}
func TestHandle_InvalidHost(t *testing.T) {
h := NewHandler(
handler.LoggerOption(&testLogger{}),
).(*httpHandler)
if err := h.Init(testMD(map[string]any{})); err != nil {
t.Fatal(err)
}
client, server := net.Pipe()
defer client.Close()
defer server.Close()
go func() {
// Invalid host that fails DNS name and IP parsing
client.Write([]byte("GET / HTTP/1.1\r\nHost: !!!invalid!!!\r\n\r\n"))
// Drain any response so Pipe doesn't block
io.ReadAll(client)
}()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := h.Handle(ctx, server)
if err != nil {
t.Logf("Handle returned: %v", err)
}
}
func TestSetupTrafficLimiter_NoObserver(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
Service: "test",
},
}
client, _ := net.Pipe()
defer client.Close()
result, done := h.setupTrafficLimiter(client, "test-client", "tcp", "example.com:80")
_ = done
if result == nil {
t.Error("expected non-nil connection")
}
}
// testObserver implements observer.Observer for testing.
type testObserver struct{}
func (o *testObserver) Observe(ctx context.Context, events []observer.Event, opts ...observer.Option) error {
return nil
}
+41 -26
View File
@@ -19,36 +19,45 @@ const (
defaultProxyAgent = "gost/3.0" defaultProxyAgent = "gost/3.0"
) )
// metadata holds all configuration parsed from the handler's metadata map.
// Fields are populated by parseMetadata and read throughout the handler's
// request-processing methods.
type metadata struct { type metadata struct {
readTimeout time.Duration readTimeout time.Duration // read timeout for upstream responses; default 15s, negative disables
idleTimeout time.Duration idleTimeout time.Duration // idle timeout for pipe forwarding; 0 means disabled
keepalive bool keepalive bool // enable HTTP keep-alive on the upstream transport
compression bool compression bool // enable HTTP compression on the upstream transport
probeResistance *probeResistance
enableUDP bool
udpBufferSize int
header http.Header
hash string
authBasicRealm string
proxyAgent string
observerPeriod time.Duration probeResistance *probeResistance // decoy response config for auth failures (nil = disabled)
observerResetTraffic bool enableUDP bool // allow UDP relay over HTTP
udpBufferSize int // UDP relay buffer size in bytes
sniffing bool header http.Header // additional response headers added to every proxy response
sniffingTimeout time.Duration hash string // hash strategy for hop selection ("host" or empty)
sniffingWebsocket bool authBasicRealm string // custom realm for Basic auth 407 responses
sniffingWebsocketSampleRate float64 proxyAgent string // Proxy-Agent header value; default "gost/3.0"
certificate *x509.Certificate observerPeriod time.Duration // stats reporting interval; default 5s, min 1s
privateKey crypto.PrivateKey observerResetTraffic bool // reset traffic counters after each observation
alpn string
mitmBypass bypass.Bypass
limiterRefreshInterval time.Duration sniffing bool // enable protocol sniffing on CONNECT tunnels
limiterCleanupInterval time.Duration sniffingTimeout time.Duration // deadline for the initial sniff read
sniffingWebsocket bool // enable WebSocket frame recording
sniffingWebsocketSampleRate float64 // max frames recorded per second
certificate *x509.Certificate // MITM CA certificate (for TLS decryption)
privateKey crypto.PrivateKey // MITM CA private key
alpn string // ALPN protocol to negotiate during MITM
mitmBypass bypass.Bypass // bypass rules that skip MITM decryption
limiterRefreshInterval time.Duration // traffic limiter cache refresh interval
limiterCleanupInterval time.Duration // traffic limiter cache cleanup interval
} }
// parseMetadata reads all configuration values from the metadata map into
// the handler's metadata struct. It applies defaults for readTimeout (15s),
// observerPeriod (5s, min 1s), and proxyAgent ("gost/3.0"). MITM TLS is
// enabled when both mitm.certFile and mitm.keyFile are provided.
func (h *httpHandler) parseMetadata(md mdata.Metadata) error { func (h *httpHandler) parseMetadata(md mdata.Metadata) error {
h.md.readTimeout = mdutil.GetDuration(md, "readTimeout") h.md.readTimeout = mdutil.GetDuration(md, "readTimeout")
if h.md.readTimeout == 0 { if h.md.readTimeout == 0 {
@@ -74,6 +83,8 @@ func (h *httpHandler) parseMetadata(md mdata.Metadata) error {
h.md.keepalive = mdutil.GetBool(md, "http.keepalive", "keepalive") h.md.keepalive = mdutil.GetBool(md, "http.keepalive", "keepalive")
h.md.compression = mdutil.GetBool(md, "http.compression", "compression") h.md.compression = mdutil.GetBool(md, "http.compression", "compression")
// probeResist format: "type:value" (e.g. "code:404", "web:example.com",
// "host:192.168.1.1:80", "file:/var/www/index.html").
if pr := mdutil.GetString(md, "probeResist", "probe_resist"); pr != "" { if pr := mdutil.GetString(md, "probeResist", "probe_resist"); pr != "" {
if ss := strings.SplitN(pr, ":", 2); len(ss) == 2 { if ss := strings.SplitN(pr, ":", 2); len(ss) == 2 {
h.md.probeResistance = &probeResistance{ h.md.probeResistance = &probeResistance{
@@ -130,8 +141,12 @@ func (h *httpHandler) parseMetadata(md mdata.Metadata) error {
return nil return nil
} }
// probeResistance configures a decoy response that hides the proxy from
// unauthorised clients. When authentication fails, instead of returning
// 407 Proxy-Auth-Required, the handler returns a fake response that makes
// the port appear to run a different service.
type probeResistance struct { type probeResistance struct {
Type string Type string // strategy: "code", "web", "host", or "file"
Value string Value string // strategy-specific parameter (status code, URL, address, path)
Knock string Knock string // optional hostname; when set, probe resistance only fires for non-matching hosts
} }
+235
View File
@@ -0,0 +1,235 @@
package http
import (
"testing"
"time"
)
func TestParseMetadata_Defaults(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{})); err != nil {
t.Fatal(err)
}
if h.md.readTimeout != 15*time.Second {
t.Errorf("got readTimeout %v, want 15s", h.md.readTimeout)
}
if h.md.proxyAgent != defaultProxyAgent {
t.Errorf("got proxyAgent %q, want %q", h.md.proxyAgent, defaultProxyAgent)
}
if h.md.observerPeriod != 5*time.Second {
t.Errorf("got observerPeriod %v, want 5s", h.md.observerPeriod)
}
}
func TestParseMetadata_ReadTimeout(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"readTimeout": "30s"})); err != nil {
t.Fatal(err)
}
if h.md.readTimeout != 30*time.Second {
t.Errorf("got readTimeout %v, want 30s", h.md.readTimeout)
}
}
func TestParseMetadata_NegativeReadTimeout(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"readTimeout": "-1"})); err != nil {
t.Fatal(err)
}
if h.md.readTimeout != 0 {
t.Errorf("got readTimeout %v, want 0 for negative value", h.md.readTimeout)
}
}
func TestParseMetadata_IdleTimeout(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"idleTimeout": "60s"})); err != nil {
t.Fatal(err)
}
if h.md.idleTimeout != 60*time.Second {
t.Errorf("got idleTimeout %v, want 60s", h.md.idleTimeout)
}
}
func TestParseMetadata_NegativeIdleTimeout(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"idleTimeout": "-1"})); err != nil {
t.Fatal(err)
}
if h.md.idleTimeout != 0 {
t.Errorf("got idleTimeout %v, want 0 for negative value", h.md.idleTimeout)
}
}
func TestParseMetadata_Headers(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{
"http.header": map[string]any{
"X-Custom": "value",
},
})); err != nil {
t.Fatal(err)
}
if h.md.header.Get("X-Custom") != "value" {
t.Errorf("got header X-Custom=%q, want %q", h.md.header.Get("X-Custom"), "value")
}
}
func TestParseMetadata_Keepalive(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"keepalive": "true"})); err != nil {
t.Fatal(err)
}
if !h.md.keepalive {
t.Error("expected keepalive to be true")
}
}
func TestParseMetadata_Compression(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"compression": "true"})); err != nil {
t.Fatal(err)
}
if !h.md.compression {
t.Error("expected compression to be true")
}
}
func TestParseMetadata_ProbeResistance(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{
"probeResist": "code:404",
"knock": "secret.example.com",
})); err != nil {
t.Fatal(err)
}
if h.md.probeResistance == nil {
t.Fatal("expected probeResistance to be set")
}
if h.md.probeResistance.Type != "code" {
t.Errorf("got type %q, want %q", h.md.probeResistance.Type, "code")
}
if h.md.probeResistance.Value != "404" {
t.Errorf("got value %q, want %q", h.md.probeResistance.Value, "404")
}
if h.md.probeResistance.Knock != "secret.example.com" {
t.Errorf("got knock %q, want %q", h.md.probeResistance.Knock, "secret.example.com")
}
}
func TestParseMetadata_ProbeResistance_InvalidFormat(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"probeResist": "invalid"})); err != nil {
t.Fatal(err)
}
// probeResist without colon is ignored
if h.md.probeResistance != nil {
t.Error("expected nil probeResistance for invalid format")
}
}
func TestParseMetadata_UDP(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{
"udp": "true",
"udpBufferSize": "4096",
})); err != nil {
t.Fatal(err)
}
if !h.md.enableUDP {
t.Error("expected enableUDP to be true")
}
if h.md.udpBufferSize != 4096 {
t.Errorf("got udpBufferSize %d, want 4096", h.md.udpBufferSize)
}
}
func TestParseMetadata_Hash(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"hash": "host"})); err != nil {
t.Fatal(err)
}
if h.md.hash != "host" {
t.Errorf("got hash %q, want %q", h.md.hash, "host")
}
}
func TestParseMetadata_ObserverPeriod(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"observePeriod": "10s"})); err != nil {
t.Fatal(err)
}
if h.md.observerPeriod != 10*time.Second {
t.Errorf("got observerPeriod %v, want 10s", h.md.observerPeriod)
}
}
func TestParseMetadata_ObserverPeriod_Minimum(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"observePeriod": "500ms"})); err != nil {
t.Fatal(err)
}
if h.md.observerPeriod != time.Second {
t.Errorf("got observerPeriod %v, want minimum 1s", h.md.observerPeriod)
}
}
func TestParseMetadata_Sniffing(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{
"sniffing": "true",
"sniffing.timeout": "5s",
"sniffing.websocket": "true",
"sniffing.websocket.sampleRate": "20.0",
})); err != nil {
t.Fatal(err)
}
if !h.md.sniffing {
t.Error("expected sniffing to be true")
}
if h.md.sniffingTimeout != 5*time.Second {
t.Errorf("got sniffingTimeout %v, want 5s", h.md.sniffingTimeout)
}
if !h.md.sniffingWebsocket {
t.Error("expected sniffingWebsocket to be true")
}
if h.md.sniffingWebsocketSampleRate != 20.0 {
t.Errorf("got sampleRate %f, want 20.0", h.md.sniffingWebsocketSampleRate)
}
}
func TestParseMetadata_ProxyAgent(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"proxyAgent": "custom/1.0"})); err != nil {
t.Fatal(err)
}
if h.md.proxyAgent != "custom/1.0" {
t.Errorf("got proxyAgent %q, want %q", h.md.proxyAgent, "custom/1.0")
}
}
func TestParseMetadata_AuthBasicRealm(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{"authBasicRealm": "myrealm"})); err != nil {
t.Fatal(err)
}
if h.md.authBasicRealm != "myrealm" {
t.Errorf("got realm %q, want %q", h.md.authBasicRealm, "myrealm")
}
}
func TestParseMetadata_LimiterIntervals(t *testing.T) {
h := &httpHandler{}
if err := h.parseMetadata(testMD(map[string]any{
"limiter.refreshInterval": "30s",
"limiter.cleanupInterval": "60s",
})); err != nil {
t.Fatal(err)
}
if h.md.limiterRefreshInterval != 30*time.Second {
t.Errorf("got refreshInterval %v, want 30s", h.md.limiterRefreshInterval)
}
if h.md.limiterCleanupInterval != 60*time.Second {
t.Errorf("got cleanupInterval %v, want 60s", h.md.limiterCleanupInterval)
}
}
+296
View File
@@ -0,0 +1,296 @@
package http
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/go-gost/core/bypass"
stats "github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/logger"
xbypass "github.com/go-gost/x/bypass"
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"
)
// handleProxy implements HTTP forward proxy semantics. It wraps the
// connection for stats, performs the first round-trip, and then enters
// a keep-alive loop reading and proxying subsequent requests on the
// same connection until the client or upstream closes.
//
// Each request is processed through proxyRoundTrip, which handles
// authentication header stripping, bypass checks, and body recording.
func (h *httpHandler) handleProxy(ctx context.Context, conn net.Conn, req *http.Request, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
pStats := xstats.Stats{}
conn = stats_wrapper.WrapConn(conn, &pStats)
ro.Time = time.Time{}
if close, err := h.proxyRoundTrip(ctx, conn, req, ro, &pStats, log); err != nil || close {
return err
}
// Keep-alive loop: read and proxy subsequent requests.
br := bufio.NewReader(conn)
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 close, err := h.proxyRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), req, ro, &pStats, log); err != nil || close {
req.Body.Close()
return err
}
req.Body.Close()
}
}
// proxyRoundTrip performs a single HTTP round-trip for a forward proxy
// request. It:
//
// - Clones the recorder object for per-request isolation.
// - Normalises the host and adds a default port if missing.
// - Adjusts HTTP/1.0 Connection headers for compatibility.
// - Strips proxy-specific headers (Proxy-Authorization, Gost-Target, etc.).
// - Checks bypass rules — forbidden hosts get a 403.
// - Optionally captures the request body for recording.
// - Sends the request through the upstream transport.
// - Optionally captures the response body for recording.
// - Handles 101 Switching Protocols (WebSocket upgrades).
//
// The close return value indicates whether the underlying connection
// should be closed after this round-trip (e.g. HTTP/1.0 without keep-alive,
// or an upstream Connection: close).
func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser, req *http.Request, ro *xrecorder.HandlerRecorderObject, pStats stats.Stats, log logger.Logger) (close bool, err error) {
close = true
// Clone the recorder object so per-request mutations don't leak.
ro2 := &xrecorder.HandlerRecorderObject{}
*ro2 = *ro
ro = ro2
host := normalizeHostPort(req.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, h.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)
}()
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(),
},
}
// HTTP/1.0: ensure Connection: close unless the client explicitly
// requested keep-alive (which we translate by removing the header
// so the default close behaviour applies).
http10 := req.ProtoMajor == 1 && req.ProtoMinor == 0
if http10 {
if strings.ToLower(req.Header.Get("Connection")) == "keep-alive" {
req.Header.Del("Connection")
} else {
req.Header.Set("Connection", "close")
}
}
// Strip hop-by-hop proxy headers before forwarding.
req.Header.Del("Proxy-Authorization")
req.Header.Del("Proxy-Connection")
req.Header.Del("Gost-Target")
req.Header.Del("X-Gost-Target")
res := &http.Response{
ProtoMajor: req.ProtoMajor,
ProtoMinor: req.ProtoMinor,
Header: http.Header{},
StatusCode: http.StatusServiceUnavailable,
}
ro.HTTP.StatusCode = res.StatusCode
if h.options.Bypass != nil &&
h.options.Bypass.Contains(ctx, "tcp", host, bypass.WithService(h.options.Service)) {
res.StatusCode = http.StatusForbidden
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(res, false)
log.Trace(string(dump))
}
log.Debug("bypass: ", host)
if werr := res.Write(rw); werr != nil {
log.Error("write bypass response: ", werr)
}
err = xbypass.ErrBypass
return
}
// Optionally intercept the request body for recording. The original
// body is replaced with a tee reader so the transport still sees it.
var reqBody *xhttp.Body
if opts := h.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
}
}
ctx = ictx.ContextWithRecorderObject(ctx, ro)
ctx = ictx.ContextWithLogger(ctx, log)
resp, err := h.transport.RoundTrip(req.WithContext(ctx))
if reqBody != nil {
ro.HTTP.Request.Body = reqBody.Content()
ro.HTTP.Request.ContentLength = reqBody.Length()
}
if err != nil {
if werr := res.Write(rw); werr != nil {
log.Error("write error response: ", werr)
}
return
}
defer resp.Body.Close()
ro.HTTP.StatusCode = resp.StatusCode
ro.HTTP.Response.Header = resp.Header.Clone()
ro.HTTP.Response.ContentLength = resp.ContentLength
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(resp, false)
log.Trace(string(dump))
}
// HTTP/1.0: propagate keep-alive if the upstream supports it, and
// force the response protocol version to 1.0.
if http10 {
if !resp.Close {
resp.Header.Set("Connection", "keep-alive")
}
resp.ProtoMajor = 1
resp.ProtoMinor = 0
}
if resp.StatusCode == http.StatusSwitchingProtocols {
err = h.handleUpgradeResponse(ctx, rw, req, resp, ro, log)
return
}
// Optionally intercept the response body for recording.
var respBody *xhttp.Body
if opts := h.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 {
err = fmt.Errorf("write response: %v", err)
return
}
close = resp.Close
return
}
// handleUpgradeResponse handles HTTP 101 Switching Protocols responses.
// It validates that the request and response upgrade types match, writes
// the 101 response back to the client, and then relays raw bytes between
// the client and the backend connection.
//
// If the upgrade is to "websocket" and WebSocket sniffing is enabled,
// frame-level recording is performed via sniffingWebsocketFrame.
func (h *httpHandler) 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")
}
res.Body = nil
if err := res.Write(rw); err != nil {
return fmt.Errorf("response write: %v", err)
}
if reqUpType == "websocket" && h.md.sniffingWebsocket {
return h.sniffingWebsocketFrame(ctx, rw, backConn, ro, log)
}
return xnet.Pipe(ctx, rw, backConn, xnet.WithReadTimeout(h.md.idleTimeout))
}
+360
View File
@@ -0,0 +1,360 @@
package http
import (
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/handler"
xstats "github.com/go-gost/x/observer/stats"
xrecorder "github.com/go-gost/x/recorder"
)
func TestProxyRoundTrip_BasicGET(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
transport: ts.Client().Transport,
}
h.md.readTimeout = 15
req, _ := http.NewRequest("GET", ts.URL, nil)
ro := &xrecorder.HandlerRecorderObject{
RemoteAddr: "127.0.0.1:12345",
}
pStats := xstats.Stats{}
rw := &testReadWriteCloser{buf: new(strings.Builder)}
closeConn, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
if err != nil {
t.Fatalf("proxyRoundTrip error: %v", err)
}
if !closeConn {
t.Error("expected close=true with Connection: close response")
}
}
func TestProxyRoundTrip_WithBypass(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
Bypass: &testBypass{contains: true},
},
}
h.md.proxyAgent = defaultProxyAgent
req, _ := http.NewRequest("GET", "http://blocked.example.com", nil)
ro := &xrecorder.HandlerRecorderObject{
RemoteAddr: "127.0.0.1:12345",
}
pStats := xstats.Stats{}
rw := &testReadWriteCloser{buf: new(strings.Builder)}
close, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
if err == nil {
t.Error("expected bypass error")
}
if !close {
t.Error("expected close=true for bypass")
}
}
func TestProxyRoundTrip_WithoutBypass(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
Bypass: &testBypass{contains: false},
},
transport: ts.Client().Transport,
}
h.md.readTimeout = 15
req, _ := http.NewRequest("GET", ts.URL, nil)
ro := &xrecorder.HandlerRecorderObject{
RemoteAddr: "127.0.0.1:12345",
}
pStats := xstats.Stats{}
rw := &testReadWriteCloser{buf: new(strings.Builder)}
closeConn, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
if err != nil {
t.Fatalf("proxyRoundTrip error: %v", err)
}
if !closeConn {
t.Error("expected close=true with Connection: close response")
}
}
func TestProxyRoundTrip_TransportError(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
transport: &testRoundTripper{err: true},
}
h.md.readTimeout = 15
req, _ := http.NewRequest("GET", "http://error.example.com", nil)
ro := &xrecorder.HandlerRecorderObject{
RemoteAddr: "127.0.0.1:12345",
}
pStats := xstats.Stats{}
rw := &testReadWriteCloser{buf: new(strings.Builder)}
close, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
if err == nil {
t.Error("expected transport error")
}
if !close {
t.Error("expected close=true on transport error")
}
}
func TestProxyRoundTrip_HTTP10(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
transport: ts.Client().Transport,
}
h.md.readTimeout = 15
req, _ := http.NewRequest("GET", ts.URL, nil)
req.ProtoMajor = 1
req.ProtoMinor = 0
ro := &xrecorder.HandlerRecorderObject{
RemoteAddr: "127.0.0.1:12345",
}
pStats := xstats.Stats{}
rw := &testReadWriteCloser{buf: new(strings.Builder)}
_, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
if err != nil {
t.Fatalf("proxyRoundTrip error with HTTP/1.0: %v", err)
}
}
func TestHandleUpgradeResponse_Mismatch(t *testing.T) {
h := &httpHandler{}
h.md.proxyAgent = defaultProxyAgent
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")
res := &http.Response{
Header: http.Header{
"Connection": {"Upgrade"},
"Upgrade": {"h2c"},
},
}
err := h.handleUpgradeResponse(context.Background(), &testReadWriteCloser{}, req, res, &xrecorder.HandlerRecorderObject{}, &testLogger{})
if err == nil {
t.Error("expected error for upgrade type mismatch")
}
}
func TestHandleUpgradeResponse_NonReadWriteCloserBody(t *testing.T) {
h := &httpHandler{}
h.md.proxyAgent = defaultProxyAgent
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")
res := &http.Response{
Header: http.Header{
"Connection": {"Upgrade"},
"Upgrade": {"websocket"},
},
Body: io.NopCloser(strings.NewReader("not a rwc")),
}
err := h.handleUpgradeResponse(context.Background(), &testReadWriteCloser{}, req, res, &xrecorder.HandlerRecorderObject{}, &testLogger{})
if err == nil {
t.Error("expected error for non-io.ReadWriteCloser body")
}
}
func TestProxyRoundTrip_HeaderCleanup(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify headers are cleaned
if r.Header.Get("Proxy-Authorization") != "" {
t.Error("Proxy-Authorization should be removed")
}
if r.Header.Get("Proxy-Connection") != "" {
t.Error("Proxy-Connection should be removed")
}
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
transport: ts.Client().Transport,
}
h.md.readTimeout = 15
req, _ := http.NewRequest("GET", ts.URL, nil)
req.Header.Set("Proxy-Authorization", "Basic xyz")
req.Header.Set("Proxy-Connection", "keep-alive")
req.Header.Set("Gost-Target", "hidden")
req.Header.Set("X-Gost-Target", "also-hidden")
ro := &xrecorder.HandlerRecorderObject{
RemoteAddr: "127.0.0.1:12345",
}
pStats := xstats.Stats{}
rw := &testReadWriteCloser{buf: new(strings.Builder)}
_, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
if err != nil {
t.Fatalf("proxyRoundTrip error: %v", err)
}
}
// testBypass implements bypass.Bypass for testing.
type testBypass struct {
contains bool
}
func (b *testBypass) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool {
return b.contains
}
func (b *testBypass) IsWhitelist() bool { return false }
// testRoundTripper implements http.RoundTripper for testing.
type testRoundTripper struct {
err bool
status int
body string
}
func (rt *testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if rt.err {
return nil, &testError{msg: "transport error"}
}
if rt.status == 0 {
rt.status = http.StatusOK
}
return &http.Response{
StatusCode: rt.status,
Body: io.NopCloser(strings.NewReader(rt.body)),
Header: http.Header{},
}, nil
}
func TestHandleProxy_SingleRequest(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Connection", "close")
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
transport: ts.Client().Transport,
}
h.md.readTimeout = 15
client, server := net.Pipe()
defer client.Close()
defer server.Close()
req, _ := http.NewRequest("GET", ts.URL, nil)
ro := &xrecorder.HandlerRecorderObject{
RemoteAddr: "127.0.0.1:12345",
}
// Drain response in a goroutine
go func() {
io.ReadAll(server)
}()
err := h.handleProxy(context.Background(), client, req, ro, &testLogger{})
if err != nil {
t.Logf("handleProxy returned: %v", err)
}
}
func TestProxyRoundTrip_SwitchingProtocols(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
transport: &testRoundTripper{status: http.StatusSwitchingProtocols, body: ""},
}
h.md.readTimeout = 15
req, _ := http.NewRequest("GET", "http://example.com", nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")
ro := &xrecorder.HandlerRecorderObject{
RemoteAddr: "127.0.0.1:12345",
}
pStats := xstats.Stats{}
rw := &testReadWriteCloser{buf: new(strings.Builder)}
_, err := h.proxyRoundTrip(context.Background(), rw, req, ro, &pStats, &testLogger{})
// Switching protocols with a non-RWC body should error
if err == nil {
t.Log("expected error for switching protocols with non-RWC body")
}
}
type testError struct {
msg string
}
func (e *testError) Error() string { return e.msg }
func (e *testError) Timeout() bool { return false }
func (e *testError) Temporary() bool { return true }
// testReadWriteCloser implements io.ReadWriteCloser for testing.
type testReadWriteCloser struct {
buf *strings.Builder
}
func (rw *testReadWriteCloser) Read(p []byte) (int, error) { return 0, io.EOF }
func (rw *testReadWriteCloser) Write(p []byte) (int, error) {
if rw.buf != nil {
return rw.buf.Write(p)
}
return len(p), nil
}
func (rw *testReadWriteCloser) Close() error { return nil }
+16 -2
View File
@@ -16,6 +16,13 @@ import (
xrecorder "github.com/go-gost/x/recorder" xrecorder "github.com/go-gost/x/recorder"
) )
// handleUDP implements UDP over HTTP (UDP relay). When the client sets the
// X-Gost-Protocol header to "udp", the handler responds with 200 OK and
// establishes a UDP association through the proxy chain. Client data sent
// over the HTTP connection is wrapped as SOCKS5 UDP packets and relayed
// through the UDP tunnel.
//
// If UDP relay is disabled (enableUDP=false), a 403 Forbidden is returned.
func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error { func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
log = log.WithFields(map[string]any{ log = log.WithFields(map[string]any{
"cmd": "udp", "cmd": "udp",
@@ -53,9 +60,14 @@ func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, ro *xrecorde
return err return err
} }
// obtain a udp connection // Dial a UDP association through the proxy chain router. The empty
// address signals that the router should create a UDP socket rather
// than connect to a specific target.
if h.options.Router == nil {
return errors.New("nil router")
}
var buf bytes.Buffer var buf bytes.Buffer
c, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "udp", "") // UDP association c, err := h.options.Router.Dial(ictx.ContextWithBuffer(ctx, &buf), "udp", "")
ro.Route = buf.String() ro.Route = buf.String()
if err != nil { if err != nil {
log.Error(err) log.Error(err)
@@ -73,6 +85,8 @@ func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, ro *xrecorde
return err return err
} }
// Wrap the HTTP connection as a SOCKS5 UDP tunnel server conn so
// that the relay can read/write SOCKS5-encapsulated UDP datagrams.
relay := udp.NewRelay(socks.UDPTunServerConn(conn), pc). relay := udp.NewRelay(socks.UDPTunServerConn(conn), pc).
WithService(h.options.Service). WithService(h.options.Service).
WithBypass(h.options.Bypass). WithBypass(h.options.Bypass).
+78
View File
@@ -0,0 +1,78 @@
package http
import (
"bufio"
"context"
"net"
"net/http"
"testing"
"github.com/go-gost/core/handler"
xrecorder "github.com/go-gost/x/recorder"
)
func TestHandleUDP_Disabled_WritesForbidden(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
h.md.enableUDP = false
client, server := net.Pipe()
defer client.Close()
defer server.Close()
ro := &xrecorder.HandlerRecorderObject{}
go func() {
h.handleUDP(context.Background(), server, ro, &testLogger{})
}()
br := bufio.NewReader(client)
resp, err := http.ReadResponse(br, nil)
if err != nil {
t.Fatalf("read response: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusForbidden)
}
}
func TestHandleUDP_Enabled_NilRouter(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
}
h.md.proxyAgent = defaultProxyAgent
h.md.enableUDP = true
client, server := net.Pipe()
defer client.Close()
defer server.Close()
ro := &xrecorder.HandlerRecorderObject{}
go func() {
// OK response sent, then nil router causes error
err := h.handleUDP(context.Background(), server, ro, &testLogger{})
if err == nil {
t.Log("expected error with nil router for UDP")
}
}()
br := bufio.NewReader(client)
resp, err := http.ReadResponse(br, nil)
if err != nil {
t.Fatalf("read response: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK)
}
}
+86
View File
@@ -0,0 +1,86 @@
package http
import (
"encoding/base64"
"encoding/binary"
"errors"
"hash/crc32"
"net"
"net/http"
"strings"
"golang.org/x/net/http/httpguts"
)
// decodeServerName decodes a GOST v2 Gost-Target / X-Gost-Target header
// value. The encoding is: base64(CRC32(hostname) + base64(hostname)).
// It verifies the CRC32 checksum before returning the decoded hostname.
func decodeServerName(s string) (string, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return "", err
}
if len(b) < 4 {
return "", errors.New("invalid name")
}
v, err := base64.RawURLEncoding.DecodeString(string(b[4:]))
if err != nil {
return "", err
}
if crc32.ChecksumIEEE(v) != binary.BigEndian.Uint32(b[:4]) {
return "", errors.New("invalid name")
}
return string(v), nil
}
// basicProxyAuth extracts the username and password from an HTTP Basic
// authentication Proxy-Authorization header value. It returns ok=false
// if the value is empty, not a Basic scheme, or not valid base64.
func basicProxyAuth(proxyAuth string) (username, password string, ok bool) {
if proxyAuth == "" {
return
}
if !strings.HasPrefix(proxyAuth, "Basic ") {
return
}
c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(proxyAuth, "Basic "))
if err != nil {
return
}
cs := string(c)
username, password, ok = strings.Cut(cs, ":")
if !ok {
return
}
return username, password, true
}
// upgradeType returns the upgrade protocol token from an HTTP header.
// It returns "" if the Connection header does not include an "Upgrade"
// token or if the Upgrade header is absent.
func upgradeType(h http.Header) string {
if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
return ""
}
return h.Get("Upgrade")
}
// normalizeHostPort ensures a host string includes a port. If the host
// already contains a port, it is returned unchanged. Otherwise the
// defaultPort is appended. IPv6 addresses are correctly handled: bare
// addresses get bracketed by net.JoinHostPort, and bracketed addresses
// are stripped of brackets before re-joining.
func normalizeHostPort(host, defaultPort string) string {
if _, port, _ := net.SplitHostPort(host); port == "" {
return net.JoinHostPort(strings.Trim(host, "[]"), defaultPort)
}
return host
}
// buildConnectResponse returns the raw bytes of an HTTP 200 Connection
// established response with the given Proxy-Agent header.
func buildConnectResponse(proxyAgent string) []byte {
return []byte("HTTP/1.1 200 Connection established\r\n" +
"Proxy-Agent: " + proxyAgent + "\r\n\r\n")
}
+341
View File
@@ -0,0 +1,341 @@
package http
import (
"encoding/base64"
"encoding/binary"
"hash/crc32"
"net/http"
"testing"
)
func TestDecodeServerName(t *testing.T) {
// Helper: encode a hostname using the GOST v2 encoding scheme
encodeName := func(name string) string {
v := []byte(name)
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, crc32.ChecksumIEEE(v))
inner := base64.RawURLEncoding.EncodeToString(v)
return base64.RawURLEncoding.EncodeToString(append(b, []byte(inner)...))
}
tests := []struct {
name string
input string
want string
wantErr bool
}{
{
name: "valid hostname",
input: encodeName("example.com:8080"),
want: "example.com:8080",
},
{
name: "valid IP address",
input: encodeName("1.2.3.4:443"),
want: "1.2.3.4:443",
},
{
name: "empty string",
input: "",
wantErr: true,
},
{
name: "invalid base64",
input: "!!!not-valid-base64!!!",
wantErr: true,
},
{
name: "too short (less than 4 bytes)",
input: base64.RawURLEncoding.EncodeToString([]byte("ab")),
wantErr: true,
},
{
name: "valid base64 but inner is garbage",
input: base64.RawURLEncoding.EncodeToString([]byte("0000!!!!")),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := decodeServerName(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("decodeServerName(%q) = (%q, nil), want error", tt.input, got)
}
return
}
if err != nil {
t.Errorf("decodeServerName(%q) error = %v", tt.input, err)
return
}
if got != tt.want {
t.Errorf("decodeServerName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestDecodeServerName_CRC32Mismatch(t *testing.T) {
name := "test.example.com"
v := []byte(name)
// Use wrong CRC32
badCRC := crc32.ChecksumIEEE(v) ^ 0xFFFFFFFF
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, badCRC)
inner := base64.RawURLEncoding.EncodeToString(v)
s := base64.RawURLEncoding.EncodeToString(append(b, []byte(inner)...))
_, err := decodeServerName(s)
if err == nil {
t.Error("expected error for CRC32 mismatch")
}
}
func TestBasicProxyAuth(t *testing.T) {
encode := func(u, p string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(u+":"+p))
}
tests := []struct {
name string
proxyAuth string
wantUser string
wantPass string
wantOK bool
}{
{
name: "valid basic auth",
proxyAuth: encode("admin", "secret"),
wantUser: "admin",
wantPass: "secret",
wantOK: true,
},
{
name: "username only, no password",
proxyAuth: "Basic " + base64.StdEncoding.EncodeToString([]byte("user:")),
wantUser: "user",
wantPass: "",
wantOK: true,
},
{
name: "password only, no username",
proxyAuth: "Basic " + base64.StdEncoding.EncodeToString([]byte(":pass")),
wantUser: "",
wantPass: "pass",
wantOK: true,
},
{
name: "password contains colon",
proxyAuth: "Basic " + base64.StdEncoding.EncodeToString([]byte("user:a:b:c")),
wantUser: "user",
wantPass: "a:b:c",
wantOK: true,
},
{
name: "empty string",
proxyAuth: "",
},
{
name: "not Basic scheme",
proxyAuth: "Bearer token123",
},
{
name: "invalid base64",
proxyAuth: "Basic not-valid-base64!",
},
{
name: "no colon separator",
proxyAuth: "Basic " + base64.StdEncoding.EncodeToString([]byte("username")),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user, pass, ok := basicProxyAuth(tt.proxyAuth)
if ok != tt.wantOK {
t.Errorf("basicProxyAuth(%q) ok = %v, want %v", tt.proxyAuth, ok, tt.wantOK)
return
}
if ok {
if user != tt.wantUser {
t.Errorf("basicProxyAuth(%q) user = %q, want %q", tt.proxyAuth, user, tt.wantUser)
}
if pass != tt.wantPass {
t.Errorf("basicProxyAuth(%q) pass = %q, want %q", tt.proxyAuth, pass, tt.wantPass)
}
}
})
}
}
func TestUpgradeType(t *testing.T) {
tests := []struct {
name string
header http.Header
want string
}{
{
name: "nil header",
header: nil,
want: "",
},
{
name: "websocket upgrade",
header: http.Header{
"Connection": {"Upgrade"},
"Upgrade": {"websocket"},
},
want: "websocket",
},
{
name: "http2 upgrade",
header: http.Header{
"Connection": {"Upgrade"},
"Upgrade": {"h2c"},
},
want: "h2c",
},
{
name: "no connection upgrade header",
header: http.Header{
"Upgrade": {"websocket"},
},
want: "",
},
{
name: "empty header",
header: http.Header{},
want: "",
},
{
name: "connection has upgrade but no upgrade header",
header: http.Header{
"Connection": {"Upgrade"},
},
want: "",
},
{
name: "connection upgrade in multi-value",
header: http.Header{
"Connection": {"keep-alive", "Upgrade"},
"Upgrade": {"websocket"},
},
want: "websocket",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := upgradeType(tt.header); got != tt.want {
t.Errorf("upgradeType() = %q, want %q", got, tt.want)
}
})
}
}
func TestNormalizeHostPort(t *testing.T) {
tests := []struct {
name string
host string
defaultPort string
want string
}{
{
name: "hostname without port",
host: "example.com",
defaultPort: "80",
want: "example.com:80",
},
{
name: "hostname with port",
host: "example.com:8080",
defaultPort: "80",
want: "example.com:8080",
},
{
name: "IPv4 without port",
host: "1.2.3.4",
defaultPort: "80",
want: "1.2.3.4:80",
},
{
name: "IPv4 with port",
host: "1.2.3.4:443",
defaultPort: "80",
want: "1.2.3.4:443",
},
{
name: "IPv6 without port (bracketed)",
host: "[::1]",
defaultPort: "80",
want: "[::1]:80",
},
{
name: "IPv6 with port",
host: "[::1]:443",
defaultPort: "80",
want: "[::1]:443",
},
{
name: "empty string",
host: "",
defaultPort: "80",
want: ":80",
},
{
name: "custom default port",
host: "example.com",
defaultPort: "443",
want: "example.com:443",
},
{
name: "bare IPv6 without brackets or port",
host: "::1",
defaultPort: "80",
want: "[::1]:80", // net.JoinHostPort wraps IPv6 in brackets
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeHostPort(tt.host, tt.defaultPort); got != tt.want {
t.Errorf("normalizeHostPort(%q, %q) = %q, want %q", tt.host, tt.defaultPort, got, tt.want)
}
})
}
}
func TestBuildConnectResponse(t *testing.T) {
got := buildConnectResponse("gost/3.0")
if len(got) == 0 {
t.Error("buildConnectResponse returned empty")
}
s := string(got)
if !contains(s, "200") {
t.Errorf("expected 200 status in response: %s", s)
}
if !contains(s, "Connection established") {
t.Errorf("expected Connection established in response: %s", s)
}
if !contains(s, "gost/3.0") {
t.Errorf("expected proxy agent in response: %s", s)
}
}
func TestBuildConnectResponse_CustomAgent(t *testing.T) {
got := buildConnectResponse("custom-agent/1.0")
s := string(got)
if !contains(s, "custom-agent/1.0") {
t.Errorf("expected custom agent in response: %s", s)
}
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+138
View File
@@ -0,0 +1,138 @@
package http
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 relays WebSocket frames between the client (rw)
// and the backend (cc) while sampling frames for recording. Two goroutines
// run in parallel — one per direction — copying frames and recording them
// at the configured sample rate.
//
// The first direction to encounter an error terminates the relay.
func (h *httpHandler) sniffingWebsocketFrame(ctx context.Context, rw, cc io.ReadWriter, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
errc := make(chan error, 1)
sampleRate := h.md.sniffingWebsocketSampleRate
if sampleRate == 0 {
sampleRate = sniffing.DefaultSampleRate
}
if sampleRate < 0 {
sampleRate = math.MaxFloat64
}
go h.copyWebsocketDirection(ctx, cc, rw, "client", ro, sampleRate, log, errc)
go h.copyWebsocketDirection(ctx, rw, cc, "server", ro, sampleRate, log, errc)
<-errc
return nil
}
// copyWebsocketDirection copies WebSocket frames from r to w in a loop
// until an error occurs. Each frame is recorded (subject to the rate
// limiter) with directional metadata — "client" for frames originating
// from the client, "server" for frames from the backend.
//
// The ro parameter is cloned at the start so per-frame mutations are
// isolated from the caller.
func (h *httpHandler) copyWebsocketDirection(ctx context.Context, r io.Reader, w io.Writer, from string, ro *xrecorder.HandlerRecorderObject, sampleRate float64, log logger.Logger, errc chan<- error) {
if ro == nil {
ro = &xrecorder.HandlerRecorderObject{}
}
ro2 := &xrecorder.HandlerRecorderObject{}
*ro2 = *ro
r2 := ro2
limiter := rate.NewLimiter(rate.Limit(sampleRate), int(sampleRate))
buf := &bytes.Buffer{}
for {
start := time.Now()
if err := h.copyWebsocketFrame(w, r, buf, from, r2); err != nil {
errc <- err
return
}
if limiter.Allow() {
r2.Duration = time.Since(start)
r2.Time = time.Now()
if err := r2.Record(ctx, h.recorder.Recorder); err != nil {
log.Errorf("record: %v", err)
}
}
}
}
// copyWebsocketFrame reads a single WebSocket frame from r, records its
// metadata and optionally its payload, then writes it to w.
//
// The payload is tee'd: it is copied to buf for recording (up to the
// configured body size limit) and then forwarded by reconstructing the
// frame with a MultiReader that replays the captured bytes followed by
// the remaining data.
//
// Directional byte counts are set on the recorder object — InputBytes
// for client frames, OutputBytes for server frames.
func (h *httpHandler) 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 := h.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
}
// Reconstruct the frame by prepending any captured payload bytes
// before the (possibly truncated) original data stream.
fr.Data = io.MultiReader(bytes.NewReader(buf.Bytes()), fr.Data)
if _, err := fr.WriteTo(w); err != nil {
return err
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package http
import (
"bytes"
"context"
"io"
"testing"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/recorder"
)
func TestCopyWebsocketFrame_ReadError(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
}
buf := &bytes.Buffer{}
err := h.copyWebsocketFrame(io.Discard, bytes.NewReader(nil), buf, "client", nil)
if err == nil {
t.Error("expected read error from empty reader")
}
}
func TestCopyWebsocketDirection_ErrorReader(t *testing.T) {
h := &httpHandler{
options: handler.Options{
Logger: &testLogger{},
},
}
h.recorder = recorder.RecorderObject{}
errc := make(chan error, 1)
go h.copyWebsocketDirection(context.Background(), &errorReader{}, io.Discard, "client", nil, 10.0, &testLogger{}, errc)
err := <-errc
if err == nil {
t.Error("expected error from error reader")
}
}
// errorReader always returns an error.
type errorReader struct{}
func (r *errorReader) Read(p []byte) (int, error) { return 0, io.ErrUnexpectedEOF }
func (r *errorReader) Write(p []byte) (int, error) { return len(p), nil }
func (r *errorReader) Close() error { return nil }