Skip to content
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ require (
github.com/zmap/zgrab2 v0.1.8
gitlab.com/gitlab-org/api/client-go v1.9.1
go.mongodb.org/mongo-driver v1.17.9
golang.org/x/sync v0.20.0
golang.org/x/term v0.43.0
gopkg.in/yaml.v3 v3.0.1
moul.io/http2curl v1.0.0
Expand Down Expand Up @@ -395,7 +396,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/sync v0.20.0 // indirect
mellium.im/sasl v0.3.2 // indirect
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
)
Expand Down
12 changes: 11 additions & 1 deletion internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ func New(options *types.Options) (*Runner, error) {
var httpclient *retryablehttp.Client
if options.ProxyInternal && options.AliveHttpProxy != "" || options.AliveSocksProxy != "" {
var err error
httpclient, err = httpclientpool.Get(options, &httpclientpool.Configuration{})
httpclient, err = httpclientpool.Get(options, &httpclientpool.Configuration{}, "")
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -427,6 +427,11 @@ func (r *Runner) Close() {
if r.httpStats != nil {
r.httpStats.DisplayTopStats(r.options.NoColor)
}
if newConns, reusedConns := httpclientpool.GetConnectionStats(); newConns+reusedConns > 0 {
total := newConns + reusedConns
ratio := float64(reusedConns) / float64(total) * 100
gologger.Info().Msgf("HTTP connections: %d total, %d new, %d reused (%.1f%%)", total, newConns, reusedConns, ratio)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// dump hosterrors cache
if r.hostErrors != nil {
r.hostErrors.Close()
Expand Down Expand Up @@ -507,6 +512,11 @@ func (r *Runner) setupPDCPUpload(writer output.Writer) output.Writer {
// RunEnumeration sets up the input layer for giving input nuclei.
// binary and runs the actual enumeration
func (r *Runner) RunEnumeration() error {
// Reset connection-reuse counters so the summary logged on Close()
// reflects only this run, not totals accumulated across multiple
// in-process executions (e.g. SDK / embedded usage).
httpclientpool.ResetConnectionStats()

// If the user has asked for DAST server mode, run the live
// DAST fuzzing server.
if r.options.DASTServer {
Expand Down
2 changes: 1 addition & 1 deletion lib/sdk_private.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ func (e *NucleiEngine) init(ctx context.Context) error {
}

if e.opts.ProxyInternal && e.opts.AliveHttpProxy != "" || e.opts.AliveSocksProxy != "" {
httpclient, err := httpclientpool.Get(e.opts, &httpclientpool.Configuration{})
httpclient, err := httpclientpool.Get(e.opts, &httpclientpool.Configuration{}, "")
if err != nil {
return err
}
Expand Down
7 changes: 4 additions & 3 deletions lib/tests/sdk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import (
)

var knownLeaks = []goleak.Option{
// prettyify the output and generate dependency graph and more details instead of just stack output
goleak.Pretty(),
// net/http transport maintains idle connections which are closed with cooldown
// hence they don't count as leaks
// net/http transport maintains idle keep-alive connections whose goroutines
// exit on idle timeout or explicit close - not real leaks.
goleak.IgnoreAnyFunction("net/http.(*http2ClientConn).readLoop"),
goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"),
goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"),
}

func TestSimpleNuclei(t *testing.T) {
Expand Down
12 changes: 6 additions & 6 deletions pkg/protocols/common/automaticscan/automaticscan.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/logrusorgru/aurora/v4"
"github.com/pkg/errors"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
Expand All @@ -22,10 +23,8 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/writer"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
httputil "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils/http"
"github.com/projectdiscovery/nuclei/v3/pkg/scan"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/retryablehttp-go"
"github.com/projectdiscovery/useragent"
mapsutil "github.com/projectdiscovery/utils/maps"
Expand Down Expand Up @@ -95,11 +94,12 @@ func New(opts Options) (*Service, error) {
return nil, err
}

// Wappalyzer fingerprinting is a stateless GET reused across every target.
// Disable the cookie jar to avoid retaining cross-target state and the
// associated memory growth from a long-lived shared client.
httpclient, err := httpclientpool.Get(opts.ExecuterOpts.Options, &httpclientpool.Configuration{
Connection: &httpclientpool.ConnectionConfiguration{
DisableKeepAlive: httputil.ShouldDisableKeepAlive(opts.ExecuterOpts.Options),
},
})
DisableCookie: true,
}, "")
if err != nil {
return nil, errors.Wrap(err, "could not get http client")
}
Expand Down
3 changes: 1 addition & 2 deletions pkg/protocols/common/protocolstate/dialers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ import (
"github.com/projectdiscovery/networkpolicy"
"github.com/projectdiscovery/rawhttp"
"github.com/projectdiscovery/retryablehttp-go"
mapsutil "github.com/projectdiscovery/utils/maps"
)

type Dialers struct {
Fastdialer *fastdialer.Dialer
RawHTTPClient *rawhttp.Client
DefaultHTTPClient *retryablehttp.Client
HTTPClientPool *mapsutil.SyncLockMap[string, *retryablehttp.Client]
HTTPClientPool *HTTPPool
NetworkPolicy *networkpolicy.NetworkPolicy
LocalFileAccessAllowed bool
RestrictLocalNetworkAccess bool
Expand Down
225 changes: 225 additions & 0 deletions pkg/protocols/common/protocolstate/httppool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package protocolstate

import (
"net/http"
"sync"
"sync/atomic"
"time"

"github.com/projectdiscovery/retryablehttp-go"
"golang.org/x/sync/singleflight"
)

// closeIdler is implemented by transports that can drop idle connections.
type closeIdler interface{ CloseIdleConnections() }

// httpTransportEntry tracks a pooled transport and its last access time.
type httpTransportEntry struct {
rt http.RoundTripper
lastAccess atomic.Int64 // unix nanoseconds
}

func (e *httpTransportEntry) touch(now int64) { e.lastAccess.Store(now) }

// httpClientEntry tracks a pooled client, the transport it shares and its
// last access time.
type httpClientEntry struct {
client *retryablehttp.Client
transport *httpTransportEntry
lastAccess atomic.Int64 // unix nanoseconds
}

func (e *httpClientEntry) touch(now int64) {
e.lastAccess.Store(now)
if e.transport != nil {
// keep the shared transport alive while any of its clients is active
e.transport.touch(now)
}
}

// HTTPPool is a two-level cache for retryablehttp clients and the
// http.RoundTripper transports they share.
//
// Design goals (hot path = one lookup per outgoing request):
// - lock-free cache hits: sync.Map reads plus atomic last-access updates,
// no global mutex acquisition per request
// - singleflight creation: concurrent first requests to the same key build
// exactly one client/transport instead of N-1 orphans holding sockets
// - transport/client split: client-level settings (redirect policy, cookie
// jar, timeout) get their own cheap client wrapper while sharing one
// transport (and therefore one connection pool) per host
// - eviction closes connections: idle transports get CloseIdleConnections()
// instead of being silently dropped for the GC to find
type HTTPPool struct {
clients sync.Map // string -> *httpClientEntry
transports sync.Map // string -> *httpTransportEntry
clientSF singleflight.Group
transportSF singleflight.Group

inactivity time.Duration
cleanupInterval time.Duration
lastCleanup atomic.Int64 // unix nanoseconds
cleanupRunning atomic.Bool
}

// NewHTTPPool creates a pool whose entries are evicted after the given
// inactivity duration, checked lazily at most once per cleanupInterval.
func NewHTTPPool(inactivity, cleanupInterval time.Duration) *HTTPPool {
p := &HTTPPool{
inactivity: inactivity,
cleanupInterval: cleanupInterval,
}
p.lastCleanup.Store(time.Now().UnixNano())
return p
}

// GetClient returns a cached client for the key, refreshing its eviction
// timestamp. The hit path performs no locking.
func (p *HTTPPool) GetClient(key string) (*retryablehttp.Client, bool) {
v, ok := p.clients.Load(key)
if !ok {
return nil, false
}
entry := v.(*httpClientEntry)
entry.touch(time.Now().UnixNano())
p.maybeCleanup()
return entry.client, true
}

// GetOrCreateClient returns the cached client for clientKey or builds it
// exactly once (singleflight) using a transport shared via transportKey.
func (p *HTTPPool) GetOrCreateClient(
clientKey, transportKey string,
createTransport func() (http.RoundTripper, error),
createClient func(rt http.RoundTripper) (*retryablehttp.Client, error),
) (*retryablehttp.Client, error) {
if client, ok := p.GetClient(clientKey); ok {
return client, nil
}
v, err, _ := p.clientSF.Do(clientKey, func() (interface{}, error) {
if existing, ok := p.clients.Load(clientKey); ok {
return existing.(*httpClientEntry), nil
}
tEntry, err := p.getOrCreateTransportEntry(transportKey, createTransport)
if err != nil {
return nil, err
}
client, err := createClient(tEntry.rt)
if err != nil {
return nil, err
}
entry := &httpClientEntry{client: client, transport: tEntry}
entry.touch(time.Now().UnixNano())
p.clients.Store(clientKey, entry)
return entry, nil
})
if err != nil {
return nil, err
}
entry := v.(*httpClientEntry)
entry.touch(time.Now().UnixNano())
return entry.client, nil
}

// GetOrCreateTransport returns the shared transport for the key, building it
// exactly once. Used directly by callers that need an uncached client (e.g.
// explicit per-request cookie jars) but still want pooled connections.
func (p *HTTPPool) GetOrCreateTransport(key string, create func() (http.RoundTripper, error)) (http.RoundTripper, error) {
entry, err := p.getOrCreateTransportEntry(key, create)
if err != nil {
return nil, err
}
return entry.rt, nil
}

func (p *HTTPPool) getOrCreateTransportEntry(key string, create func() (http.RoundTripper, error)) (*httpTransportEntry, error) {
if v, ok := p.transports.Load(key); ok {
entry := v.(*httpTransportEntry)
entry.touch(time.Now().UnixNano())
return entry, nil
}
v, err, _ := p.transportSF.Do(key, func() (interface{}, error) {
if existing, ok := p.transports.Load(key); ok {
return existing.(*httpTransportEntry), nil
}
rt, err := create()
if err != nil {
return nil, err
}
entry := &httpTransportEntry{rt: rt}
entry.touch(time.Now().UnixNano())
p.transports.Store(key, entry)
return entry, nil
})
if err != nil {
return nil, err
}
entry := v.(*httpTransportEntry)
entry.touch(time.Now().UnixNano())
return entry, nil
}

// maybeCleanup spawns a single background eviction pass if the cleanup
// interval has elapsed. Uses CAS so only one goroutine wins.
func (p *HTTPPool) maybeCleanup() {
if p.inactivity <= 0 {
return
}
now := time.Now().UnixNano()
last := p.lastCleanup.Load()
if now-last < p.cleanupInterval.Nanoseconds() {
return
}
if !p.lastCleanup.CompareAndSwap(last, now) {
return
}
if !p.cleanupRunning.CompareAndSwap(false, true) {
return
}
go func() {
defer p.cleanupRunning.Store(false)
p.evictInactive()
}()
}

// evictInactive drops clients and transports idle for longer than the
// inactivity window. Evicted transports get their idle connections closed
// immediately instead of waiting for the GC / IdleConnTimeout.
func (p *HTTPPool) evictInactive() {
deadline := time.Now().Add(-p.inactivity).UnixNano()

p.clients.Range(func(k, v interface{}) bool {
if v.(*httpClientEntry).lastAccess.Load() < deadline {
p.clients.Delete(k)
}
return true
})
// Transports are touched whenever one of their clients is touched, so a
// transport only goes idle once all clients sharing it are idle too.
p.transports.Range(func(k, v interface{}) bool {
entry := v.(*httpTransportEntry)
if entry.lastAccess.Load() < deadline {
p.transports.Delete(k)
if ci, ok := entry.rt.(closeIdler); ok {
ci.CloseIdleConnections()
}
}
return true
})
}

// Close drops all cached clients and transports, closing idle connections so
// no transport goroutines linger after shutdown.
func (p *HTTPPool) Close() {
p.clients.Range(func(k, _ interface{}) bool {
p.clients.Delete(k)
return true
})
p.transports.Range(func(k, v interface{}) bool {
p.transports.Delete(k)
if ci, ok := v.(*httpTransportEntry).rt.(closeIdler); ok {
ci.CloseIdleConnections()
}
return true
})
}
Loading
Loading