From 590fc339550f5348cab41cb60b6e3ef2db4bb666 Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Mon, 6 Apr 2026 00:41:33 +0300 Subject: [PATCH] feat(09-02): add LimiterRegistry with per-source rate limiters and jitter - NewLimiterRegistry + For(name, rate, burst) idempotent lookup - Wait blocks on token then applies 100ms-1s jitter when stealth - Per-source isolation (RECON-INFRA-05), ctx cancellation honored - Tests: isolation, idempotency, ctx cancel, jitter range, no-jitter --- pkg/recon/limiter.go | 64 +++++++++++++++++++++++++++++++++++++++ pkg/recon/limiter_test.go | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 pkg/recon/limiter.go create mode 100644 pkg/recon/limiter_test.go diff --git a/pkg/recon/limiter.go b/pkg/recon/limiter.go new file mode 100644 index 0000000..c601346 --- /dev/null +++ b/pkg/recon/limiter.go @@ -0,0 +1,64 @@ +// Package recon provides the OSINT/recon framework: per-source rate limiting, +// stealth controls, robots.txt compliance, and parallel sweep orchestration. +package recon + +import ( + "context" + "math/rand" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// LimiterRegistry holds one *rate.Limiter per source name. +// +// RECON-INFRA-05: each source owns its own limiter — no centralization. +// Lookups are idempotent: the same name always returns the same *rate.Limiter +// pointer, guaranteeing that token state persists across calls for a source. +type LimiterRegistry struct { + mu sync.Mutex + limiters map[string]*rate.Limiter +} + +// NewLimiterRegistry constructs an empty registry. +func NewLimiterRegistry() *LimiterRegistry { + return &LimiterRegistry{limiters: make(map[string]*rate.Limiter)} +} + +// For returns the limiter for name, creating it with (r, burst) on first call. +// Repeat calls with the same name return the same *rate.Limiter pointer, +// regardless of subsequent (r, burst) arguments — the first registration wins. +func (lr *LimiterRegistry) For(name string, r rate.Limit, burst int) *rate.Limiter { + lr.mu.Lock() + defer lr.mu.Unlock() + if l, ok := lr.limiters[name]; ok { + return l + } + l := rate.NewLimiter(r, burst) + lr.limiters[name] = l + return l +} + +// Wait blocks until the source's token is available. If stealth is true, +// an additional random jitter between 100ms and 1s is applied after the token +// acquisition to evade fingerprint-based detection (RECON-INFRA-06 partial — +// fully wired with the stealth package in 09-03). +// +// Context cancellation is honored both during the underlying limiter.Wait and +// during the jitter sleep; ctx.Err() is returned promptly in either case. +func (lr *LimiterRegistry) Wait(ctx context.Context, name string, r rate.Limit, burst int, stealth bool) error { + l := lr.For(name, r, burst) + if err := l.Wait(ctx); err != nil { + return err + } + if stealth { + jitter := time.Duration(100+rand.Intn(900)) * time.Millisecond + select { + case <-time.After(jitter): + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} diff --git a/pkg/recon/limiter_test.go b/pkg/recon/limiter_test.go new file mode 100644 index 0000000..33e5c7f --- /dev/null +++ b/pkg/recon/limiter_test.go @@ -0,0 +1,61 @@ +package recon + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" +) + +func TestLimiterPerSourceIsolation(t *testing.T) { + r := NewLimiterRegistry() + a := r.For("a", 10, 1) + b := r.For("b", 10, 1) + require.NotNil(t, a) + require.NotNil(t, b) + require.NotSame(t, a, b, "different source names must yield distinct limiters") +} + +func TestLimiterIdempotent(t *testing.T) { + r := NewLimiterRegistry() + a1 := r.For("a", 10, 1) + a2 := r.For("a", 10, 1) + require.Same(t, a1, a2, "repeat calls with same name must return same *rate.Limiter") +} + +func TestWaitRespectsContext(t *testing.T) { + r := NewLimiterRegistry() + // Prime limiter with a very slow rate so Wait would block + _ = r.For("slow", rate.Limit(0.001), 1) + // Consume the single burst token so the next Wait actually blocks + l := r.For("slow", rate.Limit(0.001), 1) + require.True(t, l.Allow()) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // pre-cancelled + + err := r.Wait(ctx, "slow", rate.Limit(0.001), 1, false) + require.Error(t, err) +} + +func TestJitterRange(t *testing.T) { + r := NewLimiterRegistry() + // High rate so only jitter meaningfully contributes + start := time.Now() + err := r.Wait(context.Background(), "fast", rate.Limit(1000), 100, true) + elapsed := time.Since(start) + require.NoError(t, err) + require.GreaterOrEqual(t, elapsed, 90*time.Millisecond, "jitter should be >= 100ms (with slack)") + require.LessOrEqual(t, elapsed, 1200*time.Millisecond, "jitter should be <= 1s (with slack)") +} + +func TestJitterDisabled(t *testing.T) { + r := NewLimiterRegistry() + start := time.Now() + err := r.Wait(context.Background(), "fast2", rate.Limit(1000), 100, false) + elapsed := time.Since(start) + require.NoError(t, err) + require.Less(t, elapsed, 50*time.Millisecond, "no jitter when stealth=false") +}