mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 19:11:25 -07:00
546ab091da
the shared transport was a bare DefaultTransport.Clone() with the stock MaxIdleConnsPerHost=2, and call-sites only close response bodies without draining them - so go could never return a conn to the idle pool and every request re-dialed. high thread counts just thrashed the dialer. - plumb Threads through Options into buildTransport; size MaxIdleConnsPerHost to the worker count (floored) so concurrent workers on one host pool instead of re-dialing, MaxIdleConns=512, MaxConnsPerHost=0, IdleConnTimeout=90s, ForceAttemptHTTP2. the socks5 branch gets its own keepalive net.Dialer so it doesn't lose os-level pooling under proxy.Direct. - add DrainClose to read (capped) and close a body so the conn is reusable. - benchmark proves it: 50 sequential requests reuse 1 conn tuned vs 50 bare.
492 lines
14 KiB
Go
492 lines
14 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package httpx
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// resetConfig clears the package-level transport so each test starts clean.
|
|
func resetConfig(t *testing.T) {
|
|
t.Helper()
|
|
mu.Lock()
|
|
configured = nil
|
|
mu.Unlock()
|
|
}
|
|
|
|
// captureServer records the headers of the last request it served.
|
|
func captureServer(t *testing.T, seen *http.Header) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
*seen = r.Header.Clone()
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func get(t *testing.T, client *http.Client, url string) {
|
|
t.Helper()
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestClientBeforeConfigure(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
var seen http.Header
|
|
srv := captureServer(t, &seen)
|
|
|
|
// a client must work with no Configure call so existing code is unaffected.
|
|
get(t, Client(5*time.Second), srv.URL)
|
|
|
|
if seen == nil {
|
|
t.Fatal("request never reached the server")
|
|
}
|
|
}
|
|
|
|
func TestConfigureHeadersAndCookie(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opts Options
|
|
wantKey string
|
|
wantValue string
|
|
}{
|
|
{
|
|
name: "custom header injected",
|
|
opts: Options{Headers: []string{"X-Test: sif"}},
|
|
wantKey: "X-Test",
|
|
wantValue: "sif",
|
|
},
|
|
{
|
|
name: "cookie injected",
|
|
opts: Options{Cookie: "session=abc"},
|
|
wantKey: "Cookie",
|
|
wantValue: "session=abc",
|
|
},
|
|
{
|
|
name: "user agent injected",
|
|
opts: Options{UserAgent: "sif-scanner"},
|
|
wantKey: "User-Agent",
|
|
wantValue: "sif-scanner",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
if err := Configure(tt.opts); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
var seen http.Header
|
|
srv := captureServer(t, &seen)
|
|
get(t, Client(5*time.Second), srv.URL)
|
|
|
|
if got := seen.Get(tt.wantKey); got != tt.wantValue {
|
|
t.Errorf("header %q = %q, want %q", tt.wantKey, got, tt.wantValue)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigureHeaderDoesNotOverride(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
if err := Configure(Options{Headers: []string{"X-Test: global"}}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
var seen http.Header
|
|
srv := captureServer(t, &seen)
|
|
|
|
// a caller that sets the header explicitly must win over the global default.
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("X-Test", "caller")
|
|
resp, err := Client(5 * time.Second).Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if got := seen.Get("X-Test"); got != "caller" {
|
|
t.Errorf("X-Test = %q, want caller (caller value must not be overridden)", got)
|
|
}
|
|
}
|
|
|
|
func TestConfigureInvalidHeader(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
// a header without ": " should fail loud rather than silently dropping.
|
|
if err := Configure(Options{Headers: []string{"missing-separator"}}); err == nil {
|
|
t.Fatal("expected error for malformed header, got nil")
|
|
}
|
|
}
|
|
|
|
func TestConfigureInvalidProxy(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
proxy string
|
|
}{
|
|
{name: "unsupported scheme", proxy: "ftp://localhost:1080"},
|
|
{name: "malformed url", proxy: "://nope"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
resetConfig(t)
|
|
if err := Configure(Options{Proxy: tt.proxy}); err == nil {
|
|
t.Errorf("expected error for proxy %q, got nil", tt.proxy)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRateLimit(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
const ratePerSec = 5
|
|
if err := Configure(Options{RateLimit: ratePerSec}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
var seen http.Header
|
|
srv := captureServer(t, &seen)
|
|
client := Client(5 * time.Second)
|
|
|
|
// at 5 req/s the limiter starts with a full burst, so the first batch is
|
|
// immediate and the next request must wait roughly one tick. fire burst+1
|
|
// requests and assert the extra one forced a measurable delay.
|
|
const requests = ratePerSec + 1
|
|
start := time.Now()
|
|
for i := 0; i < requests; i++ {
|
|
get(t, client, srv.URL)
|
|
}
|
|
elapsed := time.Since(start)
|
|
|
|
// one request beyond the burst should cost about 1/rate; allow slack but
|
|
// require a non-trivial delay so an unthrottled client fails this.
|
|
minDelay := time.Second / ratePerSec / 2
|
|
if elapsed < minDelay {
|
|
t.Errorf("expected rate limiting to add >= %v of delay, got %v", minDelay, elapsed)
|
|
}
|
|
}
|
|
|
|
func TestRateLimitUnlimited(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
// RateLimit 0 means no limiter is installed; requests should fly through.
|
|
if err := Configure(Options{RateLimit: 0}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
mu.RLock()
|
|
rt, ok := configured.(*roundTripper)
|
|
mu.RUnlock()
|
|
if !ok {
|
|
t.Fatal("configured transport is not *roundTripper")
|
|
}
|
|
if rt.limiter != nil {
|
|
t.Error("expected no limiter when RateLimit is 0")
|
|
}
|
|
}
|
|
|
|
func TestIdlePerHost(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
threads int
|
|
want int
|
|
}{
|
|
{name: "below floor clamps up", threads: 1, want: minIdleConnsPerHost},
|
|
{name: "zero clamps up", threads: 0, want: minIdleConnsPerHost},
|
|
{name: "at floor", threads: minIdleConnsPerHost, want: minIdleConnsPerHost},
|
|
{name: "above floor passes through", threads: 64, want: 64},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := idlePerHost(tt.threads); got != tt.want {
|
|
t.Errorf("idlePerHost(%d) = %d, want %d", tt.threads, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildTransportTuning(t *testing.T) {
|
|
const threads = 32
|
|
tr, err := buildTransport("", threads)
|
|
if err != nil {
|
|
t.Fatalf("buildTransport: %v", err)
|
|
}
|
|
|
|
if tr.MaxIdleConns != maxIdleConns {
|
|
t.Errorf("MaxIdleConns = %d, want %d", tr.MaxIdleConns, maxIdleConns)
|
|
}
|
|
if tr.MaxIdleConnsPerHost != threads {
|
|
t.Errorf("MaxIdleConnsPerHost = %d, want %d", tr.MaxIdleConnsPerHost, threads)
|
|
}
|
|
if tr.MaxConnsPerHost != 0 {
|
|
t.Errorf("MaxConnsPerHost = %d, want 0 (unbounded)", tr.MaxConnsPerHost)
|
|
}
|
|
if tr.IdleConnTimeout != idleConnTimeout {
|
|
t.Errorf("IdleConnTimeout = %v, want %v", tr.IdleConnTimeout, idleConnTimeout)
|
|
}
|
|
if !tr.ForceAttemptHTTP2 {
|
|
t.Error("ForceAttemptHTTP2 = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestDrainClose(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
// serve a body the caller never reads; DrainClose must drain it so the conn
|
|
// is eligible for reuse rather than abandoned mid-stream.
|
|
const body = "sif response body that the caller never reads"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
io.WriteString(w, body)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
resp, err := Client(5 * time.Second).Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request: %v", err)
|
|
}
|
|
|
|
DrainClose(resp)
|
|
|
|
// after DrainClose the body is closed; a further read must fail.
|
|
if _, err := resp.Body.Read(make([]byte, 1)); err == nil {
|
|
t.Error("expected read after DrainClose to fail on a closed body")
|
|
}
|
|
}
|
|
|
|
func TestDrainCloseNil(t *testing.T) {
|
|
// a nil response (e.g. an errored request) must not panic.
|
|
DrainClose(nil)
|
|
DrainClose(&http.Response{})
|
|
}
|
|
|
|
// countConns wraps a test server with a ConnState hook that tallies how many
|
|
// distinct tcp conns the server saw. distinct conns == failed reuse.
|
|
func countConns(t *testing.T) (*httptest.Server, func() int) {
|
|
t.Helper()
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
conns = make(map[net.Conn]struct{})
|
|
)
|
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
// always write a body so reuse depends on the caller draining it.
|
|
io.WriteString(w, "ok")
|
|
}))
|
|
srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
|
|
if state != http.StateNew {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
conns[c] = struct{}{}
|
|
mu.Unlock()
|
|
}
|
|
srv.Start()
|
|
t.Cleanup(srv.Close)
|
|
|
|
return srv, func() int {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return len(conns)
|
|
}
|
|
}
|
|
|
|
func TestTransportReusesConnections(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
const (
|
|
threads = 8
|
|
requests = 30
|
|
)
|
|
if err := Configure(Options{Threads: threads}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
srv, distinct := countConns(t)
|
|
|
|
// fire N sequential requests through the tuned client, draining each body so
|
|
// the conn returns to the pool. a working pool serves all of them on one conn.
|
|
client := Client(5 * time.Second)
|
|
for i := 0; i < requests; i++ {
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("new request %d: %v", i, err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request %d: %v", i, err)
|
|
}
|
|
DrainClose(resp)
|
|
}
|
|
|
|
// sequential reuse should land on exactly one conn; allow a tiny margin for
|
|
// the rare race where a conn is reaped between requests.
|
|
const maxReuseConns = 2
|
|
if got := distinct(); got > maxReuseConns {
|
|
t.Errorf("tuned client opened %d conns for %d requests, want <= %d (pool not reusing)",
|
|
got, requests, maxReuseConns)
|
|
}
|
|
}
|
|
|
|
func TestBareClientDoesNotReuse(t *testing.T) {
|
|
srv, distinct := countConns(t)
|
|
|
|
// the control: a bare DefaultTransport client whose caller closes but never
|
|
// drains the body. go can't reuse a half-read conn, so each request dials
|
|
// fresh - this is exactly the pre-tuning behavior we're fixing.
|
|
client := &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
Transport: http.DefaultTransport.(*http.Transport).Clone(),
|
|
}
|
|
|
|
const requests = 30
|
|
for i := 0; i < requests; i++ {
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("new request %d: %v", i, err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request %d: %v", i, err)
|
|
}
|
|
// close without draining - the leak that kills reuse.
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// most requests should have dialed a fresh conn. don't demand exactly N (the
|
|
// scheduler occasionally reuses one), just that it's clearly not pooling.
|
|
const minDistinct = requests / 2
|
|
if got := distinct(); got < minDistinct {
|
|
t.Errorf("bare client opened only %d conns for %d requests, want >= %d "+
|
|
"(expected near-zero reuse without draining)", got, requests, minDistinct)
|
|
}
|
|
}
|
|
|
|
// BenchmarkConnReuse contrasts the tuned, draining client against a bare client
|
|
// that closes without draining. the reported conns/op metric is the distinct
|
|
// tcp conns one pass of `requests` opened - tuned≈1, bare≈requests - so the
|
|
// README can quote real before/after reuse numbers. the conn map is reset per
|
|
// iteration so the metric stays a per-pass count and the bare path doesn't
|
|
// accumulate b.N*requests live sockets and exhaust the ephemeral port range.
|
|
//
|
|
// run the bare sub-bench with a bounded -benchtime (e.g. -benchtime 5x): its
|
|
// whole point is that it can't reuse, so a large b.N floods the local port
|
|
// space with TIME_WAIT sockets. the tuned sub-bench reuses and runs unbounded.
|
|
func BenchmarkConnReuse(b *testing.B) {
|
|
const requests = 50
|
|
|
|
run := func(b *testing.B, drain bool, client *http.Client) {
|
|
b.Helper()
|
|
var (
|
|
mu sync.Mutex
|
|
conns = make(map[net.Conn]struct{})
|
|
)
|
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
io.WriteString(w, strings.Repeat("x", 256))
|
|
}))
|
|
srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
|
|
if state != http.StateNew {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
conns[c] = struct{}{}
|
|
mu.Unlock()
|
|
}
|
|
srv.Start()
|
|
defer srv.Close()
|
|
|
|
var lastPass int
|
|
b.ResetTimer()
|
|
for n := 0; n < b.N; n++ {
|
|
mu.Lock()
|
|
conns = make(map[net.Conn]struct{})
|
|
mu.Unlock()
|
|
for i := 0; i < requests; i++ {
|
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
b.Fatalf("do: %v", err)
|
|
}
|
|
if drain {
|
|
DrainClose(resp)
|
|
} else {
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
// close idle conns between passes so the bare client's per-pass
|
|
// sockets land in TIME_WAIT and free up before the next pass.
|
|
client.CloseIdleConnections()
|
|
mu.Lock()
|
|
lastPass = len(conns)
|
|
mu.Unlock()
|
|
}
|
|
b.StopTimer()
|
|
|
|
// distinct conns for a single pass of `requests`.
|
|
b.ReportMetric(float64(lastPass), "conns/op")
|
|
}
|
|
|
|
b.Run("tuned-drain", func(b *testing.B) {
|
|
resetBench()
|
|
tr, err := buildTransport("", 8)
|
|
if err != nil {
|
|
b.Fatalf("buildTransport: %v", err)
|
|
}
|
|
run(b, true, &http.Client{Timeout: 5 * time.Second, Transport: tr})
|
|
})
|
|
|
|
b.Run("bare-noDrain", func(b *testing.B) {
|
|
run(b, false, &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
Transport: http.DefaultTransport.(*http.Transport).Clone(),
|
|
})
|
|
})
|
|
}
|
|
|
|
// resetBench clears the package transport without a *testing.T for benchmarks.
|
|
func resetBench() {
|
|
mu.Lock()
|
|
configured = nil
|
|
mu.Unlock()
|
|
}
|