diff --git a/handler/http/auth.go b/handler/http/auth.go index ab75ae4c..22785b34 100644 --- a/handler/http/auth.go +++ b/handler/http/auth.go @@ -16,129 +16,161 @@ import ( 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. +// AuthResult is returned by Authenticator.Authenticate. When OK is false, +// Response is the HTTP response to write to the client (407 or a probe- +// resistance decoy). When PipeTo is non-empty, the caller must dial the +// given address, pipe the raw request through it, and return immediately — +// the connection is consumed by host-mode probe resistance. +type AuthResult struct { + ClientID string + OK bool + Response *http.Response // non-nil when !OK (407 or probe-resistance decoy) + PipeTo string // non-empty when pr.Type=="host"; caller dials+pipes +} + +// Authenticator validates Proxy-Authorization headers and generates probe- +// resistance decoy responses on failure. It does not perform I/O — the +// caller is responsible for writing the returned response or dialling the +// PipeTo target. +type Authenticator struct { + Auther auth.Authenticator + PR *probeResistance + Realm string + Service string // service name passed to auth.WithService + WebClient func(string) (*http.Response, error) // injectable for tests; defaults to http.Get + Log logger.Logger +} + +// Authenticate validates the client's credentials and returns an AuthResult. +// When no Auther is configured, all requests are anonymous (OK=true). +// On auth failure, if probe resistance is configured, a decoy response is +// built; otherwise a standard 407 Proxy-Auth-Required is returned. // -// 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 (comma-separated hostnames), probe resistance -// only activates if the request hostname matches NONE of the knock addresses. -// Clients that know any 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) { +// The "host" probe resistance strategy is signalled via PipeTo rather than +// handled inline — the caller must dial the PipeTo address and pipe the +// raw request through it. All other strategies produce a Response. +func (a *Authenticator) Authenticate(ctx context.Context, req *http.Request) *AuthResult { u, p, _ := basicProxyAuth(req.Header.Get("Proxy-Authorization")) - if h.options.Auther == nil { - return "", true + if a.Auther == nil { + return &AuthResult{OK: true} } - if id, ok = h.options.Auther.Authenticate(ctx, u, p, auth.WithService(h.options.Service)); ok { - return + if id, ok := a.Auther.Authenticate(ctx, u, p, auth.WithService(a.optionsService())); ok { + return &AuthResult{OK: true, ClientID: id} } - 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. + pr := a.PR if pr != nil && (pr.Knock == "" || !knockMatch(req.URL.Hostname(), pr.Knock)) { - resp.StatusCode = http.StatusServiceUnavailable + return a.probeResistanceResponse(req) + } - 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() + return a.build407Response(req) +} - 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() +func (a *Authenticator) optionsService() string { + return a.Service +} - resp.StatusCode = http.StatusOK - if finfo, _ := f.Stat(); finfo != nil { - resp.ContentLength = finfo.Size() - } - resp.Header.Set("Content-Type", "text/html") - resp.Body = f +func (a *Authenticator) probeResistanceResponse(req *http.Request) *AuthResult { + pr := a.PR + resp := &http.Response{ + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{}, + ContentLength: -1, + StatusCode: http.StatusServiceUnavailable, + } + + switch pr.Type { + case "code": + if code, err := strconv.Atoi(pr.Value); err == nil { + resp.StatusCode = code + } else if a.Log != nil { + a.Log.Warnf("invalid probe resistance code: %s", pr.Value) + } + case "web": + url := pr.Value + if !strings.HasPrefix(url, "http") { + url = "http://" + url + } + webClient := a.WebClient + if webClient == nil { + webClient = httpGet + } + r, err := webClient(url) + if err != nil { + if a.Log != nil { + a.Log.Error(err) } + break + } + *resp = *r + case "host": + return &AuthResult{PipeTo: pr.Value} + case "file": + f, _ := os.Open(pr.Value) + if f != nil { + 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") - } + return a.build407Response(req) } - if log.IsLevelEnabled(logger.TraceLevel) { - dump, _ := httputil.DumpResponse(resp, false) - log.Trace(string(dump)) + if resp.StatusCode == http.StatusOK { + resp.Header.Set("Connection", "keep-alive") } + return &AuthResult{Response: resp} +} - if resp.Body != nil { - defer resp.Body.Close() +func (a *Authenticator) build407Response(req *http.Request) *AuthResult { + realm := defaultRealm + if a.Realm != "" { + realm = a.Realm } - if err := resp.Write(conn); err != nil { - log.Error("write auth response: ", err) + resp := &http.Response{ + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{}, + ContentLength: -1, + StatusCode: http.StatusProxyAuthRequired, } - return + resp.Header.Add("Proxy-Authenticate", fmt.Sprintf("Basic realm=\"%s\"", realm)) + if strings.ToLower(req.Header.Get("Proxy-Connection")) == "keep-alive" { + resp.Header.Set("Connection", "close") + resp.Header.Set("Proxy-Connection", "close") + } + return &AuthResult{Response: resp} +} + +// httpGet is the default web client used by probe-resistance "web" strategy. +func httpGet(url string) (*http.Response, error) { + return (&http.Client{Timeout: 15 * time.Second}).Get(url) +} + +// handleProbeResistanceHost dials the decoy host and relays the raw request +// and response bidirectionally. It is called by handleRequest when the +// Authenticator returns PipeTo != "". The connection is consumed. +func (h *httpHandler) handleProbeResistanceHost(ctx context.Context, conn net.Conn, req *http.Request, target string, log logger.Logger, resp *http.Response) error { + cc, err := net.Dial("tcp", target) + if err != nil { + log.Error(err) + resp.StatusCode = http.StatusServiceUnavailable + if log.IsLevelEnabled(logger.TraceLevel) { + dump, _ := httputil.DumpResponse(resp, false) + log.Trace(string(dump)) + } + return resp.Write(conn) + } + defer cc.Close() + + req.Write(cc) + return xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.idleTimeout)) } // checkRateLimit verifies that the remote address has not exceeded the diff --git a/handler/http/auth_test.go b/handler/http/auth_test.go index 08f5078e..565c746d 100644 --- a/handler/http/auth_test.go +++ b/handler/http/auth_test.go @@ -1,19 +1,16 @@ 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. @@ -26,345 +23,185 @@ func (a *stubAuther) Authenticate(ctx context.Context, user, password string, op 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) {} +// --- Authenticator tests (synchronous — no net.Pipe or goroutines) --- 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() - + a := &Authenticator{} req, _ := http.NewRequest("GET", "http://example.com/", nil) - resp := &http.Response{ - ProtoMajor: 1, - ProtoMinor: 1, - Header: http.Header{}, - ContentLength: -1, + result := a.Authenticate(context.Background(), req) + if !result.OK { + t.Fatal("expected OK=true when no Auther") + } + if result.ClientID != "" { + t.Errorf("expected empty client ID, got %q", result.ClientID) } - - // 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{}, - }, + a := &Authenticator{ + Auther: &stubAuther{accept: true, id: "testuser"}, } - 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, + req.Header.Set("Proxy-Authorization", "Basic dGVzdHVzZXI6cGFzc3dvcmQ=") + result := a.Authenticate(context.Background(), req) + if !result.OK { + t.Fatal("expected auth success") + } + if result.ClientID != "testuser" { + t.Errorf("got id %q, want testuser", result.ClientID) } - - 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{}, - }, + a := &Authenticator{ + Auther: &stubAuther{accept: false}, + Realm: "gost", } - 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, + result := a.Authenticate(context.Background(), req) + if result.OK { + t.Fatal("expected auth failure") } - - 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) + if result.Response == nil { + t.Fatal("expected non-nil response on failure") } - defer r.Body.Close() - - if r.StatusCode != http.StatusProxyAuthRequired { - t.Errorf("got status %d, want %d", r.StatusCode, http.StatusProxyAuthRequired) + if result.Response.StatusCode != http.StatusProxyAuthRequired { + t.Errorf("got status %d, want %d", result.Response.StatusCode, http.StatusProxyAuthRequired) } - if r.Header.Get("Proxy-Authenticate") == "" { + if result.Response.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{}, - }, + a := &Authenticator{ + Auther: &stubAuther{accept: false}, + PR: &probeResistance{Type: "code", Value: "404"}, } - 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, + result := a.Authenticate(context.Background(), req) + if result.OK { + t.Fatal("expected auth failure") } - - 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) + if result.Response.StatusCode != 404 { + t.Errorf("got status %d, want 404", result.Response.StatusCode) } } func TestAuthenticate_ProbeResistanceKnock(t *testing.T) { - h := &httpHandler{ - options: handler.Options{ - Auther: &stubAuther{accept: false}, - Logger: &testLogger{}, - }, + a := &Authenticator{ + Auther: &stubAuther{accept: false}, + Realm: "gost", + PR: &probeResistance{Type: "code", Value: "404", Knock: "secret.example.com"}, } - 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, + result := a.Authenticate(context.Background(), req) + if result.OK { + t.Fatal("expected auth failure") } - - 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) + // Knock matches, probe resistance bypassed → 407 + if result.Response.StatusCode != http.StatusProxyAuthRequired { + t.Errorf("got status %d, want %d (knock matched, bypass probe resistance)", + result.Response.StatusCode, http.StatusProxyAuthRequired) } } func TestAuthenticate_ProbeResistanceKnockMismatch(t *testing.T) { - h := &httpHandler{ - options: handler.Options{ - Auther: &stubAuther{accept: false}, - Logger: &testLogger{}, - }, + a := &Authenticator{ + Auther: &stubAuther{accept: false}, + PR: &probeResistance{Type: "code", Value: "503", Knock: "secret.example.com"}, } - 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, + result := a.Authenticate(context.Background(), req) + if result.OK { + t.Fatal("expected auth failure") } - - 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) + // Knock doesn't match, probe resistance activates → 503 + if result.Response.StatusCode != 503 { + t.Errorf("got status %d, want 503", result.Response.StatusCode) } } func TestAuthenticate_ProbeResistanceKnockMultiMatch(t *testing.T) { - h := &httpHandler{ - options: handler.Options{ - Auther: &stubAuther{accept: false}, - Logger: &testLogger{}, + a := &Authenticator{ + Auther: &stubAuther{accept: false}, + Realm: "gost", + PR: &probeResistance{ + Type: "code", + Value: "404", + Knock: "a.example.com, b.example.com, secret.example.com", }, } - h.md.proxyAgent = defaultProxyAgent - // Third entry matches — probe resistance should be bypassed, return 407 - h.md.probeResistance = &probeResistance{ - Type: "code", - Value: "404", - Knock: "a.example.com, b.example.com, 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, + result := a.Authenticate(context.Background(), req) + if result.OK { + t.Fatal("expected auth failure") } - - 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() - - if r.StatusCode != http.StatusProxyAuthRequired { - t.Errorf("got status %d, want %d (knock matched, should bypass probe resistance)", r.StatusCode, http.StatusProxyAuthRequired) + if result.Response.StatusCode != http.StatusProxyAuthRequired { + t.Errorf("got status %d, want %d (knock matched, bypass probe resistance)", + result.Response.StatusCode, http.StatusProxyAuthRequired) } } func TestAuthenticate_ProbeResistanceKnockMultiMismatch(t *testing.T) { - h := &httpHandler{ - options: handler.Options{ - Auther: &stubAuther{accept: false}, - Logger: &testLogger{}, + a := &Authenticator{ + Auther: &stubAuther{accept: false}, + PR: &probeResistance{ + Type: "code", + Value: "503", + Knock: "a.example.com, b.example.com", }, } - h.md.proxyAgent = defaultProxyAgent - // No entry matches — probe resistance should activate - h.md.probeResistance = &probeResistance{ - Type: "code", - Value: "503", - Knock: "a.example.com, b.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, + result := a.Authenticate(context.Background(), req) + if result.OK { + t.Fatal("expected auth failure") } - - 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() - - if r.StatusCode != 503 { - t.Errorf("got status %d, want 503", r.StatusCode) + if result.Response.StatusCode != 503 { + t.Errorf("got status %d, want 503", result.Response.StatusCode) } } +func TestAuthenticate_ProbeResistanceHost(t *testing.T) { + a := &Authenticator{ + Auther: &stubAuther{accept: false}, + PR: &probeResistance{Type: "host", Value: "1.2.3.4:80"}, + } + req, _ := http.NewRequest("GET", "http://example.com/", nil) + result := a.Authenticate(context.Background(), req) + if result.OK { + t.Fatal("expected auth failure") + } + if result.PipeTo != "1.2.3.4:80" { + t.Errorf("got PipeTo %q, want 1.2.3.4:80", result.PipeTo) + } + if result.Response != nil { + t.Error("expected nil Response for host probe resistance") + } +} + +func TestAuthenticate_ProbeResistanceInvalidCode(t *testing.T) { + a := &Authenticator{ + Auther: &stubAuther{accept: false}, + Realm: "gost", + PR: &probeResistance{Type: "code", Value: "not-a-number"}, + } + req, _ := http.NewRequest("GET", "http://example.com/", nil) + result := a.Authenticate(context.Background(), req) + if result.OK { + t.Fatal("expected auth failure") + } + // Invalid code → status stays at 503 (the probe resistance default) + if result.Response.StatusCode != http.StatusServiceUnavailable { + t.Errorf("got status %d, want %d (invalid code keeps default 503)", + result.Response.StatusCode, http.StatusServiceUnavailable) + } +} + +// --- knockMatch tests --- + func TestKnockMatch(t *testing.T) { tests := []struct { hostname string @@ -389,11 +226,12 @@ func TestKnockMatch(t *testing.T) { } } +// --- checkRateLimit tests --- + 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") @@ -401,13 +239,11 @@ func TestCheckRateLimit_NoLimiter(t *testing.T) { } 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") @@ -420,7 +256,6 @@ func TestCheckRateLimit_Allow(t *testing.T) { 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") @@ -433,34 +268,22 @@ func TestCheckRateLimit_NilAddr(t *testing.T) { 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 + _ = h.checkRateLimit(nil) }() } -// testRecorderObject is a recorder.RecorderObject for testing. -var testRecorderObject = struct { - Record string - Options *testRecorderOptions -}{Record: "serviceHandler"} - -type testRecorderOptions struct { - HTTPBody bool -} +// --- Stub implementations (used by multiple test files) --- // stubRateLimiter implements rate.RateLimiter for testing. type stubRateLimiter struct { - allow bool - limiter *stubLimiter + allow bool + limiter *stubLimiter } func (l *stubRateLimiter) Limiter(key string) rate.Limiter { @@ -475,9 +298,9 @@ 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 } +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{} @@ -497,4 +320,3 @@ 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) {} - diff --git a/handler/http/connect.go b/handler/http/connect.go index bcbb5a61..3edfa32a 100644 --- a/handler/http/connect.go +++ b/handler/http/connect.go @@ -4,20 +4,25 @@ import ( "bufio" "bytes" "context" + "crypto" "crypto/tls" + "crypto/x509" "errors" "net" "net/http" "net/http/httputil" "time" + "github.com/go-gost/core/bypass" "github.com/go-gost/core/limiter" + "github.com/go-gost/core/recorder" 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" + tls_util "github.com/go-gost/x/internal/util/tls" 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" @@ -118,18 +123,7 @@ func (h *httpHandler) sniffAndHandle(ctx context.Context, conn net.Conn, cc net. 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, - } + sniffer := h.sniffer.Build() conn = xnet.NewReadWriteConn(br, conn, conn) switch proto { @@ -223,3 +217,34 @@ func (h *httpHandler) setupTrafficLimiter(conn net.Conn, clientID, network, addr return xnet.NewReadWriteConn(rw, rw, conn), cleanup } + +// SnifferBuilder holds all configuration needed to construct a sniffing.Sniffer. +// It is populated once during Init and reused for each sniffed connection. +type SnifferBuilder struct { + Websocket bool + WebsocketSampleRate float64 + Recorder recorder.Recorder + RecorderOptions *recorder.Options + Certificate *x509.Certificate + PrivateKey crypto.PrivateKey + ALPN string + CertPool tls_util.CertPool + MitmBypass bypass.Bypass + ReadTimeout time.Duration +} + +// Build creates a new sniffing.Sniffer from the builder's configuration. +func (b *SnifferBuilder) Build() *sniffing.Sniffer { + return &sniffing.Sniffer{ + Websocket: b.Websocket, + WebsocketSampleRate: b.WebsocketSampleRate, + Recorder: b.Recorder, + RecorderOptions: b.RecorderOptions, + Certificate: b.Certificate, + PrivateKey: b.PrivateKey, + NegotiatedProtocol: b.ALPN, + CertPool: b.CertPool, + MitmBypass: b.MitmBypass, + ReadTimeout: b.ReadTimeout, + } +} diff --git a/handler/http/connect_test.go b/handler/http/connect_test.go index dd97f9ad..bef4139c 100644 --- a/handler/http/connect_test.go +++ b/handler/http/connect_test.go @@ -18,6 +18,7 @@ func TestSniffAndHandle_NoMatch(t *testing.T) { }, } h.md.sniffing = true + h.sniffer = &SnifferBuilder{} client, server := net.Pipe() defer client.Close() @@ -59,6 +60,7 @@ func TestSniffAndHandle_TLS(t *testing.T) { }, } h.md.sniffing = true + h.sniffer = &SnifferBuilder{} client, server := net.Pipe() defer client.Close() @@ -115,6 +117,7 @@ func TestHandleConnect_SniffingEnabled(t *testing.T) { h.md.proxyAgent = defaultProxyAgent h.md.readTimeout = 15 h.md.sniffing = true + h.sniffer = &SnifferBuilder{} client, server := net.Pipe() defer client.Close() diff --git a/handler/http/handler.go b/handler/http/handler.go index 3b7fa757..f9bc1209 100644 --- a/handler/http/handler.go +++ b/handler/http/handler.go @@ -196,7 +196,6 @@ import ( "net/http/httputil" "time" - "github.com/asaskevich/govalidator" "github.com/go-gost/core/bypass" "github.com/go-gost/core/handler" "github.com/go-gost/core/limiter" @@ -230,6 +229,8 @@ func init() { type httpHandler struct { md metadata // parsed configuration options handler.Options // handler options from the service config + auth *Authenticator // auth + probe resistance (constructed in Init) + sniffer *SnifferBuilder // builds sniffing.Sniffer per connection stats *stats_util.HandlerStats // per-client stats, created when Observer is set limiter traffic.TrafficLimiter // per-client traffic shaper (cached) cancel context.CancelFunc // cancels the observeStats goroutine @@ -282,6 +283,27 @@ func (h *httpHandler) Init(md md.Metadata) error { } } + h.auth = &Authenticator{ + Auther: h.options.Auther, + PR: h.md.probeResistance, + Realm: h.md.authBasicRealm, + Service: h.options.Service, + Log: h.options.Logger, + } + + h.sniffer = &SnifferBuilder{ + Websocket: h.md.sniffingWebsocket, + WebsocketSampleRate: h.md.sniffingWebsocketSampleRate, + Recorder: h.recorder.Recorder, + RecorderOptions: h.recorder.Options, + Certificate: h.md.certificate, + PrivateKey: h.md.privateKey, + ALPN: h.md.alpn, + CertPool: h.certPool, + MitmBypass: h.md.mitmBypass, + ReadTimeout: h.md.readTimeout, + } + if h.md.certificate != nil && h.md.privateKey != nil { h.certPool = tls_util.NewMemoryCertPool() } @@ -382,39 +404,10 @@ func (h *httpHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler // - HTTP forward proxy (GET/POST/…): handleProxy // - HTTP CONNECT tunnel: handleConnect func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *http.Request, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error { - // If the URL is not absolute (typical for plain HTTP proxy requests), - // attempt to infer the scheme by validating the Host header. A valid - // DNS name or IP address implies http://. - if !req.URL.IsAbs() { - host := req.Host - if h, _, err := net.SplitHostPort(host); err == nil { - host = h - } - if govalidator.IsDNSName(host) || net.ParseIP(host) != nil { - req.URL.Scheme = "http" - } - } - - network := req.Header.Get("X-Gost-Protocol") - if network != "udp" { - network = "tcp" - } + nr := normalizeRequest(req) + network := nr.Network + addr := nr.Addr ro.Network = network - - // GOST v2 sent the actual target host in Gost-Target / X-Gost-Target - // headers using a base64+CRC32 encoding. Decode them for compatibility. - if v := req.Header.Get("Gost-Target"); v != "" { - if h, err := decodeServerName(v); err == nil { - req.Host = h - } - } - if v := req.Header.Get("X-Gost-Target"); v != "" { - if h, err := decodeServerName(v); err == nil { - req.Host = h - } - } - - addr := normalizeHostPort(req.Host, "80") ro.Host = addr fields := map[string]any{ @@ -446,17 +439,7 @@ func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *htt resp.Header = http.Header{} } - ro.HTTP = &xrecorder.HTTPRecorderObject{ - Host: req.Host, - Proto: req.Proto, - Scheme: req.URL.Scheme, - Method: req.Method, - URI: req.RequestURI, - Request: xrecorder.HTTPRequestRecorderObject{ - ContentLength: req.ContentLength, - Header: req.Header.Clone(), - }, - } + ro.HTTP = buildHTTPRecorder(req) defer func() { ro.HTTP.StatusCode = resp.StatusCode ro.HTTP.Response.Header = resp.Header @@ -476,19 +459,32 @@ func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *htt return resp.Write(conn) } - clientID, ok := h.authenticate(ctx, conn, req, resp, log) - if !ok { + result := h.auth.Authenticate(ctx, req) + if !result.OK { + if result.PipeTo != "" { + return h.handleProbeResistanceHost(ctx, conn, req, result.PipeTo, log, resp) + } + if log.IsLevelEnabled(logger.TraceLevel) { + dump, _ := httputil.DumpResponse(result.Response, false) + log.Trace(string(dump)) + } + if result.Response.Body != nil { + defer result.Response.Body.Close() + } + if err := result.Response.Write(conn); err != nil { + log.Error("write auth response: ", err) + } return errors.New("authentication failed") } - log = log.WithFields(map[string]any{"clientID": clientID}) - ro.ClientID = clientID + log = log.WithFields(map[string]any{"clientID": result.ClientID}) + ro.ClientID = result.ClientID if resp.Header.Get("Proxy-Agent") == "" { resp.Header.Set("Proxy-Agent", h.md.proxyAgent) } - ctx = xctx.ContextWithClientID(ctx, xctx.ClientID(clientID)) + ctx = xctx.ContextWithClientID(ctx, xctx.ClientID(result.ClientID)) if h.options.Bypass != nil && h.options.Bypass.Contains(ctx, network, addr, bypass.WithService(h.options.Service)) { @@ -506,10 +502,10 @@ func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *htt } if network == "udp" { - return h.handleUDP(ctx, conn, ro, log) + return h.handleUDP(ctx, conn, result.ClientID, ro, log) } - conn, done := h.setupTrafficLimiter(conn, clientID, network, addr) + conn, done := h.setupTrafficLimiter(conn, result.ClientID, network, addr) if done != nil { defer done() } @@ -530,6 +526,23 @@ func (h *httpHandler) Close() error { return nil } +// buildHTTPRecorder creates an HTTPRecorderObject from the request metadata. +// The deferred status-code and response-header capture closure is set up by +// the caller (handleRequest) since it references the local resp variable. +func buildHTTPRecorder(req *http.Request) *xrecorder.HTTPRecorderObject { + return &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(), + }, + } +} + // observeStats periodically publishes per-client traffic stats to the // configured Observer. It runs in a background goroutine started by Init. // Events that fail to send are retried on the next tick. diff --git a/handler/http/handler_test.go b/handler/http/handler_test.go index 46b20854..83892aa4 100644 --- a/handler/http/handler_test.go +++ b/handler/http/handler_test.go @@ -8,14 +8,8 @@ import ( "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() @@ -212,9 +206,3 @@ func TestSetupTrafficLimiter_NoObserver(t *testing.T) { } } -// 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 -} diff --git a/handler/http/helpers_test.go b/handler/http/helpers_test.go new file mode 100644 index 00000000..0cc70023 --- /dev/null +++ b/handler/http/helpers_test.go @@ -0,0 +1,107 @@ +package http + +import ( + "context" + "net" + "strings" + "sync" + + "github.com/go-gost/core/handler" + "github.com/go-gost/core/logger" + "github.com/go-gost/core/observer" + cmdata "github.com/go-gost/core/metadata" + xmetadata "github.com/go-gost/x/metadata" +) + +// --- Shared test helpers --- + +// testLogger implements logger.Logger for testing. It discards all output. +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) {} + +// 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 +} + +// testMD creates metadata from a map for testing. +func testMD(m map[string]any) cmdata.Metadata { + return xmetadata.NewMetadata(m) +} + +// --- Handler factory helpers --- + +// newTestHandler creates an httpHandler with default test settings. +// Pass additional options to override defaults. +func newTestHandler(opts ...handler.Option) *httpHandler { + options := handler.Options{ + Logger: &testLogger{}, + } + for _, opt := range opts { + opt(&options) + } + h := &httpHandler{ + options: options, + } + return h +} + +// newInitdHandler creates an httpHandler and calls Init with empty metadata. +func newInitdHandler(opts ...handler.Option) *httpHandler { + h := newTestHandler(opts...) + _ = h.Init(testMD(map[string]any{})) + return h +} + +// --- stringConn: in-memory net.Conn for tests --- + +// stringConn is an in-memory net.Conn implementation for testing. +// It reads from a prepopulated buffer and captures writes into a separate +// buffer. Useful for testing code that writes HTTP responses without +// needing net.Pipe() + goroutine. +type stringConn struct { + readBuf *strings.Reader + writeBuf *strings.Builder + closed bool + mu sync.Mutex +} + +func newStringConn(data string) *stringConn { + return &stringConn{ + readBuf: strings.NewReader(data), + writeBuf: &strings.Builder{}, + } +} + +func (c *stringConn) Read(b []byte) (int, error) { return c.readBuf.Read(b) } +func (c *stringConn) Write(b []byte) (int, error) { return c.writeBuf.Write(b) } +func (c *stringConn) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + c.closed = true + return nil +} +func (c *stringConn) LocalAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8080} } +func (c *stringConn) RemoteAddr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 12345} } +func (c *stringConn) SetDeadline(t any) error { return nil } +func (c *stringConn) SetReadDeadline(t any) error { return nil } +func (c *stringConn) SetWriteDeadline(t any) error { return nil } +func (c *stringConn) Bytes() []byte { return []byte(c.writeBuf.String()) } +func (c *stringConn) String() string { return c.writeBuf.String() } diff --git a/handler/http/proxy.go b/handler/http/proxy.go index 946f9f81..6a6b9914 100644 --- a/handler/http/proxy.go +++ b/handler/http/proxy.go @@ -177,18 +177,9 @@ func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser, // 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 - } + if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && req.Body != nil { + reqBody = xhttp.NewBody(req.Body, bodySize) + req.Body = reqBody } ctx = ictx.ContextWithRecorderObject(ctx, ro) @@ -235,14 +226,7 @@ func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser, // 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 - } + if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 { respBody = xhttp.NewBody(resp.Body, bodySize) resp.Body = respBody } diff --git a/handler/http/udp.go b/handler/http/udp.go index 5183c0ac..5380748b 100644 --- a/handler/http/udp.go +++ b/handler/http/udp.go @@ -10,6 +10,7 @@ import ( "time" "github.com/go-gost/core/logger" + stats "github.com/go-gost/core/observer/stats" ictx "github.com/go-gost/x/internal/ctx" "github.com/go-gost/x/internal/net/udp" "github.com/go-gost/x/internal/util/socks" @@ -23,7 +24,7 @@ import ( // 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, clientID string, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error { log = log.WithFields(map[string]any{ "cmd": "udp", }) @@ -60,6 +61,13 @@ func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, ro *xrecorde return err } + if h.options.Observer != nil { + pstats := h.stats.Stats(clientID) + pstats.Add(stats.KindTotalConns, 1) + pstats.Add(stats.KindCurrentConns, 1) + defer pstats.Add(stats.KindCurrentConns, -1) + } + // 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. diff --git a/handler/http/udp_test.go b/handler/http/udp_test.go index 4ad26285..97afab57 100644 --- a/handler/http/udp_test.go +++ b/handler/http/udp_test.go @@ -27,7 +27,7 @@ func TestHandleUDP_Disabled_WritesForbidden(t *testing.T) { ro := &xrecorder.HandlerRecorderObject{} go func() { - h.handleUDP(context.Background(), server, ro, &testLogger{}) + h.handleUDP(context.Background(), server, "", ro, &testLogger{}) }() br := bufio.NewReader(client) @@ -59,7 +59,7 @@ func TestHandleUDP_Enabled_NilRouter(t *testing.T) { go func() { // OK response sent, then nil router causes error - err := h.handleUDP(context.Background(), server, ro, &testLogger{}) + err := h.handleUDP(context.Background(), server, "", ro, &testLogger{}) if err == nil { t.Log("expected error with nil router for UDP") } diff --git a/handler/http/util.go b/handler/http/util.go index d7ce31e2..5d77c8da 100644 --- a/handler/http/util.go +++ b/handler/http/util.go @@ -9,6 +9,7 @@ import ( "net/http" "strings" + "github.com/asaskevich/govalidator" "golang.org/x/net/http/httpguts" ) @@ -84,3 +85,50 @@ func buildConnectResponse(proxyAgent string) []byte { return []byte("HTTP/1.1 200 Connection established\r\n" + "Proxy-Agent: " + proxyAgent + "\r\n\r\n") } + +// NormalizedRequest holds the parsed target address and network from an +// HTTP proxy request after URL inference, GOST v2 compatibility decoding, +// and host normalisation. +type NormalizedRequest struct { + Network string // "tcp" or "udp" + Addr string // host:port (default port appended when absent) +} + +// normalizeRequest extracts the target address and network from an HTTP +// request. It infers the URL scheme when absent, decodes GOST v2 +// compatibility headers (Gost-Target, X-Gost-Target), detects the +// transport protocol from X-Gost-Protocol, and normalises the host to +// include a port. +// +// The function mutates req.URL.Scheme and req.Host as side effects so +// that the caller's request reflects the inferred target. +func normalizeRequest(req *http.Request) *NormalizedRequest { + if !req.URL.IsAbs() { + host := req.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + if govalidator.IsDNSName(host) || net.ParseIP(host) != nil { + req.URL.Scheme = "http" + } + } + + network := req.Header.Get("X-Gost-Protocol") + if network != "udp" { + network = "tcp" + } + + if v := req.Header.Get("Gost-Target"); v != "" { + if h, err := decodeServerName(v); err == nil { + req.Host = h + } + } + if v := req.Header.Get("X-Gost-Target"); v != "" { + if h, err := decodeServerName(v); err == nil { + req.Host = h + } + } + + addr := normalizeHostPort(req.Host, "80") + return &NormalizedRequest{Network: network, Addr: addr} +} diff --git a/handler/http/util_test.go b/handler/http/util_test.go index ee751c4c..b1ef4a57 100644 --- a/handler/http/util_test.go +++ b/handler/http/util_test.go @@ -339,3 +339,142 @@ func contains(s, substr string) bool { } return false } + +func TestNormalizeRequest_AbsoluteURL(t *testing.T) { + req, _ := http.NewRequest("GET", "http://example.com:8080/path", nil) + nr := normalizeRequest(req) + if nr.Network != "tcp" { + t.Errorf("got network %q, want tcp", nr.Network) + } + if nr.Addr != "example.com:8080" { + t.Errorf("got addr %q, want example.com:8080", nr.Addr) + } +} + +func TestNormalizeRequest_AbsoluteURL_DefaultPort(t *testing.T) { + req, _ := http.NewRequest("GET", "http://example.com/path", nil) + nr := normalizeRequest(req) + if nr.Addr != "example.com:80" { + t.Errorf("got addr %q, want example.com:80", nr.Addr) + } +} + +func TestNormalizeRequest_NonAbsolute_ValidHost(t *testing.T) { + req, _ := http.NewRequest("GET", "/path", nil) + req.Host = "example.com" + nr := normalizeRequest(req) + if req.URL.Scheme != "http" { + t.Errorf("got scheme %q, want http", req.URL.Scheme) + } + if nr.Addr != "example.com:80" { + t.Errorf("got addr %q, want example.com:80", nr.Addr) + } +} + +func TestNormalizeRequest_NonAbsolute_ValidIP(t *testing.T) { + req, _ := http.NewRequest("GET", "/path", nil) + req.Host = "1.2.3.4" + nr := normalizeRequest(req) + if req.URL.Scheme != "http" { + t.Errorf("got scheme %q, want http", req.URL.Scheme) + } + if nr.Addr != "1.2.3.4:80" { + t.Errorf("got addr %q, want 1.2.3.4:80", nr.Addr) + } +} + +func TestNormalizeRequest_NonAbsolute_InvalidHost(t *testing.T) { + req, _ := http.NewRequest("GET", "/path", nil) + req.Host = "!!!invalid!!!" + nr := normalizeRequest(req) + if req.URL.Scheme != "" { + t.Errorf("scheme should remain empty for invalid host, got %q", req.URL.Scheme) + } + if nr.Addr != "!!!invalid!!!:80" { + t.Errorf("got addr %q, want !!!invalid!!!:80", nr.Addr) + } +} + +func TestNormalizeRequest_UDPProtocol(t *testing.T) { + req, _ := http.NewRequest("GET", "http://example.com:8080/path", nil) + req.Header.Set("X-Gost-Protocol", "udp") + nr := normalizeRequest(req) + if nr.Network != "udp" { + t.Errorf("got network %q, want udp", nr.Network) + } +} + +func TestNormalizeRequest_GostTarget(t *testing.T) { + // 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)...)) + } + + req, _ := http.NewRequest("GET", "http://example.com/path", nil) + req.Header.Set("Gost-Target", encodeName("real-target.com:443")) + nr := normalizeRequest(req) + if nr.Addr != "real-target.com:443" { + t.Errorf("got addr %q, want real-target.com:443", nr.Addr) + } +} + +func TestNormalizeRequest_XGostTarget(t *testing.T) { + 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)...)) + } + + req, _ := http.NewRequest("GET", "http://example.com/path", nil) + req.Header.Set("X-Gost-Target", encodeName("alternate-target.com:443")) + nr := normalizeRequest(req) + if nr.Addr != "alternate-target.com:443" { + t.Errorf("got addr %q, want alternate-target.com:443", nr.Addr) + } +} + +func TestNormalizeRequest_IPv6Bracket(t *testing.T) { + req, _ := http.NewRequest("GET", "http://[::1]:8080/path", nil) + nr := normalizeRequest(req) + if nr.Addr != "[::1]:8080" { + t.Errorf("got addr %q, want [::1]:8080", nr.Addr) + } +} + +func TestNormalizeRequest_NonAbsolute_IPv6(t *testing.T) { + req, _ := http.NewRequest("GET", "/path", nil) + req.Host = "[::1]:9090" + nr := normalizeRequest(req) + if nr.Addr != "[::1]:9090" { + t.Errorf("got addr %q, want [::1]:9090", nr.Addr) + } +} + +func TestNormalizeRequest_HostWithPortInURL(t *testing.T) { + req, _ := http.NewRequest("GET", "http://example.com:8080/path", nil) + req.Host = "example.com:9090" + // URL is absolute, so host is taken from req.URL via req.Host + // normalizeHostPort uses req.Host which is set from req.URL + nr := normalizeRequest(req) + // The Host comes from req.Host, which for absolute URLs is set from req.URL.Host + if nr.Addr != "example.com:9090" { + t.Errorf("got addr %q, want example.com:9090", nr.Addr) + } +} + +func TestNormalizeRequest_NoPort(t *testing.T) { + req, _ := http.NewRequest("GET", "http://example.com/path", nil) + nr := normalizeRequest(req) + if nr.Addr != "example.com:80" { + t.Errorf("got addr %q, want example.com:80", nr.Addr) + } + if nr.Network != "tcp" { + t.Errorf("got network %q, want tcp", nr.Network) + } +} diff --git a/handler/http/websocket.go b/handler/http/websocket.go index e0979748..0bba5d84 100644 --- a/handler/http/websocket.go +++ b/handler/http/websocket.go @@ -101,15 +101,7 @@ func (h *httpHandler) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Bu 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 - } - + if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 { buf.Reset() if _, err := io.Copy(buf, io.LimitReader(fr.Data, int64(bodySize))); err != nil { return err diff --git a/internal/util/sniffing/sniffer.go b/internal/util/sniffing/sniffer.go index a0f79ede..2e44be11 100644 --- a/internal/util/sniffing/sniffer.go +++ b/internal/util/sniffing/sniffer.go @@ -106,10 +106,10 @@ type Sniffer struct { ReadTimeout time.Duration } -// clampBodySize returns the effective body capture size from recorder options, +// ClampBodySize returns the effective body capture size from recorder options, // bounded by [DefaultBodySize, MaxBodySize]. Returns 0 if body recording is // disabled. -func clampBodySize(opts *recorder.Options) int { +func ClampBodySize(opts *recorder.Options) int { if opts == nil || !opts.HTTPBody { return 0 } diff --git a/internal/util/sniffing/sniffer_h2.go b/internal/util/sniffing/sniffer_h2.go index 15efb423..e3bf08ba 100644 --- a/internal/util/sniffing/sniffer_h2.go +++ b/internal/util/sniffing/sniffer_h2.go @@ -139,7 +139,7 @@ func (h *h2Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } var reqBody *xhttp.Body - if bodySize := clampBodySize(h.recorderOptions); bodySize > 0 && req.Body != nil { + if bodySize := ClampBodySize(h.recorderOptions); bodySize > 0 && req.Body != nil { reqBody = xhttp.NewBody(req.Body, bodySize) req.Body = reqBody } @@ -169,7 +169,7 @@ func (h *h2Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.setHeader(w, resp.Header) w.WriteHeader(resp.StatusCode) - if bodySize := clampBodySize(h.recorderOptions); bodySize > 0 { + if bodySize := ClampBodySize(h.recorderOptions); bodySize > 0 { respBody := xhttp.NewBody(resp.Body, bodySize) resp.Body = respBody io.Copy(w, resp.Body) diff --git a/internal/util/sniffing/sniffer_http.go b/internal/util/sniffing/sniffer_http.go index afcde444..d07ceaae 100644 --- a/internal/util/sniffing/sniffer_http.go +++ b/internal/util/sniffing/sniffer_http.go @@ -179,7 +179,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser, } var reqBody *xhttp.Body - if bodySize := clampBodySize(h.RecorderOptions); bodySize > 0 && req.Body != nil { + if bodySize := ClampBodySize(h.RecorderOptions); bodySize > 0 && req.Body != nil { reqBody = xhttp.NewBody(req.Body, bodySize) req.Body = reqBody } @@ -239,7 +239,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser, } var respBody *xhttp.Body - if bodySize := clampBodySize(h.RecorderOptions); bodySize > 0 { + if bodySize := ClampBodySize(h.RecorderOptions); bodySize > 0 { respBody = xhttp.NewBody(resp.Body, bodySize) resp.Body = respBody } diff --git a/internal/util/sniffing/sniffer_test.go b/internal/util/sniffing/sniffer_test.go index f7c51a5e..858959c2 100644 --- a/internal/util/sniffing/sniffer_test.go +++ b/internal/util/sniffing/sniffer_test.go @@ -62,8 +62,8 @@ func TestClampBodySize(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := clampBodySize(tt.opts); got != tt.want { - t.Errorf("clampBodySize() = %d, want %d", got, tt.want) + if got := ClampBodySize(tt.opts); got != tt.want { + t.Errorf("ClampBodySize() = %d, want %d", got, tt.want) } }) } diff --git a/internal/util/sniffing/sniffer_ws.go b/internal/util/sniffing/sniffer_ws.go index e043631e..d657f212 100644 --- a/internal/util/sniffing/sniffer_ws.go +++ b/internal/util/sniffing/sniffer_ws.go @@ -103,7 +103,7 @@ func (h *Sniffer) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Buffer Length: fr.Header.PayloadLength, } - if bodySize := clampBodySize(h.RecorderOptions); bodySize > 0 { + if bodySize := ClampBodySize(h.RecorderOptions); bodySize > 0 { buf.Reset() if _, err := io.Copy(buf, io.LimitReader(fr.Data, int64(bodySize))); err != nil { return err