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"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user