Files
Leonov Vladislav 1c42c638a6 recorder: emit periodic session snapshots and byte deltas
Handlers write one recorder entry when a session ends, so long-lived
SOCKS5 tunnels, streaming HTTP responses and UDP associations stay
invisible while they run and report their traffic as one late record.

With recorder.period set, a session now emits interim records on the
interval while it is active and one final record when it ends. Each
record carries the cumulative counters alongside the increments since the
previous one, so a consumer sums records instead of replacing them, plus
sessionID, recordIndex and phase. A session that ends inside one period
still writes a single record, as it does today.

One goroutine per handler samples every live session on the tick and
writes its record straight to the recorder. There is no queue and no
retry loop, and none is needed: a session moves its cursor past a record
only once the recorder has taken it, so a refused write costs no bytes.
They stay in the delta and go out with the next record. Holding on to
what the sink cannot take right now is the sink's job.

Interim and Finish take a context and return an error, so a handler
learns what became of every record it asked for. Start writes nothing, so
it takes neither. Close stops the sampling and reports the sessions its
owner had not finished; nothing is buffered, so there is nothing to
drain.

With recorder.period unset or non-positive the previous behaviour is
kept: one record per session, written inline, in the old shape. An object
its handler never stamped, and one whose record mode is off, is dropped
in both modes.
2026-09-15 15:05:02 +03:00

314 lines
9.8 KiB
Go

package http
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptrace"
"net/http/httputil"
"strings"
"time"
"github.com/go-gost/core/bypass"
"github.com/go-gost/core/logger"
stats "github.com/go-gost/core/observer/stats"
xbypass "github.com/go-gost/x/bypass"
ictx "github.com/go-gost/x/internal/ctx"
xio "github.com/go-gost/x/internal/io"
xnet "github.com/go-gost/x/internal/net"
xhttp "github.com/go-gost/x/internal/net/http"
"github.com/go-gost/x/internal/util/sniffing"
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"
)
// handleProxy implements HTTP forward proxy semantics. It wraps the
// connection for stats, performs the first round-trip, and then enters
// a keep-alive loop reading and proxying subsequent requests on the
// same connection until the client or upstream closes.
//
// Each request is processed through proxyRoundTrip, which handles
// authentication header stripping, bypass checks, and body recording.
func (h *httpHandler) handleProxy(ctx context.Context, conn net.Conn, req *http.Request, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
pStats := xstats.Stats{}
conn = stats_wrapper.WrapConn(conn, &pStats)
ro.Time = time.Time{}
if close, err := h.proxyRoundTrip(ctx, conn, req, ro, &pStats, log); err != nil || close {
return err
}
// Keep-alive loop: read and proxy subsequent requests.
br := bufio.NewReader(conn)
for {
pStats.Reset()
req, err := http.ReadRequest(br)
if err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpRequest(req, false)
log.Trace(string(dump))
}
if close, err := h.proxyRoundTrip(ctx, xio.NewReadWriteCloser(br, conn, conn), req, ro, &pStats, log); err != nil || close {
req.Body.Close()
return err
}
req.Body.Close()
}
}
// proxyRoundTrip performs a single HTTP round-trip for a forward proxy
// request. It:
//
// - Clones the recorder object for per-request isolation.
// - Normalises the host and adds a default port if missing.
// - Adjusts HTTP/1.0 Connection headers for compatibility.
// - Strips proxy-specific headers (Proxy-Authorization, Gost-Target, etc.).
// - Checks bypass rules — forbidden hosts get a 403.
// - Optionally captures the request body for recording.
// - Sends the request through the upstream transport.
// - Optionally captures the response body for recording.
// - Handles 101 Switching Protocols (WebSocket upgrades).
//
// The close return value indicates whether the underlying connection
// should be closed after this round-trip (e.g. HTTP/1.0 without keep-alive,
// or an upstream Connection: close).
func (h *httpHandler) proxyRoundTrip(ctx context.Context, rw io.ReadWriteCloser, req *http.Request, ro *xrecorder.HandlerRecorderObject, pStats stats.Stats, log logger.Logger) (close bool, err error) {
close = true
// Clone the recorder object so per-request mutations don't leak.
ro2 := &xrecorder.HandlerRecorderObject{}
*ro2 = *ro
ro = ro2
if v := req.Header.Get("Gost-Record"); v != "" {
ro.RecordMode = strings.ToLower(v)
}
req.Header.Del("Gost-Record")
// Origin-form requests ("GET / HTTP/1.1" + Host header, e.g. sent by
// nginx proxy_pass) carry their target only in req.Host, which
// normalizeRequest doesn't mirror into the URL (it fills Scheme only,
// leaving URL.Host empty). Subsequent keep-alive requests parsed in
// handleProxy never pass through normalizeRequest at all. The
// http.Transport requires a non-empty URL.Host, otherwise it fails
// with "http: no Host in request URL" (#679).
if req.URL.Host == "" {
req.URL.Scheme = "http"
req.URL.Host = req.Host
}
host := normalizeHostPort(req.Host, "80")
ro.Host = host
ro.Time = time.Now()
log = log.WithFields(map[string]any{
"host": host,
})
// A keep-alive connection carries several exchanges; each is accounted for
// on its own, under the connection's shared counters.
session := h.sessionRecorder.NewSession(ctx, pStats)
log.Infof("%s <-> %s", ro.RemoteAddr, req.Host)
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(ro.Time)
if err := session.Finish(ctx, *ro); err != nil {
log.Errorf("record: %v", err)
}
log.WithFields(map[string]any{
"duration": time.Since(ro.Time),
"inputBytes": ro.InputBytes,
"outputBytes": ro.OutputBytes,
}).Infof("%s >-< %s", ro.RemoteAddr, req.Host)
}()
ro.HTTP = &xrecorder.HTTPRecorderObject{
Host: req.Host,
Proto: req.Proto,
Scheme: req.URL.Scheme,
Method: req.Method,
URI: req.RequestURI,
Request: xrecorder.HTTPRequestRecorderObject{
ContentLength: req.ContentLength,
Header: req.Header.Clone(),
},
}
// HTTP/1.0: ensure Connection: close unless the client explicitly
// requested keep-alive (which we translate by removing the header
// so the default close behaviour applies).
http10 := req.ProtoMajor == 1 && req.ProtoMinor == 0
if http10 {
if strings.ToLower(req.Header.Get("Connection")) == "keep-alive" {
req.Header.Del("Connection")
} else {
req.Header.Set("Connection", "close")
}
}
// Strip hop-by-hop proxy headers before forwarding.
req.Header.Del("Proxy-Authorization")
req.Header.Del("Proxy-Connection")
req.Header.Del("Gost-Target")
req.Header.Del("X-Gost-Target")
res := &http.Response{
ProtoMajor: req.ProtoMajor,
ProtoMinor: req.ProtoMinor,
Header: http.Header{},
StatusCode: http.StatusServiceUnavailable,
}
ro.HTTP.StatusCode = res.StatusCode
if h.options.Bypass != nil &&
h.options.Bypass.Contains(ctx, "tcp", host, bypass.WithService(h.options.Service)) {
res.StatusCode = http.StatusForbidden
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(res, false)
log.Trace(string(dump))
}
log.Debug("bypass: ", host)
if werr := res.Write(rw); werr != nil {
log.Error("write bypass response: ", werr)
}
err = xbypass.ErrBypass
return
}
// Optionally intercept the request body for recording. The original
// body is replaced with a tee reader so the transport still sees it.
var reqBody *xhttp.Body
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && req.Body != nil && ro.RecordMode != "headers" && ro.RecordMode != "off" {
reqBody = xhttp.NewBody(req.Body, bodySize)
req.Body = reqBody
}
ctx = ictx.ContextWithRecorderObject(ctx, ro)
ctx = ictx.ContextWithLogger(ctx, log)
// Report from the moment the upstream connection is in hand, and only for
// this request: a recorder of its own would otherwise inherit the callback
// and overwrite the addresses with its own.
traceCtx := httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
ro.SrcAddr = info.Conn.LocalAddr().String()
ro.DstAddr = info.Conn.RemoteAddr().String()
session.Start(*ro)
},
})
resp, err := h.transport.RoundTrip(req.WithContext(traceCtx))
if reqBody != nil {
ro.HTTP.Request.Body = reqBody.Content()
ro.HTTP.Request.ContentLength = reqBody.Length()
}
if err != nil {
if werr := res.Write(rw); werr != nil {
log.Error("write error response: ", werr)
}
return
}
defer resp.Body.Close()
ro.HTTP.StatusCode = resp.StatusCode
ro.HTTP.Response.Header = resp.Header.Clone()
ro.HTTP.Response.ContentLength = resp.ContentLength
if log.IsLevelEnabled(logger.TraceLevel) {
dump, _ := httputil.DumpResponse(resp, false)
log.Trace(string(dump))
}
// HTTP/1.0: propagate keep-alive if the upstream supports it, and
// force the response protocol version to 1.0.
if http10 {
if !resp.Close {
resp.Header.Set("Connection", "keep-alive")
}
resp.ProtoMajor = 1
resp.ProtoMinor = 0
}
if resp.StatusCode == http.StatusSwitchingProtocols {
err = h.handleUpgradeResponse(ctx, rw, req, resp, ro, log)
return
}
// Optionally intercept the response body for recording.
var respBody *xhttp.Body
if bodySize := sniffing.ClampBodySize(h.recorder.Options); bodySize > 0 && ro.RecordMode != "headers" && ro.RecordMode != "off" {
respBody = xhttp.NewBody(resp.Body, bodySize)
resp.Body = respBody
}
err = resp.Write(rw)
if respBody != nil {
ro.HTTP.Response.Body = respBody.Content()
ro.HTTP.Response.ContentLength = respBody.Length()
}
if err != nil {
err = fmt.Errorf("write response: %v", err)
return
}
close = resp.Close
return
}
// handleUpgradeResponse handles HTTP 101 Switching Protocols responses.
// It validates that the request and response upgrade types match, writes
// the 101 response back to the client, and then relays raw bytes between
// the client and the backend connection.
//
// If the upgrade is to "websocket" and WebSocket sniffing is enabled,
// frame-level recording is performed via sniffingWebsocketFrame.
func (h *httpHandler) handleUpgradeResponse(ctx context.Context, rw io.ReadWriteCloser, req *http.Request, res *http.Response, ro *xrecorder.HandlerRecorderObject, log logger.Logger) error {
reqUpType := upgradeType(req.Header)
resUpType := upgradeType(res.Header)
if !strings.EqualFold(reqUpType, resUpType) {
return fmt.Errorf("backend tried to switch protocol %q when %q was requested", resUpType, reqUpType)
}
backConn, ok := res.Body.(io.ReadWriteCloser)
if !ok {
return fmt.Errorf("internal error: 101 switching protocols response with non-writable body")
}
res.Body = nil
if err := res.Write(rw); err != nil {
return fmt.Errorf("response write: %v", err)
}
if reqUpType == "websocket" && h.md.sniffingWebsocket {
return h.sniffingWebsocketFrame(ctx, rw, backConn, ro, log)
}
return xnet.Pipe(ctx, rw, backConn, xnet.WithReadTimeout(h.md.idleTimeout))
}