Files
x/handler/http/handler.go
T
ginuerzh 95874c53f5 fix: add status field to service list/detail APIs, unify observeStats retry pattern, fix router bugs
- api: add fillServiceStatus helper and call it from getServiceList/getService
  so status field appears in service list and detail API responses
- observeStats: unify retry pattern across all 9 handlers (http, http2, masque,
  relay, router, socks4, socks5, tungo, tunnel) — buffer events on failure,
  continue to skip fresh collection, clear on success; fix event-loss bug
  where interim events were dropped during retry cycles
- handler/router: check WriteTo/Write return errors in associate.go and
  entrypoint.go; fix DelConnector and ConnectorPool.Del using RLock instead
  of Lock (write under read lock); remove unused fields t and cancel
2026-06-03 22:12:08 +08:00

583 lines
20 KiB
Go

// Package http implements an HTTP/HTTPS proxy handler. It supports both
// forward proxy (GET/POST/CONNECT) and tunnel modes with optional TLS
// sniffing, MITM decryption, WebSocket frame recording, probe resistance,
// and UDP relay.
//
// The handler is registered under the name "http" and created via
// NewHandler in init().
//
// # Request processing flow
//
// Each inbound net.Conn is handled by Handle, which reads exactly one
// HTTP request and delegates to handleRequest for routing. The flow
// through the handler proceeds as follows:
//
// Handle()
// ├─ stats.WrapConn (per-connection I/O counters)
// ├─ checkRateLimit (connection rate limiter, if configured)
// ├─ http.ReadRequest
// ├─ xhttp.GetClientIP (extract client IP from headers)
// └─ handleRequest()
//
// # Request dispatch (handleRequest)
//
// handleRequest is the central dispatcher. It normalises the request
// and routes to one of three code paths:
//
// 1. URL scheme inference — if the URL is not absolute (plain HTTP
// proxy request), the Host header is validated with govalidator.
// A valid DNS name or IP address implies the scheme is "http".
//
// 2. Network detection — reads the X-Gost-Protocol header. If the
// value is "udp" the request is treated as a UDP relay; otherwise
// it defaults to "tcp".
//
// 3. GOST v2 compatibility — decodes Gost-Target and X-Gost-Target
// headers (base64+CRC32 encoding) to recover the actual target host
// from older GOST clients.
//
// 4. Host normalisation — appends ":80" if no port is present.
//
// 5. Authentication — see "Authentication and probe resistance" below.
//
// 6. Bypass check — if a bypass matcher is configured and the target
// address matches, a 403 Forbidden is returned.
//
// 7. Routing:
// • X-Gost-Protocol: udp → handleUDP
// • setupTrafficLimiter wraps the conn (per-client shaping + observer stats)
// • Method != CONNECT → handleProxy (forward proxy)
// • Method == CONNECT → handleConnect (tunnel)
//
// # Forward proxy path (handleProxy → proxyRoundTrip)
//
// handleProxy wraps the connection with per-request stats, performs the
// first round-trip via proxyRoundTrip, then enters a keep-alive loop
// reading and proxying subsequent requests on the same connection until
// the client or upstream closes.
//
// proxyRoundTrip performs a single HTTP round-trip:
// - Clones the recorder object for per-request isolation.
// - Normalises the host (adds default port if missing).
// - Handles HTTP/1.0 compatibility (Connection: keep-alive / close).
// - Strips proxy-specific headers: Proxy-Authorization, Proxy-Connection,
// Gost-Target, X-Gost-Target.
// - Checks bypass rules (403 Forbidden on match).
// - Optionally intercepts the request body for recording (tee reader).
// - Sends the request through the upstream http.Transport (which dials
// through the proxy chain router via h.dial).
// - Optionally intercepts the response body for recording.
// - Handles 101 Switching Protocols (see "WebSocket upgrade" below).
// - Writes the response to the client.
//
// # CONNECT tunnel path (handleConnect → sniffAndHandle)
//
// handleConnect implements the HTTP CONNECT tunnel:
// 1. Dials the target address through the proxy chain router (h.dial).
// 2. On dial failure, writes a 503 Service Unavailable to the client.
// 3. Writes "200 Connection established" to the client.
// 4. If sniffing is enabled, calls sniffAndHandle to inspect the
// initial bytes from the client.
// 5. If sniffAndHandle does not claim the connection, falls back to
// raw bidirectional pipe forwarding (xnet.Pipe).
//
// sniffAndHandle peeks at the client's initial bytes:
// - Sets a read deadline (sniffing.timeout) to bound the peek.
// - Calls sniffing.Sniff to classify the protocol.
// - For HTTP traffic: delegates to sniffer.HandleHTTP (HTTP request
// routing through the tunnel).
// - For TLS traffic: delegates to sniffer.HandleTLS (MITM decryption
// if certificates are configured, or blind forwarding).
// - For unrecognised protocols: returns (false, nil), and the caller
// falls back to raw pipe forwarding.
// - Both sniff handlers receive dial closures that return the
// already-established upstream connection, so the sniffer uses the
// same tunnel rather than opening a new one.
//
// # WebSocket upgrade (handleUpgradeResponse)
//
// When proxyRoundTrip receives a 101 Switching Protocols response:
// 1. Validates that the request and response upgrade types match.
// 2. Extracts the backend connection from the response body
// (the HTTP transport upgrades the connection on 101).
// 3. Writes the 101 response to the client.
// 4. If the upgrade is "websocket" and sniffing.websocket is enabled,
// starts frame-level recording via sniffingWebsocketFrame.
// 5. Otherwise, falls back to raw bidirectional pipe forwarding.
//
// sniffingWebsocketFrame runs two goroutines (one per direction) that
// read WebSocket frames, record metadata and optionally the payload
// (subject to a rate limiter with sniffing.websocket.sampleRate), and
// forward the frames to the peer. The first direction to encounter an
// error terminates the relay.
//
// # Authentication and probe resistance (authenticate)
//
// authenticate extracts credentials from the Proxy-Authorization header
// (HTTP Basic auth) and validates them against the configured Auther.
//
// When no Auther is configured, all requests are anonymous (id="", ok=true).
//
// When authentication fails and probeResistance is configured, the handler
// returns a decoy response instead of the normal 407 Proxy-Auth-Required,
// making the port appear to run a different service. Four strategies are
// supported:
//
// code: respond with a custom HTTP status code (e.g. "code:404")
// web: fetch a URL and replay its response (e.g. "web:example.com")
// host: forward the raw request to a decoy host (e.g. "host:1.2.3.4:80")
// file: serve a local file with Content-Type: text/html
//
// The knock feature (pr.Knock) restricts probe resistance to clients
// that don't know a secret hostname. When pr.Knock is set and the request
// hostname matches, probe resistance is bypassed and the normal 407 is
// returned — revealing the proxy only to clients that know the knock
// address.
//
// # UDP relay (handleUDP)
//
// When X-Gost-Protocol is "udp":
// - If enableUDP is false, a 403 Forbidden is returned.
// - Otherwise a 200 OK is written, a UDP association is dialled through
// the proxy chain router, and the HTTP connection is wrapped as a
// SOCKS5 UDP tunnel. Client data is read as SOCKS5-encapsulated UDP
// datagrams and relayed through the chain.
//
// # Upstream dialing (dial)
//
// dial establishes upstream connections through the proxy chain router.
// It attaches the recorder object and logger from the context and, when
// h.md.hash is "host", sets the target address as the hash source for
// consistent hop selection. The route taken is written to the recorder
// object via the context buffer.
//
// # Initialisation (Init)
//
// Init parses metadata, creates a cached traffic limiter, starts the
// background stats observer goroutine, picks the first service-handler
// recorder, initialises the MITM certificate pool (if mitm.certFile and
// mitm.keyFile are both provided), and builds the upstream http.Transport.
// The transport dials through the proxy chain via h.dial.
//
// # Observability
//
// Per-connection stats are tracked via stats_wrapper.WrapConn in Handle.
// Per-client stats (KindTotalConns, KindCurrentConns) are tracked in
// setupTrafficLimiter and published to the configured Observer on a
// periodic ticker (observerPeriod, default 5s, min 1s). The observeStats
// goroutine retries previously failed events before sending new ones.
//
// # Key configuration (metadata)
//
// readTimeout: upstream response header timeout (default 15s, negative disables)
// idleTimeout: pipe forwarding idle timeout (0 = disabled)
// keepalive: enable HTTP keep-alive on the upstream transport
// compression: enable HTTP compression on the upstream transport
// probeResist: decoy response on auth failure (format "type:value")
// knock: secret hostname that bypasses probe resistance
// udp: enable UDP relay
// sniffing: enable protocol sniffing on CONNECT tunnels
// sniffing.timeout: read deadline for the initial sniff peek
// sniffing.websocket: enable WebSocket frame recording
// sniffing.websocket.sampleRate: max frames recorded per second
// mitm.certFile / mitm.keyFile: CA certificate pair for TLS decryption
// mitm.bypass: named bypass matcher to skip MITM decryption
// hash: hop selection strategy ("host" for consistent hashing)
// observerPeriod: stats reporting interval (default 5s, min 1s)
// proxyAgent: Proxy-Agent header value (default "gost/3.0")
package http
import (
"bufio"
"context"
"errors"
"net"
"net/http"
"net/http/httputil"
"time"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/handler"
"github.com/go-gost/core/limiter"
"github.com/go-gost/core/limiter/traffic"
"github.com/go-gost/core/logger"
md "github.com/go-gost/core/metadata"
"github.com/go-gost/core/observer"
stats "github.com/go-gost/core/observer/stats"
"github.com/go-gost/core/recorder"
xbypass "github.com/go-gost/x/bypass"
xctx "github.com/go-gost/x/ctx"
xnet "github.com/go-gost/x/internal/net"
xhttp "github.com/go-gost/x/internal/net/http"
stats_util "github.com/go-gost/x/internal/util/stats"
tls_util "github.com/go-gost/x/internal/util/tls"
rate_limiter "github.com/go-gost/x/limiter/rate"
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
xstats "github.com/go-gost/x/observer/stats"
stats_wrapper "github.com/go-gost/x/observer/stats/wrapper"
xrecorder "github.com/go-gost/x/recorder"
"github.com/go-gost/x/registry"
)
func init() {
registry.HandlerRegistry().Register("http", NewHandler)
}
// httpHandler is the HTTP/HTTPS proxy handler implementation. It holds the
// parsed metadata, handler options, and runtime state such as the traffic
// limiter, recorder, certificate pool, and the upstream HTTP transport.
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
recorder recorder.RecorderObject // first matching service-handler recorder
certPool tls_util.CertPool // in-memory cert pool for MITM TLS termination
transport http.RoundTripper // upstream HTTP transport (injectable for tests)
}
// NewHandler creates a new HTTP handler and applies the given options.
// It is called by the registry when a service references the "http" handler.
func NewHandler(opts ...handler.Option) handler.Handler {
options := handler.Options{}
for _, opt := range opts {
opt(&options)
}
return &httpHandler{
options: options,
}
}
// Init parses metadata, creates the traffic limiter and stats tracker,
// picks the first matching recorder, initialises the MITM cert pool if
// a certificate is configured, and builds the upstream http.Transport.
func (h *httpHandler) Init(md md.Metadata) error {
if err := h.parseMetadata(md); err != nil {
return err
}
ctx, cancel := context.WithCancel(context.Background())
h.cancel = cancel
if h.options.Observer != nil {
h.stats = stats_util.NewHandlerStats(h.options.Service, h.md.observerResetTraffic)
go h.observeStats(ctx)
}
if h.options.Limiter != nil {
h.limiter = cache_limiter.NewCachedTrafficLimiter(h.options.Limiter,
cache_limiter.RefreshIntervalOption(h.md.limiterRefreshInterval),
cache_limiter.CleanupIntervalOption(h.md.limiterCleanupInterval),
cache_limiter.ScopeOption(limiter.ScopeClient),
)
}
for _, ro := range h.options.Recorders {
if ro.Record == xrecorder.RecorderServiceHandler {
h.recorder = ro
break
}
}
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()
}
// Build the upstream transport that routes through the proxy chain.
// DialContext uses h.dial which goes through h.options.Router.
h.transport = &http.Transport{
DialContext: h.dial,
IdleConnTimeout: 30 * time.Second,
ResponseHeaderTimeout: h.md.readTimeout,
DisableKeepAlives: !h.md.keepalive,
DisableCompression: !h.md.compression,
}
return nil
}
// Handle processes an inbound connection. It reads one HTTP request,
// wraps the connection for stats, applies rate limiting, extracts the
// client IP, and delegates to handleRequest for routing.
//
// The connection is always closed when Handle returns.
func (h *httpHandler) Handle(ctx context.Context, conn net.Conn, opts ...handler.HandleOption) (err error) {
defer conn.Close()
start := time.Now()
ro := &xrecorder.HandlerRecorderObject{
Service: h.options.Service,
RemoteAddr: conn.RemoteAddr().String(),
LocalAddr: conn.LocalAddr().String(),
Proto: "http",
Time: start,
SID: xctx.SidFromContext(ctx).String(),
}
if srcAddr := xctx.SrcAddrFromContext(ctx); srcAddr != nil {
ro.ClientAddr = srcAddr.String()
}
log := h.options.Logger.WithFields(map[string]any{
"network": ro.Network,
"remote": conn.RemoteAddr().String(),
"local": conn.LocalAddr().String(),
"client": ro.ClientAddr,
"sid": ro.SID,
})
log.Infof("%s <> %s", conn.RemoteAddr(), conn.LocalAddr())
pStats := xstats.Stats{}
conn = stats_wrapper.WrapConn(conn, &pStats)
defer func() {
if err != nil {
ro.Err = err.Error()
}
ro.InputBytes = pStats.Get(stats.KindInputBytes)
ro.OutputBytes = pStats.Get(stats.KindOutputBytes)
ro.Duration = time.Since(start)
if err := ro.Record(ctx, h.recorder.Recorder); err != nil {
log.Error("record: %v", err)
}
log.WithFields(map[string]any{
"duration": time.Since(start),
"inputBytes": ro.InputBytes,
"outputBytes": ro.OutputBytes,
}).Infof("%s >< %s", conn.RemoteAddr(), conn.LocalAddr())
}()
if !h.checkRateLimit(conn.RemoteAddr()) {
return rate_limiter.ErrRateLimit
}
br := bufio.NewReader(conn)
req, err := http.ReadRequest(br)
if err != nil {
log.Error(err)
return err
}
defer req.Body.Close()
if clientIP := xhttp.GetClientIP(req); clientIP != nil {
ro.ClientIP = clientIP.String()
}
conn = xnet.NewReadWriteConn(br, conn, conn)
return h.handleRequest(ctx, conn, req, ro, log)
}
// handleRequest is the central request dispatcher. It normalises the request
// URL and host, extracts the target from GOST-compatible headers, validates
// the method, authenticates the client, applies bypass rules, and routes
// to one of three code paths:
//
// - UDP: handleUDP
// - 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 {
nr := normalizeRequest(req)
network := nr.Network
addr := nr.Addr
ro.Network = network
ro.Host = addr
fields := map[string]any{
"dst": addr,
"host": addr,
"network": network,
}
if u, _, _ := basicProxyAuth(req.Header.Get("Proxy-Authorization")); u != "" {
fields["user"] = u
ro.ClientID = u
}
log = log.WithFields(fields)
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpRequest(req, false)
log.Trace(string(dump))
}
log.Debugf("%s >> %s", conn.RemoteAddr(), addr)
// Build a skeleton response reused across error/auth/bypass writes.
resp := &http.Response{
ProtoMajor: 1,
ProtoMinor: 1,
Header: h.md.header,
ContentLength: -1,
}
if resp.Header == nil {
resp.Header = http.Header{}
}
ro.HTTP = buildHTTPRecorder(req)
defer func() {
ro.HTTP.StatusCode = resp.StatusCode
ro.HTTP.Response.Header = resp.Header
}()
// HTTP/2 connection preface "PRI * HTTP/2.0" is rejected, as are
// non-CONNECT requests without an http:// scheme.
if req.Method == "PRI" ||
(req.Method != http.MethodConnect && req.URL.Scheme != "http") {
resp.StatusCode = http.StatusBadRequest
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(resp, false)
log.Trace(string(dump))
}
return resp.Write(conn)
}
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": 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(result.ClientID))
if h.options.Bypass != nil &&
h.options.Bypass.Contains(ctx, network, addr, bypass.WithService(h.options.Service)) {
resp.StatusCode = http.StatusForbidden
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(resp, false)
log.Trace(string(dump))
}
log.Debug("bypass: ", addr)
if err := resp.Write(conn); err != nil {
log.Error("write bypass response: ", err)
}
return xbypass.ErrBypass
}
if network == "udp" {
return h.handleUDP(ctx, conn, result.ClientID, ro, log)
}
conn, done := h.setupTrafficLimiter(conn, result.ClientID, network, addr)
if done != nil {
defer done()
}
if req.Method != http.MethodConnect {
return h.handleProxy(ctx, conn, req, ro, log)
}
return h.handleConnect(ctx, conn, ro, log, addr, resp)
}
// Close cancels the background stats goroutine started in Init.
// It is safe to call on a handler that was never initialised.
func (h *httpHandler) Close() error {
if h.cancel != nil {
h.cancel()
}
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.
func (h *httpHandler) observeStats(ctx context.Context) {
if h.options.Observer == nil {
return
}
var events []observer.Event
ticker := time.NewTicker(h.md.observerPeriod)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Try to flush any buffered events from a previous failed attempt.
if len(events) > 0 {
if err := h.options.Observer.Observe(ctx, events); err != nil {
continue
}
}
// Collect and send fresh events.
if evs := h.stats.Events(); len(evs) > 0 {
if err := h.options.Observer.Observe(ctx, evs); err != nil {
events = evs
continue
}
}
events = nil
case <-ctx.Done():
return
}
}
}