Skip to content

Commit 032dc90

Browse files
PR3: -rate query-per-second limiter
- -rate <qps> flag (default 0 = unlimited) caps total DNS queries per second across the worker pool via a shared stdlib ticker gate inside scan.Run. Respects context cancellation so Ctrl+C stays responsive. - CHANGELOG [Unreleased] and ARCHITECTURE 2.4 document the limiter. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6ce6da7 commit 032dc90

7 files changed

Lines changed: 78 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- Resolved records are now captured during scans. `internal/dns` exposes `Resolve` and a `Record{Type, Value}` type; `scan.Event` carries `Records` for each resolved subdomain (A/AAAA today, extensible to CNAME and more).
1212
- `-format text|json|csv` flag (default `text`, byte-for-byte identical to prior output). JSON emits a buffered array of `{"subdomain", "records"}` objects; CSV streams `subdomain,type,value` rows with a header. The `-o` output file honors the selected format. Output formats are CLI-only for now (TUI-pending).
13+
- `-rate <qps>` flag (default 0 = unlimited) caps total DNS queries per second across the worker pool via a shared stdlib ticker gate inside `scan.Run`. The limiter respects context cancellation so `Ctrl+C` stays responsive.
1314

1415
## [0.5.1] - 2026-06-03
1516

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Or launch the interactive terminal UI with no flags:
7878
| Simulation Mode | Generate synthetic DNS results at a configurable hit rate, with zero network I/O |
7979
| Output Pipeline | Resolved domains to stdout (pipe-clean); progress and diagnostics to stderr |
8080
| Output Formats | Emit results as `text`, `json` (array of subdomain plus typed records), or `csv` via `-format` |
81+
| Rate Limiting | Cap total DNS queries per second across the worker pool with `-rate` (context-aware) |
8182
| Interactive TUI | Form-based config and live-scrolling results via `-tui`; session values persisted |
8283

8384
<br>
@@ -200,6 +201,7 @@ make help # list all targets
200201
| `-force` | `false` | Continue scanning even if wildcard DNS is detected |
201202
| `-o <file>` | n/a | Write results to file in addition to stdout |
202203
| `-format <fmt>` | `text` | Output format: `text`, `json`, or `csv` |
204+
| `-rate <qps>` | `0` | Max DNS queries per second across all workers (0 = unlimited) |
203205
| `-v` | `false` | Verbose output: IPs, timings, per-query detail (stderr) |
204206
| `-progress` | `true` | Live progress line on stderr |
205207
| `-simulate` | `false` | Simulation mode: no real DNS queries |

docs/ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube
9191
* **`var wg sync.WaitGroup`**: A `sync.WaitGroup` waits for all worker goroutines to finish.
9292
* **Worker Goroutines Loop**: `cfg.Concurrency` goroutines are launched. Each reads prefixes from the channel, constructs the full domain, and calls `dns.ResolveDomainWithRetry()` (or `dns.SimulateResolution()` in simulate mode).
9393
* **Progress ticker**: A separate goroutine fires every second and emits `EventProgress` events so callers can update their display.
94+
* **Rate limiter** (optional): when `cfg.Rate > 0`, a shared `time.Ticker` gate paces total DNS queries per second across the whole pool. Each worker waits on the gate before issuing a query, selecting on `ctx.Done()` so cancellation stays responsive. `0` means unlimited.
9495
* **Closing the Channel**: After all entries are sent, the channel is closed, signalling workers to exit. `wg.Wait()` blocks until all workers are done, then `EventDone` is emitted.
9596
* **Interactions**: `scan.Run` is the single entry point for scanning used by both the CLI output pipeline and the Bubble Tea TUI. It decouples the scan engine from any specific display layer.
9697

examples/advanced_usage.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,16 @@ Simulation mode with verbose output shows fake IPs and timings:
151151
./subenum -simulate -hit-rate 25 -v -w examples/sample_wordlist.txt example.com
152152
```
153153

154+
## Rate Limiting
155+
156+
Use `-rate` to cap the total number of DNS queries per second across the whole worker pool. This is useful against rate-limited resolvers or to stay under a target query budget. `0` (the default) means unlimited:
157+
158+
```bash
159+
./subenum -w wordlist.txt -rate 50 example.com
160+
```
161+
162+
The limiter is context-aware, so `Ctrl+C` stays responsive while workers are waiting on it.
163+
154164
## Output Formats
155165

156166
By default `subenum` prints human-readable `Found:` lines. Use `-format` to emit structured output instead. The `-o` file honors the same format.

internal/scan/runner.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type Config struct {
2121
Attempts int
2222
Force bool
2323
Verbose bool
24+
Rate int
2425
}
2526

2627
// EventKind categorises a scan event.
@@ -74,6 +75,19 @@ func Run(ctx context.Context, cfg Config, events chan<- Event) {
7475
subdomains := make(chan string)
7576
var wg sync.WaitGroup
7677

78+
// Optional rate limiter: a shared ticker gate paces total queries per second
79+
// across the whole worker pool. nil means unlimited.
80+
var limiter <-chan time.Time
81+
if cfg.Rate > 0 {
82+
interval := time.Second / time.Duration(cfg.Rate)
83+
if interval <= 0 {
84+
interval = time.Nanosecond
85+
}
86+
rl := time.NewTicker(interval)
87+
defer rl.Stop()
88+
limiter = rl.C
89+
}
90+
7791
// Progress ticker - fires every second.
7892
// tickerDone signals the goroutine to stop; tickerStopped confirms it has
7993
// fully exited so we never close events while a send is pending.
@@ -113,6 +127,14 @@ func Run(ctx context.Context, cfg Config, events chan<- Event) {
113127
atomic.AddInt64(&processed, 1)
114128
continue
115129
}
130+
if limiter != nil {
131+
select {
132+
case <-limiter:
133+
case <-ctx.Done():
134+
atomic.AddInt64(&processed, 1)
135+
continue
136+
}
137+
}
116138
fullDomain := prefix + "." + cfg.Domain
117139
var resolved bool
118140
var records []dns.Record

internal/scan/runner_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,41 @@ func TestRunSimulateConcurrent(t *testing.T) {
6666
}
6767
}
6868

69+
// TestRunRateLimit asserts that -rate paces queries: N queries at R qps should
70+
// take at least (N-1)/R seconds. Uses simulate mode so it is network-free.
71+
func TestRunRateLimit(t *testing.T) {
72+
if testing.Short() {
73+
t.Skip("skipping timing-sensitive rate limit test in short mode")
74+
}
75+
76+
const queries = 20
77+
const rate = 10 // qps
78+
cfg := Config{
79+
Domain: "example.com",
80+
Entries: makeEntries(queries),
81+
Concurrency: 8,
82+
Timeout: time.Second,
83+
Simulate: true,
84+
HitRate: 50,
85+
Attempts: 1,
86+
Rate: rate,
87+
}
88+
89+
events := make(chan Event, 64)
90+
start := time.Now()
91+
go Run(context.Background(), cfg, events)
92+
for range events { //nolint:revive // draining
93+
}
94+
elapsed := time.Since(start)
95+
96+
// Floor: the first tick fires after one interval, so expect at least
97+
// (queries-1)/rate seconds, with a margin for scheduling jitter.
98+
minExpected := time.Duration(float64(queries-1) / float64(rate) * 0.8 * float64(time.Second))
99+
if elapsed < minExpected {
100+
t.Errorf("rate-limited scan finished too fast: %s < %s", elapsed, minExpected)
101+
}
102+
}
103+
69104
// TestRunContextCancel cancels mid-scan and asserts Run returns and closes the
70105
// events channel promptly.
71106
func TestRunContextCancel(t *testing.T) {

main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ type cliFlags struct {
101101
retries int
102102
force bool
103103
format string
104+
rate int
104105
}
105106

106107
func parseFlags() cliFlags {
@@ -120,6 +121,7 @@ func parseFlags() cliFlags {
120121
flag.IntVar(&f.retries, "retries", 0, "Deprecated: use -attempts instead")
121122
flag.BoolVar(&f.force, "force", false, "Continue scanning even if wildcard DNS is detected")
122123
flag.StringVar(&f.format, "format", "text", "Output format: text, json, or csv")
124+
flag.IntVar(&f.rate, "rate", 0, "Max DNS queries per second across all workers (0 = unlimited)")
123125
flag.Parse()
124126
return f
125127
}
@@ -146,6 +148,10 @@ func validateFlags(f cliFlags, out *output.Writer, maxAttempts int) (string, boo
146148
out.Error("Attempts (-attempts) must be at least 1")
147149
return "", false
148150
}
151+
if f.rate < 0 {
152+
out.Error("Rate (-rate) must be 0 (unlimited) or a positive integer")
153+
return "", false
154+
}
149155
if !f.testMode {
150156
if err := validateDNSServer(f.dnsServer); err != nil {
151157
out.Error("DNS server %s: %v", f.dnsServer, err)
@@ -311,6 +317,7 @@ func run() int {
311317
Attempts: maxAttempts,
312318
Force: f.force,
313319
Verbose: f.verbose,
320+
Rate: f.rate,
314321
}
315322

316323
events := make(chan scan.Event, 64)

0 commit comments

Comments
 (0)