refactor(handler/http): extract Authenticator, SnifferBuilder, and normalizeRequest as testable units
Extract three pure-logic components from httpHandler to eliminate I/O
coupling from auth and sniffing construction, enabling synchronous unit
tests and reducing per-request allocation in sniffAndHandle:
- Authenticator struct with AuthResult return type — auth decisions no
longer write to the connection; callers handle response I/O. Auth
tests drop net.Pipe/goroutines for direct return-value assertions.
- SnifferBuilder pre-built in Init and reused per-connection via Build(),
replacing inline sniffing.Sniffer{} construction in sniffAndHandle.
- normalizeRequest extracted to util.go with NormalizedRequest type,
collapsing 20 lines of inline URL/normalisation into a single call.
- knockMatch extracted as standalone pure function.
- clampBodySize exported as ClampBodySize for cross-package use.
- util_test.go with 22 tests covering utility functions.
- helpers_test.go consolidates shared test fakes (logger, observer, conn).
This commit is contained in:
+135
-103
@@ -16,129 +16,161 @@ import (
|
|||||||
xnet "github.com/go-gost/x/internal/net"
|
xnet "github.com/go-gost/x/internal/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
// authenticate verifies the client's Proxy-Authorization header against the
|
// AuthResult is returned by Authenticator.Authenticate. When OK is false,
|
||||||
// configured Authenticator. When authentication fails and probe resistance
|
// Response is the HTTP response to write to the client (407 or a probe-
|
||||||
// is configured, a decoy response is returned to hide the proxy.
|
// 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:
|
// The "host" probe resistance strategy is signalled via PipeTo rather than
|
||||||
//
|
// handled inline — the caller must dial the PipeTo address and pipe the
|
||||||
// - "code": respond with a custom HTTP status code (e.g. "code:404").
|
// raw request through it. All other strategies produce a Response.
|
||||||
// - "web": fetch a URL and replay its response as the decoy.
|
func (a *Authenticator) Authenticate(ctx context.Context, req *http.Request) *AuthResult {
|
||||||
// - "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) {
|
|
||||||
u, p, _ := basicProxyAuth(req.Header.Get("Proxy-Authorization"))
|
u, p, _ := basicProxyAuth(req.Header.Get("Proxy-Authorization"))
|
||||||
if h.options.Auther == nil {
|
if a.Auther == nil {
|
||||||
return "", true
|
return &AuthResult{OK: true}
|
||||||
}
|
}
|
||||||
if id, ok = h.options.Auther.Authenticate(ctx, u, p, auth.WithService(h.options.Service)); ok {
|
if id, ok := a.Auther.Authenticate(ctx, u, p, auth.WithService(a.optionsService())); ok {
|
||||||
return
|
return &AuthResult{OK: true, ClientID: id}
|
||||||
}
|
}
|
||||||
|
|
||||||
pr := h.md.probeResistance
|
pr := a.PR
|
||||||
// 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 == "" || !knockMatch(req.URL.Hostname(), pr.Knock)) {
|
if pr != nil && (pr.Knock == "" || !knockMatch(req.URL.Hostname(), pr.Knock)) {
|
||||||
resp.StatusCode = http.StatusServiceUnavailable
|
return a.probeResistanceResponse(req)
|
||||||
|
}
|
||||||
|
|
||||||
switch pr.Type {
|
return a.build407Response(req)
|
||||||
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)
|
func (a *Authenticator) optionsService() string {
|
||||||
xnet.Pipe(ctx, conn, cc, xnet.WithReadTimeout(h.md.idleTimeout))
|
return a.Service
|
||||||
return "", false
|
}
|
||||||
case "file":
|
|
||||||
f, _ := os.Open(pr.Value)
|
|
||||||
if f != nil {
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
resp.StatusCode = http.StatusOK
|
func (a *Authenticator) probeResistanceResponse(req *http.Request) *AuthResult {
|
||||||
if finfo, _ := f.Stat(); finfo != nil {
|
pr := a.PR
|
||||||
resp.ContentLength = finfo.Size()
|
resp := &http.Response{
|
||||||
}
|
ProtoMajor: 1,
|
||||||
resp.Header.Set("Content-Type", "text/html")
|
ProtoMinor: 1,
|
||||||
resp.Body = f
|
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 {
|
if resp.StatusCode == 0 {
|
||||||
// Normal 407 Proxy-Auth-Required response.
|
return a.build407Response(req)
|
||||||
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) {
|
if resp.StatusCode == http.StatusOK {
|
||||||
dump, _ := httputil.DumpResponse(resp, false)
|
resp.Header.Set("Connection", "keep-alive")
|
||||||
log.Trace(string(dump))
|
|
||||||
}
|
}
|
||||||
|
return &AuthResult{Response: resp}
|
||||||
|
}
|
||||||
|
|
||||||
if resp.Body != nil {
|
func (a *Authenticator) build407Response(req *http.Request) *AuthResult {
|
||||||
defer resp.Body.Close()
|
realm := defaultRealm
|
||||||
|
if a.Realm != "" {
|
||||||
|
realm = a.Realm
|
||||||
}
|
}
|
||||||
if err := resp.Write(conn); err != nil {
|
resp := &http.Response{
|
||||||
log.Error("write auth response: ", err)
|
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
|
// checkRateLimit verifies that the remote address has not exceeded the
|
||||||
|
|||||||
+127
-305
@@ -1,19 +1,16 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-gost/core/auth"
|
"github.com/go-gost/core/auth"
|
||||||
"github.com/go-gost/core/handler"
|
"github.com/go-gost/core/handler"
|
||||||
"github.com/go-gost/core/limiter"
|
"github.com/go-gost/core/limiter"
|
||||||
"github.com/go-gost/core/limiter/rate"
|
"github.com/go-gost/core/limiter/rate"
|
||||||
"github.com/go-gost/core/limiter/traffic"
|
"github.com/go-gost/core/limiter/traffic"
|
||||||
"github.com/go-gost/core/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// stubAuther implements auth.Authenticator for testing.
|
// 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
|
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 }
|
// --- Authenticator tests (synchronous — no net.Pipe or goroutines) ---
|
||||||
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) {
|
func TestAuthenticate_NoAuther(t *testing.T) {
|
||||||
h := &httpHandler{
|
a := &Authenticator{}
|
||||||
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)
|
req, _ := http.NewRequest("GET", "http://example.com/", nil)
|
||||||
resp := &http.Response{
|
result := a.Authenticate(context.Background(), req)
|
||||||
ProtoMajor: 1,
|
if !result.OK {
|
||||||
ProtoMinor: 1,
|
t.Fatal("expected OK=true when no Auther")
|
||||||
Header: http.Header{},
|
}
|
||||||
ContentLength: -1,
|
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) {
|
func TestAuthenticate_Success(t *testing.T) {
|
||||||
h := &httpHandler{
|
a := &Authenticator{
|
||||||
options: handler.Options{
|
Auther: &stubAuther{accept: true, id: "testuser"},
|
||||||
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, _ := http.NewRequest("GET", "http://example.com/", nil)
|
||||||
req.Header.Set("Proxy-Authorization", "Basic dGVzdHVzZXI6cGFzc3dvcmQ=") // testuser:password
|
req.Header.Set("Proxy-Authorization", "Basic dGVzdHVzZXI6cGFzc3dvcmQ=")
|
||||||
resp := &http.Response{
|
result := a.Authenticate(context.Background(), req)
|
||||||
ProtoMajor: 1,
|
if !result.OK {
|
||||||
ProtoMinor: 1,
|
t.Fatal("expected auth success")
|
||||||
Header: http.Header{},
|
}
|
||||||
ContentLength: -1,
|
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) {
|
func TestAuthenticate_Failure(t *testing.T) {
|
||||||
h := &httpHandler{
|
a := &Authenticator{
|
||||||
options: handler.Options{
|
Auther: &stubAuther{accept: false},
|
||||||
Auther: &stubAuther{accept: false},
|
Realm: "gost",
|
||||||
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, _ := http.NewRequest("GET", "http://example.com/", nil)
|
||||||
req.Header.Set("Proxy-Authorization", "Basic dGVzdHVzZXI6cGFzc3dvcmQ=")
|
req.Header.Set("Proxy-Authorization", "Basic dGVzdHVzZXI6cGFzc3dvcmQ=")
|
||||||
resp := &http.Response{
|
result := a.Authenticate(context.Background(), req)
|
||||||
ProtoMajor: 1,
|
if result.OK {
|
||||||
ProtoMinor: 1,
|
t.Fatal("expected auth failure")
|
||||||
Header: http.Header{},
|
|
||||||
ContentLength: -1,
|
|
||||||
}
|
}
|
||||||
|
if result.Response == nil {
|
||||||
go func() {
|
t.Fatal("expected non-nil response on failure")
|
||||||
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 result.Response.StatusCode != http.StatusProxyAuthRequired {
|
||||||
|
t.Errorf("got status %d, want %d", result.Response.StatusCode, http.StatusProxyAuthRequired)
|
||||||
if r.StatusCode != http.StatusProxyAuthRequired {
|
|
||||||
t.Errorf("got status %d, want %d", r.StatusCode, http.StatusProxyAuthRequired)
|
|
||||||
}
|
}
|
||||||
if r.Header.Get("Proxy-Authenticate") == "" {
|
if result.Response.Header.Get("Proxy-Authenticate") == "" {
|
||||||
t.Error("expected Proxy-Authenticate header")
|
t.Error("expected Proxy-Authenticate header")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthenticate_ProbeResistanceCode(t *testing.T) {
|
func TestAuthenticate_ProbeResistanceCode(t *testing.T) {
|
||||||
h := &httpHandler{
|
a := &Authenticator{
|
||||||
options: handler.Options{
|
Auther: &stubAuther{accept: false},
|
||||||
Auther: &stubAuther{accept: false},
|
PR: &probeResistance{Type: "code", Value: "404"},
|
||||||
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)
|
req, _ := http.NewRequest("GET", "http://example.com/", nil)
|
||||||
resp := &http.Response{
|
result := a.Authenticate(context.Background(), req)
|
||||||
ProtoMajor: 1,
|
if result.OK {
|
||||||
ProtoMinor: 1,
|
t.Fatal("expected auth failure")
|
||||||
Header: http.Header{},
|
|
||||||
ContentLength: -1,
|
|
||||||
}
|
}
|
||||||
|
if result.Response.StatusCode != 404 {
|
||||||
go func() {
|
t.Errorf("got status %d, want 404", result.Response.StatusCode)
|
||||||
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) {
|
func TestAuthenticate_ProbeResistanceKnock(t *testing.T) {
|
||||||
h := &httpHandler{
|
a := &Authenticator{
|
||||||
options: handler.Options{
|
Auther: &stubAuther{accept: false},
|
||||||
Auther: &stubAuther{accept: false},
|
Realm: "gost",
|
||||||
Logger: &testLogger{},
|
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)
|
req, _ := http.NewRequest("GET", "http://secret.example.com/", nil)
|
||||||
resp := &http.Response{
|
result := a.Authenticate(context.Background(), req)
|
||||||
ProtoMajor: 1,
|
if result.OK {
|
||||||
ProtoMinor: 1,
|
t.Fatal("expected auth failure")
|
||||||
Header: http.Header{},
|
|
||||||
ContentLength: -1,
|
|
||||||
}
|
}
|
||||||
|
// Knock matches, probe resistance bypassed → 407
|
||||||
go func() {
|
if result.Response.StatusCode != http.StatusProxyAuthRequired {
|
||||||
h.authenticate(context.Background(), server, req, resp, &testLogger{})
|
t.Errorf("got status %d, want %d (knock matched, bypass probe resistance)",
|
||||||
}()
|
result.Response.StatusCode, http.StatusProxyAuthRequired)
|
||||||
|
|
||||||
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) {
|
func TestAuthenticate_ProbeResistanceKnockMismatch(t *testing.T) {
|
||||||
h := &httpHandler{
|
a := &Authenticator{
|
||||||
options: handler.Options{
|
Auther: &stubAuther{accept: false},
|
||||||
Auther: &stubAuther{accept: false},
|
PR: &probeResistance{Type: "code", Value: "503", Knock: "secret.example.com"},
|
||||||
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)
|
req, _ := http.NewRequest("GET", "http://other.example.com/", nil)
|
||||||
resp := &http.Response{
|
result := a.Authenticate(context.Background(), req)
|
||||||
ProtoMajor: 1,
|
if result.OK {
|
||||||
ProtoMinor: 1,
|
t.Fatal("expected auth failure")
|
||||||
Header: http.Header{},
|
|
||||||
ContentLength: -1,
|
|
||||||
}
|
}
|
||||||
|
// Knock doesn't match, probe resistance activates → 503
|
||||||
go func() {
|
if result.Response.StatusCode != 503 {
|
||||||
h.authenticate(context.Background(), server, req, resp, &testLogger{})
|
t.Errorf("got status %d, want 503", result.Response.StatusCode)
|
||||||
}()
|
|
||||||
|
|
||||||
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 TestAuthenticate_ProbeResistanceKnockMultiMatch(t *testing.T) {
|
func TestAuthenticate_ProbeResistanceKnockMultiMatch(t *testing.T) {
|
||||||
h := &httpHandler{
|
a := &Authenticator{
|
||||||
options: handler.Options{
|
Auther: &stubAuther{accept: false},
|
||||||
Auther: &stubAuther{accept: false},
|
Realm: "gost",
|
||||||
Logger: &testLogger{},
|
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)
|
req, _ := http.NewRequest("GET", "http://secret.example.com/", nil)
|
||||||
resp := &http.Response{
|
result := a.Authenticate(context.Background(), req)
|
||||||
ProtoMajor: 1,
|
if result.OK {
|
||||||
ProtoMinor: 1,
|
t.Fatal("expected auth failure")
|
||||||
Header: http.Header{},
|
|
||||||
ContentLength: -1,
|
|
||||||
}
|
}
|
||||||
|
if result.Response.StatusCode != http.StatusProxyAuthRequired {
|
||||||
go func() {
|
t.Errorf("got status %d, want %d (knock matched, bypass probe resistance)",
|
||||||
h.authenticate(context.Background(), server, req, resp, &testLogger{})
|
result.Response.StatusCode, http.StatusProxyAuthRequired)
|
||||||
}()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthenticate_ProbeResistanceKnockMultiMismatch(t *testing.T) {
|
func TestAuthenticate_ProbeResistanceKnockMultiMismatch(t *testing.T) {
|
||||||
h := &httpHandler{
|
a := &Authenticator{
|
||||||
options: handler.Options{
|
Auther: &stubAuther{accept: false},
|
||||||
Auther: &stubAuther{accept: false},
|
PR: &probeResistance{
|
||||||
Logger: &testLogger{},
|
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)
|
req, _ := http.NewRequest("GET", "http://other.example.com/", nil)
|
||||||
resp := &http.Response{
|
result := a.Authenticate(context.Background(), req)
|
||||||
ProtoMajor: 1,
|
if result.OK {
|
||||||
ProtoMinor: 1,
|
t.Fatal("expected auth failure")
|
||||||
Header: http.Header{},
|
|
||||||
ContentLength: -1,
|
|
||||||
}
|
}
|
||||||
|
if result.Response.StatusCode != 503 {
|
||||||
go func() {
|
t.Errorf("got status %d, want 503", result.Response.StatusCode)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestKnockMatch(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
hostname string
|
hostname string
|
||||||
@@ -389,11 +226,12 @@ func TestKnockMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- checkRateLimit tests ---
|
||||||
|
|
||||||
func TestCheckRateLimit_NoLimiter(t *testing.T) {
|
func TestCheckRateLimit_NoLimiter(t *testing.T) {
|
||||||
h := &httpHandler{
|
h := &httpHandler{
|
||||||
options: handler.Options{},
|
options: handler.Options{},
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 12345}
|
addr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 12345}
|
||||||
if !h.checkRateLimit(addr) {
|
if !h.checkRateLimit(addr) {
|
||||||
t.Error("checkRateLimit should return true when no limiter is set")
|
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) {
|
func TestCheckRateLimit_WithLimiter(t *testing.T) {
|
||||||
// Create a handler with a rate limiter that blocks everything
|
|
||||||
h := &httpHandler{
|
h := &httpHandler{
|
||||||
options: handler.Options{
|
options: handler.Options{
|
||||||
RateLimiter: &stubRateLimiter{allow: false, limiter: &stubLimiter{allow: false}},
|
RateLimiter: &stubRateLimiter{allow: false, limiter: &stubLimiter{allow: false}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 12345}
|
addr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 12345}
|
||||||
if h.checkRateLimit(addr) {
|
if h.checkRateLimit(addr) {
|
||||||
t.Error("checkRateLimit should return false when limiter denies")
|
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}},
|
RateLimiter: &stubRateLimiter{allow: true, limiter: &stubLimiter{allow: true}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 12345}
|
addr := &net.TCPAddr{IP: net.ParseIP("1.2.3.4"), Port: 12345}
|
||||||
if !h.checkRateLimit(addr) {
|
if !h.checkRateLimit(addr) {
|
||||||
t.Error("checkRateLimit should return true when limiter allows")
|
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}},
|
RateLimiter: &stubRateLimiter{allow: true, limiter: &stubLimiter{allow: true}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nil addr should not panic - robustness check
|
|
||||||
func() {
|
func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
t.Errorf("checkRateLimit panicked with nil addr: %v", r)
|
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
|
_ = h.checkRateLimit(nil)
|
||||||
// But we should guard against it
|
|
||||||
_ = h.checkRateLimit(nil) //nolint
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// testRecorderObject is a recorder.RecorderObject for testing.
|
// --- Stub implementations (used by multiple test files) ---
|
||||||
var testRecorderObject = struct {
|
|
||||||
Record string
|
|
||||||
Options *testRecorderOptions
|
|
||||||
}{Record: "serviceHandler"}
|
|
||||||
|
|
||||||
type testRecorderOptions struct {
|
|
||||||
HTTPBody bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// stubRateLimiter implements rate.RateLimiter for testing.
|
// stubRateLimiter implements rate.RateLimiter for testing.
|
||||||
type stubRateLimiter struct {
|
type stubRateLimiter struct {
|
||||||
allow bool
|
allow bool
|
||||||
limiter *stubLimiter
|
limiter *stubLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *stubRateLimiter) Limiter(key string) rate.Limiter {
|
func (l *stubRateLimiter) Limiter(key string) rate.Limiter {
|
||||||
@@ -475,9 +298,9 @@ type stubLimiter struct {
|
|||||||
allow bool
|
allow bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *stubLimiter) Allow(n int) bool { return l.allow }
|
func (l *stubLimiter) Allow(n int) bool { return l.allow }
|
||||||
func (l *stubLimiter) AllowN(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) Limit() float64 { return 0 }
|
||||||
|
|
||||||
// testTrafficLimiter implements traffic.TrafficLimiter for testing.
|
// testTrafficLimiter implements traffic.TrafficLimiter for testing.
|
||||||
type testTrafficLimiter struct{}
|
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) Wait(ctx context.Context, n int) int { return n }
|
||||||
func (l *testTLimiter) Limit() int { return 0 }
|
func (l *testTLimiter) Limit() int { return 0 }
|
||||||
func (l *testTLimiter) Set(n int) {}
|
func (l *testTLimiter) Set(n int) {}
|
||||||
|
|
||||||
|
|||||||
+37
-12
@@ -4,20 +4,25 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gost/core/bypass"
|
||||||
"github.com/go-gost/core/limiter"
|
"github.com/go-gost/core/limiter"
|
||||||
|
"github.com/go-gost/core/recorder"
|
||||||
stats "github.com/go-gost/core/observer/stats"
|
stats "github.com/go-gost/core/observer/stats"
|
||||||
"github.com/go-gost/core/logger"
|
"github.com/go-gost/core/logger"
|
||||||
xctx "github.com/go-gost/x/ctx"
|
xctx "github.com/go-gost/x/ctx"
|
||||||
ictx "github.com/go-gost/x/internal/ctx"
|
ictx "github.com/go-gost/x/internal/ctx"
|
||||||
xnet "github.com/go-gost/x/internal/net"
|
xnet "github.com/go-gost/x/internal/net"
|
||||||
"github.com/go-gost/x/internal/util/sniffing"
|
"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"
|
traffic_wrapper "github.com/go-gost/x/limiter/traffic/wrapper"
|
||||||
stats_wrapper "github.com/go-gost/x/observer/stats/wrapper"
|
stats_wrapper "github.com/go-gost/x/observer/stats/wrapper"
|
||||||
xrecorder "github.com/go-gost/x/recorder"
|
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) {
|
dialTLS := func(ctx context.Context, network, address string, cfg *tls.Config) (net.Conn, error) {
|
||||||
return cc, nil
|
return cc, nil
|
||||||
}
|
}
|
||||||
sniffer := &sniffing.Sniffer{
|
sniffer := h.sniffer.Build()
|
||||||
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)
|
conn = xnet.NewReadWriteConn(br, conn, conn)
|
||||||
switch proto {
|
switch proto {
|
||||||
@@ -223,3 +217,34 @@ func (h *httpHandler) setupTrafficLimiter(conn net.Conn, clientID, network, addr
|
|||||||
|
|
||||||
return xnet.NewReadWriteConn(rw, rw, conn), cleanup
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func TestSniffAndHandle_NoMatch(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
h.md.sniffing = true
|
h.md.sniffing = true
|
||||||
|
h.sniffer = &SnifferBuilder{}
|
||||||
|
|
||||||
client, server := net.Pipe()
|
client, server := net.Pipe()
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
@@ -59,6 +60,7 @@ func TestSniffAndHandle_TLS(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
h.md.sniffing = true
|
h.md.sniffing = true
|
||||||
|
h.sniffer = &SnifferBuilder{}
|
||||||
|
|
||||||
client, server := net.Pipe()
|
client, server := net.Pipe()
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
@@ -115,6 +117,7 @@ func TestHandleConnect_SniffingEnabled(t *testing.T) {
|
|||||||
h.md.proxyAgent = defaultProxyAgent
|
h.md.proxyAgent = defaultProxyAgent
|
||||||
h.md.readTimeout = 15
|
h.md.readTimeout = 15
|
||||||
h.md.sniffing = true
|
h.md.sniffing = true
|
||||||
|
h.sniffer = &SnifferBuilder{}
|
||||||
|
|
||||||
client, server := net.Pipe()
|
client, server := net.Pipe()
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|||||||
+64
-51
@@ -196,7 +196,6 @@ import (
|
|||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/asaskevich/govalidator"
|
|
||||||
"github.com/go-gost/core/bypass"
|
"github.com/go-gost/core/bypass"
|
||||||
"github.com/go-gost/core/handler"
|
"github.com/go-gost/core/handler"
|
||||||
"github.com/go-gost/core/limiter"
|
"github.com/go-gost/core/limiter"
|
||||||
@@ -230,6 +229,8 @@ func init() {
|
|||||||
type httpHandler struct {
|
type httpHandler struct {
|
||||||
md metadata // parsed configuration
|
md metadata // parsed configuration
|
||||||
options handler.Options // handler options from the service config
|
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
|
stats *stats_util.HandlerStats // per-client stats, created when Observer is set
|
||||||
limiter traffic.TrafficLimiter // per-client traffic shaper (cached)
|
limiter traffic.TrafficLimiter // per-client traffic shaper (cached)
|
||||||
cancel context.CancelFunc // cancels the observeStats goroutine
|
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 {
|
if h.md.certificate != nil && h.md.privateKey != nil {
|
||||||
h.certPool = tls_util.NewMemoryCertPool()
|
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 forward proxy (GET/POST/…): handleProxy
|
||||||
// - HTTP CONNECT tunnel: handleConnect
|
// - HTTP CONNECT tunnel: handleConnect
|
||||||
func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *http.Request, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
|
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),
|
nr := normalizeRequest(req)
|
||||||
// attempt to infer the scheme by validating the Host header. A valid
|
network := nr.Network
|
||||||
// DNS name or IP address implies http://.
|
addr := nr.Addr
|
||||||
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"
|
|
||||||
}
|
|
||||||
ro.Network = network
|
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
|
ro.Host = addr
|
||||||
|
|
||||||
fields := map[string]any{
|
fields := map[string]any{
|
||||||
@@ -446,17 +439,7 @@ func (h *httpHandler) handleRequest(ctx context.Context, conn net.Conn, req *htt
|
|||||||
resp.Header = http.Header{}
|
resp.Header = http.Header{}
|
||||||
}
|
}
|
||||||
|
|
||||||
ro.HTTP = &xrecorder.HTTPRecorderObject{
|
ro.HTTP = buildHTTPRecorder(req)
|
||||||
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(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
defer func() {
|
defer func() {
|
||||||
ro.HTTP.StatusCode = resp.StatusCode
|
ro.HTTP.StatusCode = resp.StatusCode
|
||||||
ro.HTTP.Response.Header = resp.Header
|
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)
|
return resp.Write(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
clientID, ok := h.authenticate(ctx, conn, req, resp, log)
|
result := h.auth.Authenticate(ctx, req)
|
||||||
if !ok {
|
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")
|
return errors.New("authentication failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
log = log.WithFields(map[string]any{"clientID": clientID})
|
log = log.WithFields(map[string]any{"clientID": result.ClientID})
|
||||||
ro.ClientID = clientID
|
ro.ClientID = result.ClientID
|
||||||
|
|
||||||
if resp.Header.Get("Proxy-Agent") == "" {
|
if resp.Header.Get("Proxy-Agent") == "" {
|
||||||
resp.Header.Set("Proxy-Agent", h.md.proxyAgent)
|
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 &&
|
if h.options.Bypass != nil &&
|
||||||
h.options.Bypass.Contains(ctx, network, addr, bypass.WithService(h.options.Service)) {
|
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" {
|
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 {
|
if done != nil {
|
||||||
defer done()
|
defer done()
|
||||||
}
|
}
|
||||||
@@ -530,6 +526,23 @@ func (h *httpHandler) Close() error {
|
|||||||
return nil
|
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
|
// observeStats periodically publishes per-client traffic stats to the
|
||||||
// configured Observer. It runs in a background goroutine started by Init.
|
// configured Observer. It runs in a background goroutine started by Init.
|
||||||
// Events that fail to send are retried on the next tick.
|
// Events that fail to send are retried on the next tick.
|
||||||
|
|||||||
@@ -8,14 +8,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-gost/core/handler"
|
"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) {
|
func TestNewHandler(t *testing.T) {
|
||||||
h := NewHandler()
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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() }
|
||||||
+4
-20
@@ -177,18 +177,9 @@ func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser,
|
|||||||
// Optionally intercept the request body for recording. The original
|
// Optionally intercept the request body for recording. The original
|
||||||
// body is replaced with a tee reader so the transport still sees it.
|
// body is replaced with a tee reader so the transport still sees it.
|
||||||
var reqBody *xhttp.Body
|
var reqBody *xhttp.Body
|
||||||
if opts := h.recorder.Options; opts != nil && opts.HTTPBody {
|
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && req.Body != nil {
|
||||||
if req.Body != nil {
|
reqBody = xhttp.NewBody(req.Body, bodySize)
|
||||||
bodySize := opts.MaxBodySize
|
req.Body = reqBody
|
||||||
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.ContextWithRecorderObject(ctx, ro)
|
||||||
@@ -235,14 +226,7 @@ func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser,
|
|||||||
|
|
||||||
// Optionally intercept the response body for recording.
|
// Optionally intercept the response body for recording.
|
||||||
var respBody *xhttp.Body
|
var respBody *xhttp.Body
|
||||||
if opts := h.recorder.Options; opts != nil && opts.HTTPBody {
|
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 {
|
||||||
bodySize := opts.MaxBodySize
|
|
||||||
if bodySize <= 0 {
|
|
||||||
bodySize = sniffing.DefaultBodySize
|
|
||||||
}
|
|
||||||
if bodySize > sniffing.MaxBodySize {
|
|
||||||
bodySize = sniffing.MaxBodySize
|
|
||||||
}
|
|
||||||
respBody = xhttp.NewBody(resp.Body, bodySize)
|
respBody = xhttp.NewBody(resp.Body, bodySize)
|
||||||
resp.Body = respBody
|
resp.Body = respBody
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-gost/core/logger"
|
"github.com/go-gost/core/logger"
|
||||||
|
stats "github.com/go-gost/core/observer/stats"
|
||||||
ictx "github.com/go-gost/x/internal/ctx"
|
ictx "github.com/go-gost/x/internal/ctx"
|
||||||
"github.com/go-gost/x/internal/net/udp"
|
"github.com/go-gost/x/internal/net/udp"
|
||||||
"github.com/go-gost/x/internal/util/socks"
|
"github.com/go-gost/x/internal/util/socks"
|
||||||
@@ -23,7 +24,7 @@ import (
|
|||||||
// through the UDP tunnel.
|
// through the UDP tunnel.
|
||||||
//
|
//
|
||||||
// If UDP relay is disabled (enableUDP=false), a 403 Forbidden is returned.
|
// 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{
|
log = log.WithFields(map[string]any{
|
||||||
"cmd": "udp",
|
"cmd": "udp",
|
||||||
})
|
})
|
||||||
@@ -60,6 +61,13 @@ func (h *httpHandler) handleUDP(ctx context.Context, conn net.Conn, ro *xrecorde
|
|||||||
return err
|
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
|
// Dial a UDP association through the proxy chain router. The empty
|
||||||
// address signals that the router should create a UDP socket rather
|
// address signals that the router should create a UDP socket rather
|
||||||
// than connect to a specific target.
|
// than connect to a specific target.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func TestHandleUDP_Disabled_WritesForbidden(t *testing.T) {
|
|||||||
ro := &xrecorder.HandlerRecorderObject{}
|
ro := &xrecorder.HandlerRecorderObject{}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
h.handleUDP(context.Background(), server, ro, &testLogger{})
|
h.handleUDP(context.Background(), server, "", ro, &testLogger{})
|
||||||
}()
|
}()
|
||||||
|
|
||||||
br := bufio.NewReader(client)
|
br := bufio.NewReader(client)
|
||||||
@@ -59,7 +59,7 @@ func TestHandleUDP_Enabled_NilRouter(t *testing.T) {
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// OK response sent, then nil router causes error
|
// 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 {
|
if err == nil {
|
||||||
t.Log("expected error with nil router for UDP")
|
t.Log("expected error with nil router for UDP")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/asaskevich/govalidator"
|
||||||
"golang.org/x/net/http/httpguts"
|
"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" +
|
return []byte("HTTP/1.1 200 Connection established\r\n" +
|
||||||
"Proxy-Agent: " + proxyAgent + "\r\n\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}
|
||||||
|
}
|
||||||
|
|||||||
@@ -339,3 +339,142 @@ func contains(s, substr string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,15 +101,7 @@ func (h *httpHandler) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Bu
|
|||||||
MaskKey: fr.Header.MaskKey,
|
MaskKey: fr.Header.MaskKey,
|
||||||
Length: fr.Header.PayloadLength,
|
Length: fr.Header.PayloadLength,
|
||||||
}
|
}
|
||||||
if opts := h.recorder.Options; opts != nil && opts.HTTPBody {
|
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 {
|
||||||
bodySize := opts.MaxBodySize
|
|
||||||
if bodySize <= 0 {
|
|
||||||
bodySize = sniffing.DefaultBodySize
|
|
||||||
}
|
|
||||||
if bodySize > sniffing.MaxBodySize {
|
|
||||||
bodySize = sniffing.MaxBodySize
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
if _, err := io.Copy(buf, io.LimitReader(fr.Data, int64(bodySize))); err != nil {
|
if _, err := io.Copy(buf, io.LimitReader(fr.Data, int64(bodySize))); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -106,10 +106,10 @@ type Sniffer struct {
|
|||||||
ReadTimeout time.Duration
|
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
|
// bounded by [DefaultBodySize, MaxBodySize]. Returns 0 if body recording is
|
||||||
// disabled.
|
// disabled.
|
||||||
func clampBodySize(opts *recorder.Options) int {
|
func ClampBodySize(opts *recorder.Options) int {
|
||||||
if opts == nil || !opts.HTTPBody {
|
if opts == nil || !opts.HTTPBody {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ func (h *h2Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var reqBody *xhttp.Body
|
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)
|
reqBody = xhttp.NewBody(req.Body, bodySize)
|
||||||
req.Body = reqBody
|
req.Body = reqBody
|
||||||
}
|
}
|
||||||
@@ -169,7 +169,7 @@ func (h *h2Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.setHeader(w, resp.Header)
|
h.setHeader(w, resp.Header)
|
||||||
w.WriteHeader(resp.StatusCode)
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
|
||||||
if bodySize := clampBodySize(h.recorderOptions); bodySize > 0 {
|
if bodySize := ClampBodySize(h.recorderOptions); bodySize > 0 {
|
||||||
respBody := xhttp.NewBody(resp.Body, bodySize)
|
respBody := xhttp.NewBody(resp.Body, bodySize)
|
||||||
resp.Body = respBody
|
resp.Body = respBody
|
||||||
io.Copy(w, resp.Body)
|
io.Copy(w, resp.Body)
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var reqBody *xhttp.Body
|
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)
|
reqBody = xhttp.NewBody(req.Body, bodySize)
|
||||||
req.Body = reqBody
|
req.Body = reqBody
|
||||||
}
|
}
|
||||||
@@ -239,7 +239,7 @@ func (h *Sniffer) httpRoundTrip(ctx context.Context, rw, cc io.ReadWriteCloser,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var respBody *xhttp.Body
|
var respBody *xhttp.Body
|
||||||
if bodySize := clampBodySize(h.RecorderOptions); bodySize > 0 {
|
if bodySize := ClampBodySize(h.RecorderOptions); bodySize > 0 {
|
||||||
respBody = xhttp.NewBody(resp.Body, bodySize)
|
respBody = xhttp.NewBody(resp.Body, bodySize)
|
||||||
resp.Body = respBody
|
resp.Body = respBody
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ func TestClampBodySize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if got := clampBodySize(tt.opts); got != tt.want {
|
if got := ClampBodySize(tt.opts); got != tt.want {
|
||||||
t.Errorf("clampBodySize() = %d, want %d", got, tt.want)
|
t.Errorf("ClampBodySize() = %d, want %d", got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ func (h *Sniffer) copyWebsocketFrame(w io.Writer, r io.Reader, buf *bytes.Buffer
|
|||||||
Length: fr.Header.PayloadLength,
|
Length: fr.Header.PayloadLength,
|
||||||
}
|
}
|
||||||
|
|
||||||
if bodySize := clampBodySize(h.RecorderOptions); bodySize > 0 {
|
if bodySize := ClampBodySize(h.RecorderOptions); bodySize > 0 {
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
if _, err := io.Copy(buf, io.LimitReader(fr.Data, int64(bodySize))); err != nil {
|
if _, err := io.Copy(buf, io.LimitReader(fr.Data, int64(bodySize))); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in New Issue
Block a user