// 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 }() // Authenticate before request validation so that probe resistance // can intercept non-proxy-form requests (e.g., browser/scanner probes // that send "GET / HTTP/1.1" instead of proxy-form URLs). 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") } // 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) } 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 } } }