diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index 2c7ef92e..3db31f4c 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -14,7 +14,7 @@ jobs:
- uses: actions/setup-go@v7
with:
- go-version: 1.24.x
+ go-version: 1.25.x
- name: Test
run: go test ./...
@@ -23,19 +23,7 @@ jobs:
run: go build .
working-directory: cmd/shuffledns/
- # Installing massdns
- - uses: actions/checkout@v7
- with:
- repository: blechschmidt/massdns
- path: ./massdns
-
- - name: Install massdns
- working-directory: ./massdns
- run: |
- make all
- sudo make install
-
- # Tests
+ # Integration tests (native resolver; massdns binary not required)
- name: Bruteforce Mode Test
run: go run . -v -d scanme.sh -r ../../tests/resolvers.txt -w ../../tests/wordlist.txt -mode bruteforce
working-directory: cmd/shuffledns/
diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml
index 4aea1696..53302da0 100644
--- a/.github/workflows/lint-test.yml
+++ b/.github/workflows/lint-test.yml
@@ -11,7 +11,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
- go-version: 1.24.x
+ go-version: 1.25.x
- name: Checkout code
uses: actions/checkout@v7
diff --git a/.github/workflows/release-binary.yml b/.github/workflows/release-binary.yml
index c857669d..f27f58e6 100644
--- a/.github/workflows/release-binary.yml
+++ b/.github/workflows/release-binary.yml
@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
- go-version: 1.24.x
+ go-version: 1.25.x
- uses: goreleaser/goreleaser-action@v7
with:
args: "release --clean"
diff --git a/.github/workflows/release-test.yml b/.github/workflows/release-test.yml
index 1a11dc71..d0ec7f40 100644
--- a/.github/workflows/release-test.yml
+++ b/.github/workflows/release-test.yml
@@ -19,7 +19,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
- go-version: 1.24.x
+ go-version: 1.25.x
- name: release test
uses: goreleaser/goreleaser-action@v7
diff --git a/README.md b/README.md
index ebb0e958..1f1af2de 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,7 @@
-
massDNS wrapper to bruteforce and resolve the subdomains with wildcard handling support
-
+Native high-throughput DNS bruteforce and resolve with wildcard handling
@@ -13,50 +12,59 @@
-
+
- Feature •
- Install •
+ Features •
+ Install •
+ Usage •
Run •
- Wildcard •
- License •
- Discord
+ Wildcards •
+ massdns parity •
+ License
---
+`shuffledns` bruteforces and resolves subdomains with multi-level wildcard filtering. Resolution is done by a **native Go stub resolver** (massdns-compatible design and output). An optional **iterative** mode walks from the DNS roots and needs no public resolver list.
-`shuffleDNS` is a wrapper around `massdns`, written in go, that allows you to enumerate valid subdomains using active bruteforce, as well as resolve subdomains with wildcard handling and easy input-output support.
-
-Based on the work on `massdns` project by [@blechschmidt](https://github.com/blechschmidt).
+Inspired by [massdns](https://github.com/blechschmidt/massdns) by [@blechschmidt](https://github.com/blechschmidt). The massdns binary is **not required**.
- # Features
+# Features
- - Simple and modular code base making it easy to contribute.
- - Fast And Simple active subdomain scanning.
- - Handles wildcard subdomains in a smart manner.
- - Optimized for **ease of use**
- - **Stdin** and **stdout** support for integrating in workflows
+- Native high-throughput resolver (no massdns binary)
+- Bruteforce, resolve, and filter modes with stdin/stdout
+- Multi-level wildcard handling
+- Optional iterative resolution from the DNS roots (`-it`)
+- Resolver health scoring, adaptive concurrency, Linux sendmmsg/recvmmsg batching
+- Shard / resume for distributed or long runs
+
+# Installation
+
+`go1.24+` required:
+
+```bash
+go install -v github.com/projectdiscovery/shuffledns/cmd/shuffledns@latest
+```
+
+You still need a resolver list for the default stub mode (e.g. from [dnsvalidator](https://github.com/vortexau/dnsvalidator)) unless you use `-it` / `--iterative`.
# Usage
```bash
shuffledns -h
```
-This will display help for the tool. Here are all the switches it supports.
```yaml
-shuffleDNS is a wrapper around massdns written in go that allows you to enumerate valid subdomains using active bruteforce as well as resolve subdomains with wildcard handling and easy input-output support.
+shuffleDNS is a high-throughput DNS bruteforcer and resolver with wildcard handling. It uses a native Go stub resolver (massdns-compatible) and optional iterative resolution from the DNS roots.
Usage:
./shuffledns [flags]
-Flags:
Flags:
INPUT:
-d, -domain string[] Domain to find or resolve subdomains for
@@ -65,15 +73,12 @@ INPUT:
-w, -wordlist string File containing words to bruteforce for domain
-r, -resolver string File containing list of resolvers for enumeration
-tr, -trusted-resolver string File containing list of trusted resolvers
- -ri, -raw-input string Validate raw full massdns output
+ -ri, -raw-input string Filter wildcards from an existing massdns-format output file
-mode string Execution mode (bruteforce, resolve, filter)
RATE-LIMIT:
- -t int Number of concurrent massdns resolves (default 10000)
-
-UPDATE:
- -up, -update update shuffledns to latest version
- -duc, -disable-update-check disable automatic shuffledns update check
+ -t int Max concurrent in-flight DNS queries (default 10000)
+ -qps int Max outbound DNS queries per second (0 = unlimited)
OUTPUT:
-o, -output string File to write output to (optional)
@@ -81,14 +86,34 @@ OUTPUT:
-wo, -wildcard-output string Dump wildcard ips to output file
CONFIGURATIONS:
- -m, -massdns string Path to the massdns binary
- -mcmd, -massdns-cmd string Optional massdns commands to run (example '-i 10')
+ -m, -massdns string Deprecated (ignored): massdns binary is not used
+ -mcmd, -massdns-cmd string Deprecated (ignored)
-directory string Temporary directory for enumeration
OPTIMIZATIONS:
- -retries int Number of retries for dns enumeration (default 5)
- -sw, -strict-wildcard Perform wildcard check on all found subdomains
- -wt int Number of concurrent wildcard checks (default 250)
+ -retries int Number of retries for dns enumeration (default 5)
+ -sw, -strict-wildcard Perform wildcard check on all found subdomains
+ -wt int Number of concurrent wildcard checks (default 250)
+ -filter-internal-ips Filter out internal/private IP addresses
+
+RESOLVER:
+ -rt, -type string DNS record type (A, AAAA, CNAME, NS, PTR, MX, TXT, SOA)
+ -bm, -batch-mode string sendmmsg/recvmmsg: off | on | adaptive (Linux, default off)
+ -sc, -socket-count int UDP sockets per run (0 = scale to cores)
+ -udp-size int EDNS0 UDP payload size (0 = 1232)
+ -norecurse Send non-recursive queries (RD=0)
+ -sticky Do not rotate resolver on retry
+ -rhz, -resolver-health De-weight failing resolvers
+ -acy, -adaptive-concurrency Adapt in-flight concurrency to packet loss
+ -cc, -cross-check Re-verify positive answers on a second resolver
+ -ei, -extended-input Parse 'name [resolver ...]' input lines
+ -no-verify-ip Disable reply source-IP verification
+ -no-tcp-fallback Disable TCP fallback on truncated answers
+ -it, -iterative Recurse from root servers (no -r needed)
+
+DISTRIBUTED:
+ -shard string Process only shard m of n (e.g. 2/8)
+ -rs, -resume string Checkpoint file for crash-safe stop/resume
DEBUG:
-silent Show only subdomains in output
@@ -97,83 +122,96 @@ DEBUG:
-nc, -no-color Don't Use colors in output
```
-
-
-|
-
-## Prerequisite
+# Running shuffledns
-`shuffledns` requires `massdns` to be installed in order to perform its operations. You can see the installation instructions at [massdns project](https://github.com/blechschmidt/massdns#compilation). If you place the binary in `/usr/bin/massdns` or `/usr/local/bin/massdns`, the tool will auto-detect the presence of the binary and use it. On Windows, you need to supply the path to the binary for the tool to work.
+### Resolve
-The tool also needs a list of valid resolvers. The [dnsvalidator](https://github.com/vortexau/dnsvalidator) project can be used to generate these lists. You also need to provide wordlist, you can use a custom wordlist or use the [commonspeak2-wordlist](https://wordlists-cdn.assetnote.io/data/manual/best-dns-wordlist.txt).
-
- |
-
-
+```bash
+shuffledns -d example.com -list example-subdomains.txt -r resolvers.txt -mode resolve
+```
-## Installation Instructions
+```bash
+subfinder -d example.com | shuffledns -d example.com -r resolvers.txt -mode resolve
+```
-`shuffledns` requires `go1.24+` to install successfully. Run the following command to install the latest version:
+### Bruteforce
```bash
-go install -v github.com/projectdiscovery/shuffledns/cmd/shuffledns@latest
+shuffledns -d hackerone.com -w wordlist.txt -r resolvers.txt -mode bruteforce
```
-## Running shuffledns
+```bash
+echo hackerone.com | shuffledns -w wordlist.txt -r resolvers.txt -mode bruteforce
+```
-`shuffledns` supports two types of operations:
+### Iterative (no public resolvers)
-**Subdomain resolving**
+```bash
+shuffledns -d example.com -w wordlist.txt -mode bruteforce -it
+```
-To resolve a list of subdomains, you can pass the list of subdomains via the `-list` option.
+### Filter existing massdns-format output
```bash
-shuffledns -d example.com -list example-subdomains.txt -r resolvers.txt -mode resolve
+shuffledns -d example.com -ri massdns-output.txt -mode filter
```
-This will run the tool against subdomains in `example-subdomains.txt` and returns the results. The tool uses the resolvers specified with `-r` flag to do the resolving.
+### Tuning
-You can also pass the list of subdomains at standard input (STDIN). This allows for easy integration in automation pipelines.
+- `-t` caps **in-flight** queries (massdns `-s`). More concurrency does not help once resolvers or RTT are the limit.
+- `-qps` caps **send rate** when you need to stay under resolver/abuse limits.
+- `-bm adaptive` helps on high-RTT / bursty paths; leave `off` on LAN/low latency (default).
+- `-rhz` / `-acy` help when public resolvers drop or rate-limit.
-```bash
-subfinder -d example.com | shuffledns -d example.com -r resolvers.txt -mode resolve
-```
+Live QPS is roughly `min(-t / RTT, -qps, resolver capacity)`. For loopback and public-resolver head-to-heads see [`bench/`](bench/).
-This uses the subdomains found passively by `subfinder` and resolves them with `shuffledns` returning only the unique and valid subdomains.
+# Handling Wildcards
-**Subdomain Bruteforcing**
+`shuffledns` tracks how many names map to each IP. Past a small threshold it walks hostname labels for that IP and filters wildcard answers with few extra DNS requests. Wildcard filtering requires `-d` / domain input.
-`shuffledns` also supports bruteforce of a target with a given wordlist. You can use the `w` flag to pass a wordlist which will be used to generate permutations that will be resolved using massdns.
+# massdns parity
-```bash
-shuffledns -d hackerone.com -w wordlist.txt -r resolvers.txt -mode bruteforce
-```
+`go run ./cmd/resolve` aims to be a **massdns CLI drop-in** for common stub workloads:
+
+| Area | Status |
+|---|---|
+| Stub resolve (`-s/-c/-i/-t/-r`, sticky, norecurse, verify-ip, extended-input, socket-count) | Yes |
+| Output `-o` S / F / L / J / **B** (+ modifiers) | Yes (`pkg/output`) |
+| `--bindto`, `--rcvbuf`, `--sndbuf`, `--predictable`, `--flush`, `--filter/--ignore/--retry` | Yes |
+| `--status-format`, `-q`, `-l` | Yes |
+| PTR / validate / AXFR / NSEC(3) / iterative / shard / resume | Yes (native extras) |
+| `--drop-user` / `--drop-group` / `--root` | Yes (Unix; after sockets open) |
+| `--rand-src-ipv6` / `--rand-src-ipv6-file` | Yes (Linux + `CAP_NET_RAW`; IPv6 resolvers; not with `--bindto`) |
+| `--processes`, `--busy-poll` | Accepted, **ignored** |
-This will run the tool against `hackerone.com` with the wordlist `wordlist.txt`. The domain bruteforce can also be done with standard input as in previous example for resolving the subdomains.
+**shuffledns** itself is not a massdns replacement (hostname list + wildcards). Use `cmd/resolve` when you need massdns-compatible output and flags.
+
+Example:
```bash
-echo hackerone.com | shuffledns -w wordlist.txt -r resolvers.txt -mode bruteforce
+go run ./cmd/resolve -r resolvers.txt -t AAAA -o Snl -w out.txt names.txt
+# same shape as: massdns -r resolvers.txt -t AAAA -o Snl -w out.txt names.txt
```
----
-
-
-
-|
+# Throughput
-## Handling Wildcards
+Local loopback bench (`RESOLVE_BENCH=1 go test ./pkg/resolve -run TestResolverBenchmark`, 50k names, 8 simulated resolvers, `-t 10000`):
-A special feature of `shuffleDNS` is its ability to handle multi-level DNS based wildcards, and do it so with a very reduced number of DNS requests. Sometimes all the subdomains would resolve, leading to lots of garbage in the results. The way `shuffleDNS` handles this is by keeping track of how many subdomains point to an IP, and if the number of subdomains increase beyond a certain small threshold, it checks for wildcard on all the levels of the hosts for that IP iteratively.
+| scenario | ~qps |
+|---|---|
+| lan-fast (~0.5ms) | ~90k |
+| wan-typical (15±10ms, 0.5% loss) | ~40k |
+| wan-lossy (25±20ms, 5% loss) | ~27k |
+| rate-limited (3k qps/resolver) | ~40k |
- |
-
-
+Compare against the massdns binary with [`bench/`](bench/) (Docker). Numbers are workload- and resolver-bound; public resolver lists will land closer to the wan/rate-limited rows than lan-fast.
-### Notes
+# Notes
-- Wildcard filter feature works with domain (`-d`) input only.
-- Resolving or Brute-forcing only one operation can be done at a time.
+- Resolving and bruteforcing are separate modes (`-mode`).
+- `-m` / `-mcmd` / `-retain-stderr` / `-batch-size` are accepted for compatibility and ignored.
+- README usage dump may lag slightly behind `-h` as flags evolve.
### License
-`shuffledns` is distributed under [GPL v3 License](https://github.com/projectdiscovery/shuffledns/blob/main/LICENSE.md)
+`shuffledns` is distributed under [GPL v3 License](https://github.com/projectdiscovery/shuffledns/blob/main/LICENSE.md).
diff --git a/bench/Dockerfile b/bench/Dockerfile
new file mode 100644
index 00000000..78168a9a
--- /dev/null
+++ b/bench/Dockerfile
@@ -0,0 +1,35 @@
+# Benchmark image: builds massdns from source and the dnsbench harness, then
+# benchmarks the native resolver against massdns using a battery of loopback
+# resolvers. No DNS traffic leaves the container (resolvers are 127.0.0.1).
+#
+# Build (context must be the repository root):
+# docker build -f bench/Dockerfile -t shuffledns-dnsbench .
+#
+# Run:
+# docker run --rm shuffledns-dnsbench -names 200000 -resolvers 16 -hit 5
+#
+# The container needs no network at runtime; everything is loopback.
+
+FROM golang:1.24-bookworm AS build
+
+# --- build massdns from source -------------------------------------------------
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends git ca-certificates gcc make libc6-dev \
+ && rm -rf /var/lib/apt/lists/*
+RUN git clone --depth 1 https://github.com/blechschmidt/massdns.git /opt/massdns \
+ && make -C /opt/massdns
+
+# --- build the dnsbench harness ------------------------------------------------
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -o /usr/local/bin/dnsbench ./cmd/dnsbench
+
+# --- runtime image -------------------------------------------------------------
+FROM debian:bookworm-slim
+COPY --from=build /opt/massdns/bin/massdns /usr/local/bin/massdns
+COPY --from=build /usr/local/bin/dnsbench /usr/local/bin/dnsbench
+
+ENTRYPOINT ["/usr/local/bin/dnsbench", "-massdns", "/usr/local/bin/massdns"]
+CMD ["-names", "100000", "-resolvers", "8", "-hit", "5"]
diff --git a/bench/Dockerfile.iterbench b/bench/Dockerfile.iterbench
new file mode 100644
index 00000000..6b55f808
--- /dev/null
+++ b/bench/Dockerfile.iterbench
@@ -0,0 +1,25 @@
+# Offline iterative-vs-stub accuracy/throughput benchmark. Builds the iterbench
+# harness and runs it entirely on loopback: a real-socket authoritative
+# hierarchy (127.0.0.x) for the iterative engine and a simulated flaky
+# recursive-resolver battery (127.0.0.1) for the stub engine. No DNS traffic
+# leaves the container.
+#
+# Build (context must be the repository root):
+# docker build -f bench/Dockerfile.iterbench -t shuffledns-iterbench .
+#
+# Run:
+# docker run --rm shuffledns-iterbench -domains 200 -hosts 250 -hijack 0.15
+#
+# Needs Linux loopback (127.0.0.x); requires no network at runtime.
+
+FROM golang:1.24-bookworm AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -o /usr/local/bin/iterbench ./cmd/iterbench
+
+FROM debian:bookworm-slim
+COPY --from=build /usr/local/bin/iterbench /usr/local/bin/iterbench
+ENTRYPOINT ["/usr/local/bin/iterbench"]
+CMD ["-domains", "200", "-hosts", "250", "-hijack", "0.15"]
diff --git a/bench/README.md b/bench/README.md
new file mode 100644
index 00000000..870cd410
--- /dev/null
+++ b/bench/README.md
@@ -0,0 +1,193 @@
+# DNS resolver benchmark
+
+Benchmarks the native Go resolver (`pkg/resolve`) against the external
+`massdns` binary using a battery of **loopback** DNS servers that simulate
+remote recursive resolvers (`internal/simdns`).
+
+No DNS traffic ever leaves the host: every resolver an engine talks to is a
+`127.0.0.1:` UDP server started in-process. The simulated resolvers
+model real remote conditions:
+
+- response latency + jitter
+- packet loss (exercises the timeout/retransmit path)
+- SERVFAIL rate (exercises retry-on-rotation)
+- per-resolver QPS cap (models throttling public resolvers)
+- a deterministic, mostly-NXDOMAIN hit ratio (realistic bruteforce workload)
+
+The answered/NXDOMAIN decision and synthetic A record are deterministic per
+name, so both engines resolve the exact same workload under identical
+conditions.
+
+## Scenarios
+
+| scenario | latency | loss | servfail | per-resolver QPS |
+|---------------|--------------|------|----------|------------------|
+| `lan-fast` | ~0.5 ms | - | - | unlimited |
+| `wan-typical` | 15 ± 10 ms | 0.5% | - | unlimited |
+| `wan-lossy` | 25 ± 20 ms | 5% | 2% | unlimited |
+| `rate-limited`| 10 ± 10 ms | - | - | 3000 / resolver |
+
+## Running with Docker (recommended)
+
+`massdns` is Linux-only (epoll), so the benchmark runs in a container that
+builds massdns from source. The build context must be the repository root:
+
+```bash
+docker build -f bench/Dockerfile -t shuffledns-dnsbench .
+docker run --rm shuffledns-dnsbench -names 200000 -resolvers 16 -hit 5
+```
+
+Useful flags (`dnsbench -h`):
+
+- `-names` names to resolve per scenario (default 50000)
+- `-resolvers` number of simulated loopback resolvers (default 8)
+- `-hit` percent of names that resolve, rest NXDOMAIN (default 5)
+- `-concurrency` in-flight concurrency / massdns hashmap size (default 10000)
+- `-retries` retry budget per name, applied to both engines (default 5)
+- `-sockets` native udp socket count (0 = scale to cores)
+- `-batch-mode` native batching: `off` (default) | `on` | `adaptive`
+- `-engines` `native,massdns` (default both)
+- `-scenarios` comma-separated scenario names or `all`
+
+### A note on `-batch-mode` (sendmmsg/recvmmsg)
+
+The native resolver can batch datagrams per syscall using Linux
+`sendmmsg`/`recvmmsg` (via `golang.org/x/net/ipv4`, IPv4-only resolver sets).
+Batching is workload-dependent, so there are three modes:
+
+- `off` (default): always one datagram per syscall — the proven, lowest-latency
+ path. Best on loopback / low-RTT links.
+- `on`: always batch. Helps on **high-latency / bursty** links, where many
+ in-flight responses arrive clustered in time and fill batches, amortizing the
+ syscall cost. **Counterproductive on loopback / low-RTT** links: batches stay
+ tiny (1–2 datagrams), so the per-call message-array setup costs more than a
+ plain `sendto`/`recvfrom`, and it adds first-response latency.
+- `adaptive`: start in single mode and let a lightweight controller toggle
+ batching at runtime based on observed conditions (see below).
+
+On this loopback benchmark you can see both effects: `-batch-mode on` roughly
+halves `lan-fast` throughput but modestly improves the higher-latency scenarios,
+while `-batch-mode adaptive` stays near single-path performance on `lan-fast`
+and ramps batching up only on the latency-/loss-bound scenarios.
+
+#### How `adaptive` decides
+
+A controller goroutine samples every 200ms and toggles batching with hysteresis:
+
+- **Smoothed RTT** (EWMA of observed round-trip times): batching needs latency
+ for packets to cluster. Engage above ~3ms, disengage below ~1ms.
+- **In-flight depth** (`len(sem)`): batches can only fill if the pipeline is
+ deep. Engage only when depth ≥ 2×batch size; disengage when it falls below a
+ batch.
+- **Interval packet loss** (retransmits ÷ sends per tick): rising loss often
+ means a resolver or kernel buffer is already saturated, and bursty `sendmmsg`
+ makes that worse — so loss above ~15% forces batching **off** to spread sends
+ out.
+
+Toggling only changes the send/recv strategy; results are identical either way.
+When batching is disengaged the send path uses plain `sendto` (it also falls
+back to single sends for batches smaller than 4 datagrams) and the read path
+uses single `recvfrom`, so "adaptive-off" costs the same as the default path.
+
+Cross-platform behaviour of the batch API (`ReadBatch`/`WriteBatch`):
+
+- **Linux**: real `recvmmsg`/`sendmmsg` (batched).
+- **macOS, Windows, *BSD, others**: the same calls transparently fall back to a
+ single `recvmsg`/`sendmsg` per call, so the code is portable and correct
+ everywhere; only Linux gets the kernel batch syscalls.
+
+### Read-path parallelism (the client-side equivalent of `SO_REUSEPORT`)
+
+`SO_REUSEPORT` is a *server* mechanism for sharing one well-known port across
+sockets so the kernel fans incoming packets out to multiple readers. A stub
+resolver is a *client*: it already opens N independent sockets on distinct
+ephemeral ports, each drained by its own reader goroutine, which delivers the
+same per-core receive parallelism without a shared in-flight map or port
+juggling. The socket pool therefore scales with `GOMAXPROCS` by default
+(`-sockets 0`); binding those sockets to a single port via `SO_REUSEPORT` would
+yield identical queue/reader counts with no throughput gain, so it is
+deliberately not used.
+
+Both engines are given matching timeout/interval and retry budgets so the
+comparison is fair; the exact massdns flags used are documented in
+`cmd/dnsbench/main.go`.
+
+## Native-only (no Docker)
+
+The native engine is pure Go and runs anywhere:
+
+```bash
+go run ./cmd/dnsbench -engines native -names 200000 -resolvers 16
+```
+
+The same battery is also wired into a Go test, which can toggle the adaptive
+resolver features so you can see their effect per scenario:
+
+```bash
+# baseline
+RESOLVE_BENCH=1 go test ./pkg/resolve -run TestResolverBenchmark -v \
+ -bench.names 100000 -bench.resolvers 16
+
+# adaptive batching (helps deep/high-latency pipelines)
+RESOLVE_BENCH=1 go test ./pkg/resolve -run TestResolverBenchmark -v \
+ -bench.names 100000 -bench.resolvers 16 -bench.batch adaptive
+
+# health scoring + adaptive concurrency (helps lossy / rate-limited resolvers)
+RESOLVE_BENCH=1 go test ./pkg/resolve -run TestResolverBenchmark -v \
+ -bench.names 100000 -bench.resolvers 16 -bench.health -bench.adaptconc
+```
+
+## Resolver capabilities (parity with / beyond massdns)
+
+Core massdns stub features plus several massdns TODOs. Main shuffledns flags:
+
+| Capability | shuffledns flag | Notes |
+|---|---|---|
+| Record type | `-rt A\|AAAA\|CNAME\|NS\|PTR\|MX\|TXT\|SOA` | massdns `-t`; bruteforce store is A/AAAA-oriented |
+| In-flight cap | `-t` | massdns `-s` |
+| Send-rate cap | `-qps` | token bucket; 0 = unlimited |
+| Non-recursive | `-norecurse` | massdns `--norecurse` (RD=0) |
+| Sticky resolver | `-sticky` | massdns `--sticky` |
+| EDNS0 UDP size | `-udp-size N` | OPT advertised payload (default 1232) |
+| Source-IP verify | on by default, `-no-verify-ip` to disable | massdns `--verify-ip` (opt-out here) |
+| TCP fallback | on by default, `-no-tcp-fallback` to disable | follows truncated (TC) answers |
+| Batching | `-bm off\|on\|adaptive` | sendmmsg/recvmmsg (Linux) |
+| Socket count | `-sc N` | massdns `--socket-count` |
+| Per-resolver health | `-rhz` | de-weights failing resolvers (massdns TODO) |
+| Adaptive concurrency | `-acy` | AIMD in-flight cap on loss (massdns TODO) |
+| Cross-resolver check | `-cc` | poisoning/spam detection (massdns TODO) |
+| Extended input | `-ei` | `name [resolver ...]` lines (massdns `--extended-input`) |
+| Iterative from roots | `-it` | no public resolver list |
+
+### Privilege drop & raw IPv6 source (cmd/resolve)
+
+| Feature | Flags | Notes |
+|---|---|---|
+| Drop root after open | `--drop-user` / `--drop-group` / `--root` | Unix; defaults to `nobody` when euid=0 unless `--root` |
+| Random IPv6 source | `--rand-src-ipv6 PREFIX` / `--rand-src-ipv6-file` | Linux `SOCK_RAW` + `IPV6_HDRINCL`; needs `CAP_NET_RAW`; IPv6 resolvers only; incompatible with `--bindto` |
+
+### Intentionally ignored (accepted for CLI drop-in)
+
+`--processes`, `--busy-poll`.
+
+`-o B`, `--bindto`, `--rcvbuf`/`--sndbuf`, `--predictable`, `--flush` are
+implemented on `cmd/resolve`. Full `-o` formatting lives there, not in the
+shuffledns hostname-list output.
+
+### QPS notes
+
+- Effective throughput ≈ `min(-t / RTT, -qps, Σ resolver capacity)`.
+- Default `-bm off` is correct for low-RTT; forced `on` often hurts lan-fast.
+- Public-resolver runs are usually RTT/rate-limit bound, not CPU bound.
+
+Example native-only numbers (50k names, 8 sim resolvers, `-t 10000`):
+
+| scenario | ~qps |
+|---|---|
+| lan-fast | ~90k |
+| wan-typical | ~40k |
+| wan-lossy | ~27k |
+| rate-limited (3k/resolver) | ~40k |
+
+Retry rcode policy matches massdns (retry everything except NOERROR / NXDOMAIN).
+Progress is available via `OnProgress` / `Stats` on the resolver client.
diff --git a/cmd/dnsbench/main.go b/cmd/dnsbench/main.go
new file mode 100644
index 00000000..1ae1343f
--- /dev/null
+++ b/cmd/dnsbench/main.go
@@ -0,0 +1,383 @@
+// Command dnsbench benchmarks DNS resolution engines against a battery of
+// loopback resolvers that simulate remote ones (see internal/simdns). It can
+// drive both the native resolver and the external massdns binary against the
+// exact same simulated conditions, so the comparison is apples-to-apples.
+//
+// No DNS traffic leaves the host: every resolver the engines talk to is a
+// 127.0.0.1: UDP server started in-process. massdns is Linux-only
+// (epoll), so this is intended to run inside the provided Docker image; the
+// native engine runs anywhere.
+//
+// Example (inside the container):
+//
+// dnsbench -massdns /opt/massdns/bin/massdns -names 200000 -resolvers 16 -hit 5
+package main
+
+import (
+ "bufio"
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/internal/simdns"
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+)
+
+var (
+ names = flag.Int("names", 50000, "number of names to resolve per scenario")
+ resolvers = flag.Int("resolvers", 8, "number of simulated loopback resolvers")
+ hit = flag.Int("hit", 5, "percentage of names that resolve (rest NXDOMAIN)")
+ concurrency = flag.Int("concurrency", 10000, "in-flight concurrency / massdns hashmap size")
+ retries = flag.Int("retries", 5, "retry budget per name (both engines)")
+ sockets = flag.Int("sockets", 0, "native resolver udp socket count (0 = scale to cores)")
+ batchMode = flag.String("batch-mode", "off", "native batching: off | on | adaptive (sendmmsg/recvmmsg, Linux)")
+ massdnsPath = flag.String("massdns", "", "path to the massdns binary (empty = skip massdns)")
+ engines = flag.String("engines", "native,massdns", "comma-separated engines to run")
+ scenarios = flag.String("scenarios", "all", "comma-separated scenario names or 'all'")
+ keepFiles = flag.Bool("keep", false, "keep generated names/resolvers files")
+)
+
+type scenario struct {
+ name string
+ cfg simdns.Config
+}
+
+func parseBatchMode(s string) resolve.BatchMode {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "on", "enabled", "true":
+ return resolve.BatchEnabled
+ case "adaptive", "auto":
+ return resolve.BatchAdaptive
+ default:
+ return resolve.BatchDisabled
+ }
+}
+
+func allScenarios(hitPct int) []scenario {
+ return []scenario{
+ {"lan-fast", simdns.Config{BaseLatency: 200 * time.Microsecond, Jitter: 300 * time.Microsecond, HitPercent: hitPct}},
+ {"wan-typical", simdns.Config{BaseLatency: 15 * time.Millisecond, Jitter: 10 * time.Millisecond, LossRate: 0.005, HitPercent: hitPct}},
+ {"wan-lossy", simdns.Config{BaseLatency: 25 * time.Millisecond, Jitter: 20 * time.Millisecond, LossRate: 0.05, ServfailRate: 0.02, HitPercent: hitPct}},
+ {"rate-limited", simdns.Config{BaseLatency: 10 * time.Millisecond, Jitter: 10 * time.Millisecond, QPSPerServer: 3000, HitPercent: hitPct}},
+ }
+}
+
+type result struct {
+ engine string
+ ttfr time.Duration
+ wall time.Duration
+ qps float64
+ resolved int64
+}
+
+func main() {
+ flag.Parse()
+
+ selectedEngines := splitCSV(*engines)
+ wantMassdns := contains(selectedEngines, "massdns")
+ wantNative := contains(selectedEngines, "native")
+
+ if wantMassdns && *massdnsPath == "" {
+ // try to find it on PATH
+ if p, err := exec.LookPath("massdns"); err == nil {
+ *massdnsPath = p
+ } else {
+ fmt.Fprintln(os.Stderr, "massdns requested but -massdns not set and not found on PATH; skipping massdns")
+ wantMassdns = false
+ }
+ }
+
+ scns := filterScenarios(allScenarios(*hit), *scenarios)
+ if len(scns) == 0 {
+ fmt.Fprintln(os.Stderr, "no scenarios selected")
+ os.Exit(1)
+ }
+
+ // write the shared names file once
+ namesFile, err := writeNamesFile(*names)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "could not write names file: %v\n", err)
+ os.Exit(1)
+ }
+ if !*keepFiles {
+ defer func() { _ = os.Remove(namesFile) }()
+ }
+
+ fmt.Printf("dnsbench: names=%d resolvers=%d hit=%d%% concurrency=%d retries=%d\n",
+ *names, *resolvers, *hit, *concurrency, *retries)
+ fmt.Printf("no traffic leaves the host; resolvers are loopback (127.0.0.1)\n\n")
+ fmt.Printf("%-14s %-9s %10s %10s %12s %10s\n", "scenario", "engine", "ttfr", "wall", "qps", "resolved")
+ fmt.Printf("%s\n", strings.Repeat("-", 70))
+
+ for _, sc := range scns {
+ var rows []result
+ if wantNative {
+ r, err := runNative(sc, namesFile)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "native %s failed: %v\n", sc.name, err)
+ } else {
+ rows = append(rows, r)
+ }
+ }
+ if wantMassdns {
+ r, err := runMassdns(sc, namesFile)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "massdns %s failed: %v\n", sc.name, err)
+ } else {
+ rows = append(rows, r)
+ }
+ }
+ for _, r := range rows {
+ fmt.Printf("%-14s %-9s %10s %10s %12.0f %10d\n",
+ sc.name, r.engine,
+ r.ttfr.Round(100*time.Microsecond),
+ r.wall.Round(time.Millisecond),
+ r.qps, r.resolved)
+ }
+ fmt.Printf("%s\n", strings.Repeat("-", 70))
+ }
+}
+
+// perAttemptTimeout sizes a timeout/interval to a few RTTs so lossy scenarios
+// complete via retransmission instead of stalling. Both engines use it.
+func perAttemptTimeout(cfg simdns.Config) time.Duration {
+ rtt := cfg.BaseLatency + cfg.Jitter
+ t := 6 * rtt
+ if t < 500*time.Millisecond {
+ t = 500 * time.Millisecond
+ }
+ return t
+}
+
+func runNative(sc scenario, namesFile string) (result, error) {
+ battery, err := simdns.Start(*resolvers, sc.cfg)
+ if err != nil {
+ return result{}, err
+ }
+ defer battery.Stop()
+
+ timeout := perAttemptTimeout(sc.cfg)
+
+ // pre-load names into memory (outside the timed region) so the producer is
+ // never the bottleneck; we want to measure the resolver, not the scanner.
+ nameList, err := loadNames(namesFile)
+ if err != nil {
+ return result{}, err
+ }
+
+ var resolved atomic.Int64
+ var ttfrNanos atomic.Int64
+ start := time.Now()
+
+ client, err := resolve.New(resolve.Options{
+ Resolvers: battery.Addrs,
+ Concurrency: *concurrency,
+ SocketCount: *sockets,
+ Batch: parseBatchMode(*batchMode),
+ Timeout: timeout,
+ MaxRetries: *retries,
+ OnResult: func(r resolve.Result) {
+ ttfrNanos.CompareAndSwap(0, int64(time.Since(start)))
+ if r.Rcode == dns.RcodeSuccess && len(r.A) > 0 {
+ resolved.Add(1)
+ }
+ },
+ })
+ if err != nil {
+ return result{}, err
+ }
+ defer client.Close()
+
+ input := make(chan string, 8192)
+ go func() {
+ defer close(input)
+ for _, n := range nameList {
+ input <- n
+ }
+ }()
+
+ if err := client.Run(context.Background(), input); err != nil {
+ return result{}, err
+ }
+ wall := time.Since(start)
+
+ printServerStats("native", sc.name, battery.Stats)
+ return result{
+ engine: "native",
+ ttfr: time.Duration(ttfrNanos.Load()),
+ wall: wall,
+ qps: float64(*names) / wall.Seconds(),
+ resolved: resolved.Load(),
+ }, nil
+}
+
+func runMassdns(sc scenario, namesFile string) (result, error) {
+ battery, err := simdns.Start(*resolvers, sc.cfg)
+ if err != nil {
+ return result{}, err
+ }
+ defer battery.Stop()
+
+ resolversFile, err := writeResolversFile(battery.Addrs)
+ if err != nil {
+ return result{}, err
+ }
+ if !*keepFiles {
+ defer func() { _ = os.Remove(resolversFile) }()
+ }
+
+ timeout := perAttemptTimeout(sc.cfg)
+ intervalMs := int(timeout / time.Millisecond)
+
+ // -o S: simple output (answer RRs only), one line per record.
+ // -s : hashmap size / concurrent lookups.
+ // -i : retransmit interval for a name (match native per-attempt timeout).
+ // -c : resolve attempts before giving up (match native retry budget).
+ args := []string{
+ "-r", resolversFile,
+ "-t", "A",
+ "-o", "S",
+ "-s", fmt.Sprintf("%d", *concurrency),
+ "-i", fmt.Sprintf("%d", intervalMs),
+ "-c", fmt.Sprintf("%d", *retries),
+ "--flush",
+ namesFile,
+ }
+
+ cmd := exec.Command(*massdnsPath, args...)
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return result{}, err
+ }
+ cmd.Stderr = nil // discard massdns progress/error log
+
+ start := time.Now()
+ if err := cmd.Start(); err != nil {
+ return result{}, err
+ }
+
+ var ttfr time.Duration
+ var resolved int64
+ scanner := bufio.NewScanner(stdout)
+ scanner.Buffer(make([]byte, 0, 1024*1024), 4*1024*1024)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if line == "" {
+ continue
+ }
+ if ttfr == 0 {
+ ttfr = time.Since(start)
+ }
+ // simple-format A record lines look like: "name. A 10.1.2.3"
+ if strings.Contains(line, " A ") {
+ resolved++
+ }
+ }
+ if err := cmd.Wait(); err != nil {
+ return result{}, fmt.Errorf("massdns exited: %w", err)
+ }
+ wall := time.Since(start)
+
+ printServerStats("massdns", sc.name, battery.Stats)
+ return result{
+ engine: "massdns",
+ ttfr: ttfr,
+ wall: wall,
+ qps: float64(*names) / wall.Seconds(),
+ resolved: resolved,
+ }, nil
+}
+
+func printServerStats(engine, scenario string, s *simdns.Stats) {
+ fmt.Fprintf(os.Stderr, " [%s/%s] server-side queries=%d answered=%d dropped=%d servfail=%d ratelimited=%d\n",
+ engine, scenario, s.Queries.Load(), s.Answered.Load(), s.Dropped.Load(),
+ s.Servfail.Load(), s.RateLimited.Load())
+}
+
+func loadNames(path string) ([]string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = f.Close() }()
+ var names []string
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ if n := strings.TrimSpace(scanner.Text()); n != "" {
+ names = append(names, n)
+ }
+ }
+ return names, scanner.Err()
+}
+
+func writeNamesFile(n int) (string, error) {
+ f, err := os.CreateTemp("", "dnsbench-names-*.txt")
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = f.Close() }()
+ w := bufio.NewWriter(f)
+ for i := 0; i < n; i++ {
+ if _, err := fmt.Fprintf(w, "host%d.bench.example.com\n", i); err != nil {
+ return "", err
+ }
+ }
+ return f.Name(), w.Flush()
+}
+
+func writeResolversFile(addrs []string) (string, error) {
+ f, err := os.CreateTemp("", "dnsbench-resolvers-*.txt")
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = f.Close() }()
+ w := bufio.NewWriter(f)
+ for _, a := range addrs {
+ if _, err := fmt.Fprintln(w, a); err != nil {
+ return "", err
+ }
+ }
+ return f.Name(), w.Flush()
+}
+
+func splitCSV(s string) []string {
+ parts := strings.Split(s, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func contains(haystack []string, needle string) bool {
+ for _, h := range haystack {
+ if strings.EqualFold(h, needle) {
+ return true
+ }
+ }
+ return false
+}
+
+func filterScenarios(all []scenario, sel string) []scenario {
+ if strings.TrimSpace(sel) == "" || strings.EqualFold(strings.TrimSpace(sel), "all") {
+ return all
+ }
+ want := splitCSV(sel)
+ var out []scenario
+ for _, s := range all {
+ if contains(want, s.name) {
+ out = append(out, s)
+ }
+ }
+ return out
+}
diff --git a/cmd/iterbench/main.go b/cmd/iterbench/main.go
new file mode 100644
index 00000000..20fa6521
--- /dev/null
+++ b/cmd/iterbench/main.go
@@ -0,0 +1,171 @@
+// Command iterbench is a fully-local, offline benchmark that compares the
+// iterative-from-root resolver against the stub resolver on the SAME workload,
+// measuring both throughput and accuracy (false negatives). No packet leaves
+// the host:
+//
+// - the iterative engine resolves against a real-socket authoritative
+// hierarchy (internal/authsim) bound to 127.0.0.x loopback addresses;
+// - the stub engine resolves against a battery of simulated recursive
+// resolvers (internal/simdns) modelling real-world public-resolver
+// conditions (latency, loss, rate-limiting and, crucially, rate-limit /
+// hijack induced false NXDOMAIN — the massdns #117 failure mode).
+//
+// Both layers answer identical synthetic IPs, so any name the stub fails to
+// resolve while the iterative engine succeeds is a measured false negative
+// attributable to the public-resolver dependency that iterative removes.
+//
+// Must run on Linux (binding 127.0.0.x). The repo Dockerfile builds it.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "sync/atomic"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/internal/authsim"
+ "github.com/projectdiscovery/shuffledns/internal/simdns"
+ "github.com/projectdiscovery/shuffledns/pkg/iterative"
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+)
+
+func main() {
+ domains := flag.Int("domains", 200, "registrable domains under .com")
+ hosts := flag.Int("hosts", 250, "hostnames per domain")
+ port := flag.Int("port", 5354, "shared UDP port for the authoritative hierarchy")
+ iterWorkers := flag.Int("iter-workers", 256, "iterative resolver worker count")
+ stubConc := flag.Int("stub-concurrency", 10000, "stub resolver in-flight concurrency")
+ recursors := flag.Int("recursors", 16, "simulated recursive resolvers for the stub")
+ retries := flag.Int("retries", 5, "retry budget (both engines)")
+ hijack := flag.Float64("hijack", 0.15, "fraction of existing names a recursor falsely answers NXDOMAIN (massdns #117)")
+ loss := flag.Float64("loss", 0.01, "recursor packet loss rate")
+ servfail := flag.Float64("servfail", 0.01, "recursor SERVFAIL rate")
+ flag.Parse()
+
+ fmt.Printf("iterbench: domains=%d hosts/domain=%d names=%d\n", *domains, *hosts, *domains*(*hosts))
+ fmt.Printf("no traffic leaves the host; authoritative tree on 127.0.0.x:%d, recursors on 127.0.0.1\n\n", *port)
+
+ // ----- authoritative hierarchy (reliable, for iterative) -----
+ hier, err := authsim.Build(*domains, *hosts, *port)
+ if err != nil {
+ fatal("could not start authoritative hierarchy: %v", err)
+ }
+ defer hier.Stop()
+ names := hier.Names
+ total := len(names)
+
+ // ----- simulated recursive resolvers (flaky, for stub) -----
+ battery, err := simdns.Start(*recursors, simdns.Config{
+ BaseLatency: 1 * time.Millisecond,
+ Jitter: 2 * time.Millisecond,
+ LossRate: *loss,
+ ServfailRate: *servfail,
+ HijackRate: *hijack,
+ HitPercent: 100, // every name exists; any miss is a false negative
+ })
+ if err != nil {
+ fatal("could not start recursor battery: %v", err)
+ }
+ defer battery.Stop()
+
+ fmt.Printf("%-12s %-10s %-10s %-12s %-10s %-12s\n", "engine", "ttfr", "wall", "qps", "resolved", "missed(FN)")
+ fmt.Println("--------------------------------------------------------------------------")
+
+ // ----- iterative engine -----
+ itResolved, itTTFR, itWall := runIterative(hier, names, *iterWorkers, *retries)
+ report("iterative", itTTFR, itWall, total, itResolved)
+ fmt.Printf(" authoritative queries=%d queries/name=%.3f\n", hier.Queries.Load(), float64(hier.Queries.Load())/float64(total))
+
+ // ----- stub engine -----
+ stResolved, stTTFR, stWall := runStub(battery.Addrs, names, *stubConc, *retries)
+ report("stub", stTTFR, stWall, total, stResolved)
+ fmt.Printf(" recursor hijacked(false NXDOMAIN)=%d servfail=%d dropped=%d ratelimited=%d\n",
+ battery.Stats.Hijacked.Load(), battery.Stats.Servfail.Load(), battery.Stats.Dropped.Load(), battery.Stats.RateLimited.Load())
+
+ fmt.Println("\nAccuracy delta (the point):")
+ fmt.Printf(" iterative missed %d/%d (%.2f%%)\n", total-int(itResolved), total, 100*float64(total-int(itResolved))/float64(total))
+ fmt.Printf(" stub missed %d/%d (%.2f%%) <- false negatives from flaky/lying recursors\n",
+ total-int(stResolved), total, 100*float64(total-int(stResolved))/float64(total))
+}
+
+func runIterative(hier *authsim.Hierarchy, names []string, workers, retries int) (int64, time.Duration, time.Duration) {
+ r, err := iterative.New(iterative.Options{
+ RootServers: []string{hier.RootAddr},
+ QueryType: dns.TypeA,
+ Concurrency: workers,
+ Retries: retries,
+ Timeout: 3 * time.Second,
+ })
+ if err != nil {
+ fatal("could not create iterative resolver: %v", err)
+ }
+ in := feed(names)
+ var resolved, ttfrSet atomic.Int64
+ var ttfr time.Duration
+ start := time.Now()
+ _ = r.ResolveStream(context.Background(), in, iterative.StreamConfig{
+ QueryType: dns.TypeA,
+ OnResult: func(res *resolve.Result) {
+ if res.Rcode == dns.RcodeSuccess && len(res.A) > 0 {
+ if ttfrSet.CompareAndSwap(0, 1) {
+ ttfr = time.Since(start)
+ }
+ resolved.Add(1)
+ }
+ },
+ })
+ return resolved.Load(), ttfr, time.Since(start)
+}
+
+func runStub(resolvers, names []string, concurrency, retries int) (int64, time.Duration, time.Duration) {
+ var resolved, ttfrSet atomic.Int64
+ var ttfr time.Duration
+ start := time.Now()
+ client, err := resolve.New(resolve.Options{
+ Resolvers: resolvers,
+ QueryType: dns.TypeA,
+ Concurrency: concurrency,
+ MaxRetries: retries,
+ Timeout: 2 * time.Second,
+ OnResult: func(r resolve.Result) {
+ if r.Rcode == dns.RcodeSuccess && len(r.A) > 0 {
+ if ttfrSet.CompareAndSwap(0, 1) {
+ ttfr = time.Since(start)
+ }
+ resolved.Add(1)
+ }
+ },
+ })
+ if err != nil {
+ fatal("could not create stub resolver: %v", err)
+ }
+ defer client.Close()
+ _ = client.Run(context.Background(), feed(names))
+ return resolved.Load(), ttfr, time.Since(start)
+}
+
+func feed(names []string) <-chan string {
+ ch := make(chan string, 8192)
+ go func() {
+ defer close(ch)
+ for _, n := range names {
+ ch <- n
+ }
+ }()
+ return ch
+}
+
+func report(engine string, ttfr, wall time.Duration, total int, resolved int64) {
+ qps := float64(total) / wall.Seconds()
+ missed := total - int(resolved)
+ fmt.Printf("%-12s %-10s %-10s %-12.0f %-10d %-12d\n",
+ engine, ttfr.Round(time.Microsecond), wall.Round(time.Millisecond), qps, resolved, missed)
+}
+
+func fatal(format string, args ...interface{}) {
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/cmd/resolve/flags.go b/cmd/resolve/flags.go
new file mode 100644
index 00000000..ca4bb90f
--- /dev/null
+++ b/cmd/resolve/flags.go
@@ -0,0 +1,147 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+// parseFlags accepts the massdns CLI surface (plus native extensions). Flags that
+// only applied to the C binary's multi-process / epoll busy-poll model are
+// accepted and ignored; raw IPv6 source spoofing and privilege drop are wired.
+func parseFlags() config {
+ var cfg config
+ var ptrList string
+ var intervalMs int
+ var retryCSV string
+ var ignoreBusyPoll bool
+ var processes int
+
+ fs := flag.NewFlagSet("resolve", flag.ContinueOnError)
+ fs.SetOutput(os.Stderr)
+ fs.Usage = func() {
+ fmt.Fprintf(os.Stderr, `resolve — native massdns-compatible bulk DNS resolver
+
+Usage: resolve [options] [domainlist]
+
+Massdns-compatible flags are accepted. --processes and --busy-poll are parsed
+and ignored. Native extensions (iterative, ptr, validate, zonewalk, ...) are
+documented below.
+
+`)
+ fs.PrintDefaults()
+ }
+
+ // Core massdns flags (short + long aliases where Go's flag package allows).
+ fs.StringVar(&cfg.resolversFile, "r", "", "file with resolver IPs (massdns -r/--resolvers)")
+ fs.StringVar(&cfg.resolversFile, "resolvers", "", "alias of -r")
+ fs.StringVar(&cfg.qtype, "t", "A", "record type (massdns -t/--type)")
+ fs.StringVar(&cfg.qtype, "type", "A", "alias of -t")
+ fs.StringVar(&cfg.format, "o", "F", "output flags (massdns -o): S/F/L/J/B + modifiers")
+ fs.StringVar(&cfg.format, "output", "F", "alias of -o")
+ fs.StringVar(&cfg.outFile, "w", "", "write output to file (massdns -w/--outfile)")
+ fs.StringVar(&cfg.outFile, "outfile", "", "alias of -w")
+ fs.IntVar(&cfg.concurrency, "s", 10000, "concurrent lookups (massdns -s/--hashmap-size)")
+ fs.IntVar(&cfg.concurrency, "hashmap-size", 10000, "alias of -s")
+ fs.IntVar(&cfg.retries, "c", 50, "resolve attempts before giving up (massdns -c/--resolve-count)")
+ fs.IntVar(&cfg.retries, "resolve-count", 50, "alias of -c")
+ fs.IntVar(&intervalMs, "i", 500, "retransmit interval in ms (massdns -i/--interval)")
+ fs.IntVar(&intervalMs, "interval", 500, "alias of -i")
+ fs.DurationVar(&cfg.timeout, "timeout", 0, "per-attempt timeout (0 = 2×interval)")
+ fs.BoolVar(&cfg.norecurse, "norecurse", false, "send non-recursive queries (RD=0)")
+ fs.BoolVar(&cfg.sticky, "sticky", false, "do not rotate resolver on retry")
+ fs.BoolVar(&cfg.predictable, "predictable", false, "use resolvers incrementally")
+ fs.BoolVar(&cfg.verifyIP, "verify-ip", false, "verify reply source IP (massdns --verify-ip)")
+ fs.BoolVar(&cfg.extendedInput, "extended-input", false, "input lines: name [resolver ...]")
+ fs.BoolVar(&cfg.flush, "flush", false, "flush output after every reply")
+ fs.BoolVar(&cfg.quiet, "q", false, "quiet mode (suppress status)")
+ fs.BoolVar(&cfg.quiet, "quiet", false, "alias of -q")
+ fs.StringVar(&cfg.errorLog, "l", "", "error log file path (default stderr)")
+ fs.StringVar(&cfg.errorLog, "error-log", "", "alias of -l")
+ fs.StringVar(&cfg.statusFormat, "status-format", "ansi", "status updates: ansi|json|none")
+ fs.StringVar(&cfg.bindAddr, "b", "", "local bind address (massdns -b/--bindto)")
+ fs.StringVar(&cfg.bindAddr, "bindto", "", "alias of -b")
+ fs.IntVar(&cfg.rcvbuf, "rcvbuf", 0, "SO_RCVBUF bytes (0 = default large buffer)")
+ fs.IntVar(&cfg.sndbuf, "sndbuf", 0, "SO_SNDBUF bytes (0 = OS default)")
+ fs.IntVar(&cfg.socketCount, "socket-count", 0, "UDP sockets (0 = scale to cores)")
+ fs.StringVar(&cfg.filter, "filter", "", "only output these response codes")
+ fs.StringVar(&cfg.ignore, "ignore", "", "drop these response codes")
+ fs.StringVar(&retryCSV, "retry", "", "response codes that trigger retry (default: all but NOERROR,NXDOMAIN)")
+
+ // Rate / native engine controls.
+ fs.IntVar(&cfg.qps, "qps", 0, "max outbound queries per second (0 = unlimited)")
+ fs.StringVar(&cfg.batchMode, "batch-mode", "off", "datagram batching: off|on|adaptive (Linux)")
+ fs.BoolVar(&cfg.noTCPFallback, "no-tcp-fallback", false, "disable TCP fallback on truncated answers")
+ fs.BoolVar(&cfg.resolverHealth, "resolver-health", false, "de-weight failing resolvers")
+ fs.BoolVar(&cfg.adaptiveConc, "adaptive-concurrency", false, "adapt in-flight concurrency to loss")
+ fs.BoolVar(&cfg.crossCheck, "cross-check", false, "re-verify positives on a second resolver")
+
+ // Native extensions (not in massdns).
+ fs.BoolVar(&cfg.iterative, "iterative", false, "recurse from root servers (no -r needed)")
+ fs.StringVar(&ptrList, "ptr", "", "reverse-PTR sweep targets: IPs/CIDRs/ranges")
+ fs.BoolVar(&cfg.onlyType, "only-type", false, "output only answer records matching the queried type")
+ fs.BoolVar(&cfg.validate, "validate", false, "validate the -r resolver list and print the good ones")
+ fs.StringVar(&cfg.validateDomain, "validate-domain", "", "known-good domains for --validate")
+ fs.StringVar(&cfg.zone, "zonewalk", "", "NSEC zone-walk the given zone")
+ fs.StringVar(&cfg.axfr, "axfr", "", "AXFR/IXFR the given zone")
+ fs.StringVar(&cfg.nsec3Dict, "nsec3-dict", "", "wordlist to crack NSEC3 from --zonewalk")
+ fs.StringVar(&cfg.shard, "shard", "", "process shard m/n (e.g. 2/8)")
+ fs.StringVar(&cfg.resume, "resume", "", "checkpoint file for crash-safe resume")
+
+ // Privilege drop (after sockets open) and Linux raw IPv6 source spoofing.
+ fs.StringVar(&cfg.dropUser, "drop-user", "", "drop privileges to user after open (default nobody when root)")
+ fs.StringVar(&cfg.dropGroup, "drop-group", "", "drop privileges to group after open (default nobody when root)")
+ fs.BoolVar(&cfg.keepRoot, "root", false, "do not drop privileges when running as root")
+ fs.StringVar(&cfg.randSrcIPv6, "rand-src-ipv6", "", "random IPv6 source from prefix (Linux, CAP_NET_RAW; e.g. 2001:db8::/32)")
+ fs.StringVar(&cfg.randSrcIPv6File, "rand-src-ipv6-file", "", "file of IPv6 source addresses (Linux, CAP_NET_RAW)")
+
+ // Accepted and ignored (massdns multi-process / epoll busy-poll).
+ fs.BoolVar(&ignoreBusyPoll, "busy-poll", false, "ignored (epoll busy-poll; not applicable)")
+ fs.IntVar(&processes, "processes", 1, "ignored (use -s / sockets instead of processes)")
+ _ = ignoreBusyPoll
+
+ if err := fs.Parse(os.Args[1:]); err != nil {
+ os.Exit(2)
+ }
+ cfg.args = fs.Args()
+ cfg.ptrTargets = splitCSV(ptrList)
+ cfg.interval = time.Duration(intervalMs) * time.Millisecond
+ if cfg.timeout <= 0 {
+ cfg.timeout = 2 * cfg.interval
+ if cfg.timeout < time.Second {
+ cfg.timeout = time.Second
+ }
+ }
+ if retryCSV != "" {
+ cfg.retryRcodes = parseRetryRcodes(retryCSV)
+ }
+ if processes > 1 && !cfg.quiet {
+ fmt.Fprintf(os.Stderr, "note: --processes=%d ignored; raise -s/--socket-count instead\n", processes)
+ }
+ if (cfg.randSrcIPv6 != "" || cfg.randSrcIPv6File != "") && cfg.bindAddr != "" {
+ fmt.Fprintln(os.Stderr, "error: --bindto and --rand-src-ipv6 cannot be used together")
+ os.Exit(2)
+ }
+ // massdns --verify-ip is opt-in; without it, skip source verification.
+ cfg.disableVerifyIP = !cfg.verifyIP
+ return cfg
+}
+
+func parseRetryRcodes(csv string) []int {
+ var out []int
+ for _, p := range splitCSV(csv) {
+ if v, ok := dns.StringToRcode[strings.ToUpper(p)]; ok {
+ out = append(out, v)
+ continue
+ }
+ if n, err := strconv.Atoi(p); err == nil {
+ out = append(out, n)
+ }
+ }
+ return out
+}
diff --git a/cmd/resolve/integration_test.go b/cmd/resolve/integration_test.go
new file mode 100644
index 00000000..2742048f
--- /dev/null
+++ b/cmd/resolve/integration_test.go
@@ -0,0 +1,244 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/internal/simdns"
+ "github.com/projectdiscovery/shuffledns/pkg/output"
+ "github.com/projectdiscovery/shuffledns/pkg/ptr"
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+)
+
+// TestReversePTRSweepEndToEnd drives the full reverse-PTR pipeline against the
+// loopback resolver battery: ptr generator -> native resolver (PTR) -> massdns
+// simple output. No traffic leaves the host.
+func TestReversePTRSweepEndToEnd(t *testing.T) {
+ battery, err := simdns.Start(4, simdns.Config{
+ BaseLatency: 200 * time.Microsecond,
+ HitPercent: 100,
+ })
+ if err != nil {
+ t.Fatalf("simdns.Start: %v", err)
+ }
+ defer battery.Stop()
+
+ var buf bytes.Buffer
+ w, err := output.NewWriter(&buf, "Snl")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ client, err := resolve.New(resolve.Options{
+ Resolvers: battery.Addrs,
+ QueryType: dns.TypePTR,
+ Concurrency: 1000,
+ MaxRetries: 3,
+ Timeout: time.Second,
+ OnResult: func(r resolve.Result) {
+ _ = w.Write(r)
+ },
+ })
+ if err != nil {
+ t.Fatalf("resolve.New: %v", err)
+ }
+ defer client.Close()
+
+ input := make(chan string, 256)
+ go func() {
+ defer close(input)
+ _ = ptr.Stream(context.Background(), []string{"192.0.2.0/28"}, input)
+ }()
+
+ if err := client.Run(context.Background(), input); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ _ = w.Flush()
+
+ out := buf.String()
+ // 192.0.2.0/28 = 16 addresses; all resolve (HitPercent 100).
+ ptrLines := 0
+ for _, line := range strings.Split(out, "\n") {
+ if strings.Contains(line, " PTR ") {
+ ptrLines++
+ if !strings.Contains(line, "in-addr.arpa.") || !strings.Contains(line, ".ptr.example.com.") {
+ t.Fatalf("unexpected PTR line: %q", line)
+ }
+ }
+ }
+ if ptrLines != 16 {
+ t.Fatalf("expected 16 PTR answers, got %d in:\n%s", ptrLines, out)
+ }
+}
+
+// TestCLIAgainstBattery builds the resolve binary and runs it end-to-end against
+// the loopback battery, validating flag parsing, stdin input and simple output.
+func TestCLIAgainstBattery(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping binary build in -short mode")
+ }
+
+ battery, err := simdns.Start(4, simdns.Config{BaseLatency: 200 * time.Microsecond, HitPercent: 100})
+ if err != nil {
+ t.Fatalf("simdns.Start: %v", err)
+ }
+ defer battery.Stop()
+
+ dir := t.TempDir()
+ bin := filepath.Join(dir, "resolve")
+ if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
+ t.Fatalf("build failed: %v\n%s", err, out)
+ }
+
+ resolversFile := filepath.Join(dir, "resolvers.txt")
+ if err := os.WriteFile(resolversFile, []byte(strings.Join(battery.Addrs, "\n")+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ var names strings.Builder
+ for i := 0; i < 20; i++ {
+ names.WriteString("host")
+ names.WriteByte(byte('0' + i%10))
+ names.WriteString(".bench.example.com\n")
+ }
+
+ cmd := exec.Command(bin, "-r", resolversFile, "-t", "A", "-o", "Snl", "-timeout", "1s")
+ cmd.Stdin = strings.NewReader(names.String())
+ out, err := cmd.Output()
+ if err != nil {
+ t.Fatalf("resolve binary failed: %v", err)
+ }
+ if !strings.Contains(string(out), " A 10.") {
+ t.Fatalf("expected A-record output, got:\n%s", out)
+ }
+}
+
+// buildResolve compiles the resolve binary into a temp dir and returns its path.
+func buildResolve(t *testing.T) string {
+ t.Helper()
+ bin := filepath.Join(t.TempDir(), "resolve")
+ if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
+ t.Fatalf("build failed: %v\n%s", err, out)
+ }
+ return bin
+}
+
+func writeResolversFile(t *testing.T, addrs []string) string {
+ t.Helper()
+ f := filepath.Join(t.TempDir(), "resolvers.txt")
+ if err := os.WriteFile(f, []byte(strings.Join(addrs, "\n")+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return f
+}
+
+// resolvedNames extracts the queried names from "Snl" output lines.
+func resolvedNames(out string) map[string]struct{} {
+ set := map[string]struct{}{}
+ for _, line := range strings.Split(out, "\n") {
+ f := strings.Fields(line)
+ if len(f) == 3 && f[1] == "A" {
+ set[strings.TrimSuffix(f[0], ".")] = struct{}{}
+ }
+ }
+ return set
+}
+
+func benchNames(n int) string {
+ var b strings.Builder
+ for i := 0; i < n; i++ {
+ fmt.Fprintf(&b, "host%d.bench.example.com\n", i)
+ }
+ return b.String()
+}
+
+// TestShardingPartitionsWork runs two shards over the same input and asserts the
+// shards resolve disjoint subsets whose union is the full input.
+func TestShardingPartitionsWork(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping binary build in -short mode")
+ }
+ battery, err := simdns.Start(4, simdns.Config{BaseLatency: 200 * time.Microsecond, HitPercent: 100})
+ if err != nil {
+ t.Fatalf("simdns.Start: %v", err)
+ }
+ defer battery.Stop()
+
+ bin := buildResolve(t)
+ resolversFile := writeResolversFile(t, battery.Addrs)
+ const total = 50
+ names := benchNames(total)
+
+ run := func(shard string) map[string]struct{} {
+ cmd := exec.Command(bin, "-r", resolversFile, "-t", "A", "-o", "Snl", "-timeout", "1s", "-shard", shard)
+ cmd.Stdin = strings.NewReader(names)
+ out, err := cmd.Output()
+ if err != nil {
+ t.Fatalf("shard %s failed: %v", shard, err)
+ }
+ return resolvedNames(string(out))
+ }
+
+ a := run("1/2")
+ b := run("2/2")
+
+ // disjoint
+ for n := range a {
+ if _, ok := b[n]; ok {
+ t.Fatalf("name %q appeared in both shards", n)
+ }
+ }
+ // union == full set
+ if len(a)+len(b) != total {
+ t.Fatalf("shard union = %d, want %d (a=%d b=%d)", len(a)+len(b), total, len(a), len(b))
+ }
+ if len(a) == 0 || len(b) == 0 {
+ t.Fatalf("a shard got nothing (a=%d b=%d)", len(a), len(b))
+ }
+}
+
+// TestResumeSkipsCompleted runs once to populate a checkpoint, then re-runs with
+// the same checkpoint and asserts everything is skipped.
+func TestResumeSkipsCompleted(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping binary build in -short mode")
+ }
+ battery, err := simdns.Start(4, simdns.Config{BaseLatency: 200 * time.Microsecond, HitPercent: 100})
+ if err != nil {
+ t.Fatalf("simdns.Start: %v", err)
+ }
+ defer battery.Stop()
+
+ bin := buildResolve(t)
+ resolversFile := writeResolversFile(t, battery.Addrs)
+ resumeFile := filepath.Join(t.TempDir(), "resume.log")
+ names := benchNames(30)
+
+ run := func() string {
+ cmd := exec.Command(bin, "-r", resolversFile, "-t", "A", "-o", "Snl", "-timeout", "1s", "-resume", resumeFile)
+ cmd.Stdin = strings.NewReader(names)
+ out, err := cmd.Output()
+ if err != nil {
+ t.Fatalf("run failed: %v", err)
+ }
+ return string(out)
+ }
+
+ first := run()
+ if len(resolvedNames(first)) != 30 {
+ t.Fatalf("first run resolved %d, want 30", len(resolvedNames(first)))
+ }
+
+ second := run()
+ if got := len(resolvedNames(second)); got != 0 {
+ t.Fatalf("resume run should skip all completed names, but resolved %d", got)
+ }
+}
diff --git a/cmd/resolve/main.go b/cmd/resolve/main.go
new file mode 100644
index 00000000..303ce651
--- /dev/null
+++ b/cmd/resolve/main.go
@@ -0,0 +1,485 @@
+// Command resolve is a native, dependency-free massdns-compatible bulk resolver.
+// It accepts the massdns CLI surface (obsolete flags are ignored) and writes
+// results in massdns output formats via pkg/output.
+//
+// Examples:
+//
+// resolve -r resolvers.txt -t A -o Snl names.txt > out.txt
+// resolve -r resolvers.txt --ptr 192.0.2.0/24 -o J > ptr.ndjson
+package main
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/pkg/axfr"
+ "github.com/projectdiscovery/shuffledns/pkg/checkpoint"
+ "github.com/projectdiscovery/shuffledns/pkg/iterative"
+ "github.com/projectdiscovery/shuffledns/pkg/output"
+ "github.com/projectdiscovery/shuffledns/pkg/ptr"
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+ "github.com/projectdiscovery/shuffledns/pkg/shard"
+ "github.com/projectdiscovery/shuffledns/pkg/zonewalk"
+)
+
+func main() {
+ cfg := parseFlags()
+
+ errOut := io.Writer(os.Stderr)
+ if cfg.errorLog != "" {
+ f, err := os.OpenFile(cfg.errorLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+ if err != nil {
+ fatal("could not open error log: %v", err)
+ }
+ defer func() { _ = f.Close() }()
+ errOut = f
+ }
+
+ var resolvers []string
+ var err error
+ if cfg.resolversFile != "" {
+ resolvers, err = readLines(cfg.resolversFile)
+ if err != nil {
+ fatal("could not read resolvers: %v", err)
+ }
+ }
+ if !cfg.iterative && len(resolvers) == 0 {
+ fatal("no resolvers provided (-r); or use --iterative to recurse from root")
+ }
+
+ qtype := dns.TypeA
+ if cfg.qtype != "" {
+ t, ok := dns.StringToType[strings.ToUpper(cfg.qtype)]
+ if !ok {
+ fatal("unknown record type %q", cfg.qtype)
+ }
+ qtype = t
+ }
+ if len(cfg.ptrTargets) > 0 {
+ qtype = dns.TypePTR
+ }
+
+ out := os.Stdout
+ if cfg.outFile != "" {
+ out, err = os.Create(cfg.outFile)
+ if err != nil {
+ fatal("could not create output file: %v", err)
+ }
+ defer func() { _ = out.Close() }()
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ if cfg.validate {
+ runValidate(ctx, cfg, resolvers, out)
+ return
+ }
+ if cfg.axfr != "" {
+ runAXFR(ctx, cfg, resolvers, out, errOut)
+ return
+ }
+ if cfg.zone != "" {
+ runZonewalk(ctx, cfg, resolvers, out, errOut)
+ return
+ }
+
+ writer, err := output.NewWriterWithConfig(out, output.Config{
+ Format: cfg.format,
+ FilterRcodes: splitCSV(cfg.filter),
+ IgnoreRcodes: splitCSV(cfg.ignore),
+ OnlyQueryType: cfg.onlyType,
+ FlushEach: cfg.flush,
+ })
+ if err != nil {
+ fatal("invalid output format: %v", err)
+ }
+ defer func() { _ = writer.Flush() }()
+
+ shardCfg, err := shard.Parse(cfg.shard)
+ if err != nil {
+ fatal("%v", err)
+ }
+ var ckpt *checkpoint.Checkpoint
+ if cfg.resume != "" {
+ ckpt, err = checkpoint.Open(cfg.resume)
+ if err != nil {
+ fatal("could not open resume checkpoint: %v", err)
+ }
+ defer func() { _ = ckpt.Close() }()
+ if n := ckpt.Resumed(); n > 0 && !cfg.quiet {
+ _, _ = fmt.Fprintf(errOut, "resuming: skipping %d already-completed names\n", n)
+ }
+ }
+ markDone := func(name string) {
+ if ckpt != nil {
+ _ = ckpt.Done(name)
+ }
+ }
+
+ onResult := func(r resolve.Result) {
+ markDone(r.Name)
+ if err := writer.Write(r); err != nil {
+ _, _ = fmt.Fprintf(errOut, "write error: %v\n", err)
+ }
+ }
+ onError := func(name string, err error) {
+ markDone(name)
+ _ = writer.WriteFailure(name, qtype, "resolution failed")
+ if err != nil && !cfg.quiet {
+ _, _ = fmt.Fprintf(errOut, "%s: %v\n", name, err)
+ }
+ }
+
+ input := make(chan string, 8192)
+ go func() {
+ defer close(input)
+ raw := make(chan string, 8192)
+ go func() {
+ defer close(raw)
+ if len(cfg.ptrTargets) > 0 {
+ if err := ptr.Stream(ctx, cfg.ptrTargets, raw); err != nil {
+ _, _ = fmt.Fprintf(errOut, "ptr generation error: %v\n", err)
+ }
+ return
+ }
+ produceNames(ctx, cfg.args, raw, errOut)
+ }()
+ for name := range raw {
+ if !shardCfg.Owns(name) {
+ continue
+ }
+ if ckpt != nil && ckpt.Has(name) {
+ continue
+ }
+ // Stop feeding once the consumer is gone (interrupt), otherwise this
+ // send blocks forever on a full buffer and leaks the producer.
+ select {
+ case input <- name:
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+
+ if cfg.iterative {
+ ir, err := iterative.New(iterative.Options{
+ QueryType: qtype,
+ Concurrency: iterativeWorkers(cfg.concurrency),
+ Timeout: cfg.timeout,
+ IPv6: qtype == dns.TypeAAAA,
+ })
+ if err != nil {
+ fatal("could not create iterative resolver: %v", err)
+ }
+ err = ir.ResolveStream(ctx, input, iterative.StreamConfig{
+ QueryType: qtype,
+ OnResult: func(r *resolve.Result) { onResult(*r) },
+ OnError: onError,
+ })
+ // A cancelled context is a user interrupt (Ctrl-C / SIGTERM), not a
+ // failure: fall through so buffered output and the checkpoint are flushed
+ // by the deferred cleanups instead of being lost to os.Exit.
+ if err != nil && !errors.Is(err, context.Canceled) {
+ fatal("resolution failed: %v", err)
+ }
+ } else {
+ client, err := resolve.New(resolve.Options{
+ Resolvers: resolvers,
+ QueryType: qtype,
+ Concurrency: cfg.concurrency,
+ QPS: cfg.qps,
+ MaxRetries: cfg.retries,
+ Timeout: cfg.timeout,
+ Interval: cfg.interval,
+ NoRecurse: cfg.norecurse,
+ Sticky: cfg.sticky,
+ Predictable: cfg.predictable,
+ ExtendedInput: cfg.extendedInput,
+ DisableIPVerification: cfg.disableVerifyIP,
+ DisableTCPFallback: cfg.noTCPFallback,
+ Batch: parseBatchMode(cfg.batchMode),
+ SocketCount: cfg.socketCount,
+ BindAddr: cfg.bindAddr,
+ ReadBuffer: cfg.rcvbuf,
+ WriteBuffer: cfg.sndbuf,
+ RandSrcIPv6: cfg.randSrcIPv6,
+ RandSrcIPv6File: cfg.randSrcIPv6File,
+ RetryRcodes: cfg.retryRcodes,
+ ResolverHealth: cfg.resolverHealth,
+ AdaptiveConcurrency: cfg.adaptiveConc,
+ CrossCheck: cfg.crossCheck,
+ OnResult: onResult,
+ OnError: onError,
+ OnProgress: statusReporter(cfg, errOut),
+ })
+ if err != nil {
+ fatal("could not create resolver: %v", err)
+ }
+ defer client.Close()
+ // Drop root after sockets are open (massdns --drop-user/--drop-group/--root).
+ if err := resolve.DropPrivileges(cfg.dropUser, cfg.dropGroup, cfg.keepRoot); err != nil {
+ fatal("privilege drop: %v", err)
+ }
+ if err := client.Run(ctx, input); err != nil && !errors.Is(err, context.Canceled) {
+ fatal("resolution failed: %v", err)
+ }
+ }
+
+ if err := writer.Flush(); err != nil {
+ fatal("flush failed: %v", err)
+ }
+}
+
+func statusReporter(cfg config, errOut io.Writer) func(resolve.Stats) {
+ if cfg.quiet || strings.EqualFold(cfg.statusFormat, "none") {
+ return nil
+ }
+ jsonMode := strings.EqualFold(cfg.statusFormat, "json")
+ return func(s resolve.Stats) {
+ if jsonMode {
+ _, _ = fmt.Fprintf(errOut, `{"queries":%d,"retransmits":%d,"answered":%d,"inflight":%d,"concurrency":%d,"loss":%.4f,"rtt_ms":%.2f}`+"\n",
+ s.Queries, s.Retransmits, s.Answered, s.InflightDepth, s.ConcurrencyCap, s.LossRate, float64(s.RTT.Microseconds())/1000)
+ return
+ }
+ _, _ = fmt.Fprintf(errOut, "\rprocessed: %d | answered: %d | inflight: %d | conc: %d | loss: %.1f%% | rtt: %s",
+ s.Queries, s.Answered, s.InflightDepth, s.ConcurrencyCap, s.LossRate*100, s.RTT.Round(time.Microsecond))
+ }
+}
+
+func iterativeWorkers(concurrency int) int {
+ const max = 1024
+ if concurrency <= 0 {
+ return 200
+ }
+ if concurrency > max {
+ return max
+ }
+ return concurrency
+}
+
+type config struct {
+ resolversFile string
+ qtype string
+ format string
+ outFile string
+ concurrency int
+ qps int
+ retries int
+ timeout time.Duration
+ interval time.Duration
+ norecurse bool
+ sticky bool
+ predictable bool
+ verifyIP bool
+ disableVerifyIP bool
+ extendedInput bool
+ flush bool
+ quiet bool
+ errorLog string
+ statusFormat string
+ bindAddr string
+ rcvbuf int
+ sndbuf int
+ socketCount int
+ dropUser string
+ dropGroup string
+ keepRoot bool
+ randSrcIPv6 string
+ randSrcIPv6File string
+ retryRcodes []int
+ batchMode string
+ noTCPFallback bool
+ resolverHealth bool
+ adaptiveConc bool
+ crossCheck bool
+ iterative bool
+ ptrTargets []string
+ filter string
+ ignore string
+ onlyType bool
+ validate bool
+ validateDomain string
+ zone string
+ axfr string
+ nsec3Dict string
+ shard string
+ resume string
+ args []string
+}
+
+func splitCSV(s string) []string {
+ var out []string
+ for _, p := range strings.Split(s, ",") {
+ if p = strings.TrimSpace(p); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func runValidate(ctx context.Context, cfg config, resolvers []string, out *os.File) {
+ good, report, err := resolve.ValidateResolvers(ctx, resolve.ValidateConfig{
+ Resolvers: resolvers,
+ GoodDomains: splitCSV(cfg.validateDomain),
+ Timeout: cfg.timeout,
+ })
+ if err != nil {
+ fatal("validation failed: %v", err)
+ }
+ w := bufio.NewWriter(out)
+ defer func() { _ = w.Flush() }()
+ for _, r := range good {
+ _, _ = fmt.Fprintln(w, r)
+ }
+ _, _ = fmt.Fprintf(os.Stderr, "validated %d resolvers: %d good, %d rejected\n",
+ len(report), len(good), len(report)-len(good))
+}
+
+func runAXFR(ctx context.Context, cfg config, resolvers []string, out *os.File, errOut io.Writer) {
+ w := bufio.NewWriter(out)
+ defer func() { _ = w.Flush() }()
+ res, err := axfr.Attempt(ctx, axfr.Config{
+ Zone: cfg.axfr,
+ Resolvers: resolvers,
+ Timeout: cfg.timeout,
+ OnName: func(name string) { _, _ = fmt.Fprintln(w, name) },
+ OnNameserver: func(ns string, names int, err error) {
+ if err != nil {
+ _, _ = fmt.Fprintf(errOut, "axfr %s: refused/failed (%v)\n", ns, err)
+ } else {
+ _, _ = fmt.Fprintf(errOut, "axfr %s: transferred %d names\n", ns, names)
+ }
+ },
+ })
+ if err != nil {
+ fatal("zone transfer failed: %v", err)
+ }
+ _, _ = fmt.Fprintf(errOut, "AXFR of %s via %s transferred %d names (%d records)\n",
+ cfg.axfr, res.Nameserver, len(res.Names), res.Records)
+}
+
+func runZonewalk(ctx context.Context, cfg config, resolvers []string, out *os.File, errOut io.Writer) {
+ w := bufio.NewWriter(out)
+ defer func() { _ = w.Flush() }()
+ res, err := zonewalk.Walk(ctx, zonewalk.Config{
+ Zone: cfg.zone,
+ Resolvers: resolvers,
+ Timeout: cfg.timeout,
+ OnName: func(name string) { _, _ = fmt.Fprintln(w, name) },
+ })
+ if err != nil {
+ fatal("zone walk failed: %v", err)
+ }
+ if res.NSEC3 {
+ _, _ = fmt.Fprintf(errOut, "zone %s is NSEC3-signed (salt=%s iterations=%d)\n",
+ cfg.zone, res.NSEC3Param.Salt, res.NSEC3Param.Iterations)
+ if cfg.nsec3Dict == "" {
+ _, _ = fmt.Fprintf(errOut, "supply --nsec3-dict to harvest and crack the NSEC3 ring\n")
+ return
+ }
+ candidates, rerr := readLines(cfg.nsec3Dict)
+ if rerr != nil {
+ fatal("could not read nsec3 wordlist: %v", rerr)
+ }
+ cres, cerr := zonewalk.CrackNSEC3(ctx, zonewalk.CrackConfig{
+ Zone: cfg.zone,
+ Resolvers: resolvers,
+ Timeout: cfg.timeout,
+ Candidates: candidates,
+ OnName: func(name string) { _, _ = fmt.Fprintln(w, name) },
+ })
+ if cerr != nil {
+ fatal("nsec3 crack failed: %v", cerr)
+ }
+ _, _ = fmt.Fprintf(errOut, "NSEC3 crack of %s: harvested %d hashes, recovered %d/%d names (saturated=%t)\n",
+ cfg.zone, cres.HarvestedHashes, len(cres.Names), len(candidates), cres.Saturated)
+ return
+ }
+ _, _ = fmt.Fprintf(errOut, "zone walk of %s discovered %d names\n", cfg.zone, len(res.Names))
+}
+
+func produceNames(ctx context.Context, files []string, out chan<- string, errOut io.Writer) {
+ emit := func(line string) bool {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ return true
+ }
+ select {
+ case <-ctx.Done():
+ return false
+ case out <- line:
+ return true
+ }
+ }
+
+ if len(files) == 0 {
+ scanLines(os.Stdin, emit)
+ return
+ }
+ for _, fname := range files {
+ f, err := os.Open(fname)
+ if err != nil {
+ _, _ = fmt.Fprintf(errOut, "could not open %s: %v\n", fname, err)
+ continue
+ }
+ cont := scanLines(f, emit)
+ _ = f.Close()
+ if !cont {
+ return
+ }
+ }
+}
+
+func scanLines(f *os.File, emit func(string) bool) bool {
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ if !emit(scanner.Text()) {
+ return false
+ }
+ }
+ return true
+}
+
+func readLines(path string) ([]string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = f.Close() }()
+ var lines []string
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ if l := strings.TrimSpace(scanner.Text()); l != "" {
+ lines = append(lines, l)
+ }
+ }
+ return lines, scanner.Err()
+}
+
+func parseBatchMode(s string) resolve.BatchMode {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "on", "enabled", "true":
+ return resolve.BatchEnabled
+ case "adaptive", "auto":
+ return resolve.BatchAdaptive
+ default:
+ return resolve.BatchDisabled
+ }
+}
+
+func fatal(format string, args ...interface{}) {
+ _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/cmd/shuffledns/main.go b/cmd/shuffledns/main.go
index e87386b2..f44e2b9c 100644
--- a/cmd/shuffledns/main.go
+++ b/cmd/shuffledns/main.go
@@ -14,6 +14,9 @@ func main() {
gologger.Fatal().Msgf("Could not create runner: %s\n", err)
}
- massdnsRunner.RunEnumeration()
+ if err := massdnsRunner.RunEnumeration(); err != nil {
+ massdnsRunner.Close()
+ gologger.Fatal().Msgf("Could not run enumeration: %s\n", err)
+ }
massdnsRunner.Close()
}
diff --git a/go.mod b/go.mod
index ba8f11b1..b6117f30 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/projectdiscovery/shuffledns
go 1.25.0
require (
+ github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/miekg/dns v1.1.62
github.com/projectdiscovery/dnsx v1.3.0
github.com/projectdiscovery/goflags v0.1.74
@@ -11,7 +12,6 @@ require (
github.com/remeh/sizedwaitgroup v1.0.0
github.com/rs/xid v1.5.0
github.com/stretchr/testify v1.11.1
- github.com/syndtr/goleveldb v1.0.0
github.com/weppos/publicsuffix-go v0.50.3-0.20260104170930-90713dec78f2
)
@@ -30,8 +30,8 @@ require (
github.com/projectdiscovery/utils v0.11.1
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
golang.org/x/mod v0.35.0 // indirect
- golang.org/x/net v0.55.0 // indirect
- golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/net v0.55.0
+ golang.org/x/sys v0.45.0
golang.org/x/tools v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
@@ -52,7 +52,6 @@ require (
github.com/fatih/color v1.15.0 // indirect
github.com/gaissmai/bart v0.28.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
- github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
@@ -90,5 +89,4 @@ require (
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
- gopkg.in/yaml.v2 v2.2.2 // indirect
)
diff --git a/go.sum b/go.sum
index 3f25e603..ef791319 100644
--- a/go.sum
+++ b/go.sum
@@ -42,16 +42,11 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
-github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gaissmai/bart v0.28.0 h1:89yZLo8NmyqD0RYgJ3QO9HhqqGGw+oWhf90cZm69Lko=
github.com/gaissmai/bart v0.28.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
-github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -68,10 +63,10 @@ github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
-github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
-github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
@@ -108,11 +103,6 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
-github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
-github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
-github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
-github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -152,8 +142,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
-github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -187,7 +175,6 @@ golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
-golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -196,10 +183,8 @@ golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -229,12 +214,5 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
-gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
-gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/authsim/authsim.go b/internal/authsim/authsim.go
new file mode 100644
index 00000000..7d0e565e
--- /dev/null
+++ b/internal/authsim/authsim.go
@@ -0,0 +1,236 @@
+// Package authsim provides a real-socket, loopback authoritative DNS hierarchy
+// (root -> TLD -> per-domain authoritative servers) for benchmarking the
+// iterative-from-root resolver entirely offline. Every server binds a distinct
+// 127.0.0.x address (Linux routes all of 127/8 to loopback without aliasing),
+// so the iterative resolver follows real referrals and glue over real UDP
+// without a single packet leaving the host.
+//
+// Answers use simdns.SyntheticIP, identical to the simulated recursive-resolver
+// battery, so the iterative engine (talking to these authoritative servers) and
+// the stub engine (talking to recursors) resolve the exact same workload and
+// their results can be compared for accuracy (false negatives).
+package authsim
+
+import (
+ "fmt"
+ "net"
+ "strings"
+ "sync"
+ "sync/atomic"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/internal/simdns"
+)
+
+type nsEntry struct {
+ name string
+ ip string // "" == glueless
+}
+
+type zoneServer struct {
+ ip string
+ port int
+ conn *net.UDPConn
+ authZones []string
+ delegations map[string][]nsEntry
+ closeCh chan struct{}
+ wg sync.WaitGroup
+ queries *atomic.Int64
+}
+
+// Hierarchy is a running authoritative tree.
+type Hierarchy struct {
+ RootAddr string // "127.0.0.1:port" to seed the iterative resolver's roots
+ Names []string // all resolvable leaf names (host{n}.d{k}.com)
+ Port int
+ Queries atomic.Int64
+
+ servers []*zoneServer
+}
+
+// Build starts an authoritative hierarchy with `domains` registrable domains
+// under .com, each served by its own authoritative server with `hosts` names.
+// All servers listen on `port` across distinct loopback IPs.
+func Build(domains, hosts, port int) (*Hierarchy, error) {
+ h := &Hierarchy{Port: port}
+
+ const rootIP = "127.0.0.1"
+ const comIP = "127.0.0.2"
+
+ root := h.newServer(rootIP, port, ".")
+ root.delegations["com."] = []nsEntry{{name: "a.gtld-servers.net.", ip: comIP}}
+
+ com := h.newServer(comIP, port, "com.")
+
+ for d := 0; d < domains; d++ {
+ zone := fmt.Sprintf("d%d.com.", d)
+ authIP := domainIP(d)
+ nsName := fmt.Sprintf("ns.%s", zone)
+ com.delegations[zone] = []nsEntry{{name: nsName, ip: authIP}}
+ h.newServer(authIP, port, zone)
+ for n := 0; n < hosts; n++ {
+ h.Names = append(h.Names, fmt.Sprintf("host%d.d%d.com", n, d))
+ }
+ }
+
+ for _, s := range h.servers {
+ if err := s.start(); err != nil {
+ h.Stop()
+ return nil, fmt.Errorf("bind %s:%d: %w (on macOS only 127.0.0.1 is available; run in Docker/Linux)", s.ip, port, err)
+ }
+ }
+ h.RootAddr = net.JoinHostPort(rootIP, fmt.Sprint(port))
+ return h, nil
+}
+
+func (h *Hierarchy) newServer(ip string, port int, zones ...string) *zoneServer {
+ s := &zoneServer{
+ ip: ip,
+ port: port,
+ authZones: zones,
+ delegations: map[string][]nsEntry{},
+ closeCh: make(chan struct{}),
+ queries: &h.Queries,
+ }
+ h.servers = append(h.servers, s)
+ return s
+}
+
+// Stop shuts down all servers.
+func (h *Hierarchy) Stop() {
+ for _, s := range h.servers {
+ select {
+ case <-s.closeCh:
+ default:
+ close(s.closeCh)
+ }
+ if s.conn != nil {
+ _ = s.conn.Close()
+ }
+ }
+ for _, s := range h.servers {
+ s.wg.Wait()
+ }
+}
+
+func domainIP(d int) string {
+ return net.IPv4(127, 1, byte(d>>8), byte(d)).String()
+}
+
+func (s *zoneServer) start() error {
+ addr := &net.UDPAddr{IP: net.ParseIP(s.ip), Port: s.port}
+ conn, err := net.ListenUDP("udp", addr)
+ if err != nil {
+ return err
+ }
+ _ = conn.SetReadBuffer(8 * 1024 * 1024)
+ _ = conn.SetWriteBuffer(8 * 1024 * 1024)
+ s.conn = conn
+ s.wg.Add(1)
+ go s.serve()
+ return nil
+}
+
+func (s *zoneServer) serve() {
+ defer s.wg.Done()
+ buf := make([]byte, 4096)
+ for {
+ n, from, err := s.conn.ReadFromUDP(buf)
+ if err != nil {
+ select {
+ case <-s.closeCh:
+ return
+ default:
+ continue
+ }
+ }
+ pkt := make([]byte, n)
+ copy(pkt, buf[:n])
+ s.handle(pkt, from)
+ }
+}
+
+func (s *zoneServer) handle(pkt []byte, from *net.UDPAddr) {
+ s.queries.Add(1)
+ req := new(dns.Msg)
+ if req.Unpack(pkt) != nil || len(req.Question) == 0 {
+ return
+ }
+ q := req.Question[0]
+ qname := canonical(q.Name)
+
+ m := new(dns.Msg)
+ m.SetReply(req)
+ m.Authoritative = true
+
+ // most specific authoritative zone and delegation for the name
+ authZone := ""
+ for _, z := range s.authZones {
+ if inBailiwick(qname, z) && len(z) > len(authZone) {
+ authZone = z
+ }
+ }
+ bestChild := ""
+ for child := range s.delegations {
+ if inBailiwick(qname, child) && len(child) > len(bestChild) {
+ bestChild = child
+ }
+ }
+
+ switch {
+ case bestChild != "" && len(bestChild) > len(authZone):
+ m.Authoritative = false
+ for _, ns := range s.delegations[bestChild] {
+ m.Ns = append(m.Ns, mustRR(fmt.Sprintf("%s 3600 IN NS %s", bestChild, ns.name)))
+ if ns.ip != "" {
+ m.Extra = append(m.Extra, mustRR(fmt.Sprintf("%s 3600 IN A %s", ns.name, ns.ip)))
+ }
+ }
+ case authZone == "":
+ m.Rcode = dns.RcodeServerFailure
+ case q.Qtype == dns.TypeA && isHostName(qname):
+ m.Answer = append(m.Answer, mustRR(fmt.Sprintf("%s 60 IN A %s", qname, simdns.SyntheticIP(qname))))
+ case q.Qtype == dns.TypeA && isNSName(qname):
+ // answer the server's own NS address if asked (glue self-lookup)
+ m.Answer = append(m.Answer, mustRR(fmt.Sprintf("%s 60 IN A %s", qname, s.ip)))
+ default:
+ m.Rcode = dns.RcodeNameError
+ m.Ns = append(m.Ns, mustRR(fmt.Sprintf("%s 3600 IN SOA ns.%s hostmaster.%s 1 3600 600 86400 60", authZone, authZone, authZone)))
+ }
+
+ out, err := m.Pack()
+ if err != nil {
+ return
+ }
+ _, _ = s.conn.WriteToUDP(out, from)
+}
+
+func isHostName(name string) bool { return strings.HasPrefix(name, "host") }
+func isNSName(name string) bool { return strings.HasPrefix(name, "ns.") }
+
+func canonical(name string) string {
+ if name == "" || name == "." {
+ return "."
+ }
+ name = strings.ToLower(name)
+ if !strings.HasSuffix(name, ".") {
+ name += "."
+ }
+ return name
+}
+
+func inBailiwick(child, parent string) bool {
+ child, parent = canonical(child), canonical(parent)
+ if parent == "." {
+ return true
+ }
+ return child == parent || strings.HasSuffix(child, "."+parent)
+}
+
+func mustRR(s string) dns.RR {
+ rr, err := dns.NewRR(s)
+ if err != nil {
+ panic(err)
+ }
+ return rr
+}
diff --git a/internal/simdns/simdns.go b/internal/simdns/simdns.go
new file mode 100644
index 00000000..564145da
--- /dev/null
+++ b/internal/simdns/simdns.go
@@ -0,0 +1,277 @@
+// Package simdns provides a battery of loopback UDP DNS servers that simulate
+// remote recursive resolvers. It is used to benchmark DNS resolvers (the native
+// resolver as well as the massdns binary) without sending any traffic to the
+// public internet.
+//
+// Each simulated resolver models realistic remote conditions: response latency
+// with jitter, packet loss, a SERVFAIL rate, and an optional per-resolver QPS
+// cap (over-budget queries are dropped, like a throttling public resolver). The
+// answered/NXDOMAIN decision is deterministic per name, so the workload is
+// stable and reproducible across runs and across engines.
+package simdns
+
+import (
+ "fmt"
+ "math/rand"
+ "net"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+// Config describes the simulated behaviour of one remote resolver.
+type Config struct {
+ BaseLatency time.Duration // minimum response delay
+ Jitter time.Duration // additional uniform-random delay on top of base
+ LossRate float64 // fraction of queries silently dropped (0..1)
+ ServfailRate float64 // fraction of queries answered with SERVFAIL (0..1)
+ QPSPerServer int // per-resolver rate cap; over-budget queries are dropped (0 = unlimited)
+ HitPercent int // fraction of names that return an A record
+ // HijackRate models a misbehaving recursive resolver that returns NXDOMAIN
+ // for a name that actually exists (e.g. rate-limit-induced false negatives
+ // or NXDOMAIN hijacking). This is the massdns #117 failure mode: the stub
+ // resolver believes the NXDOMAIN (terminal, not retried) and misses the
+ // name. Fraction 0..1 of otherwise-hitting queries answered NXDOMAIN.
+ HijackRate float64
+}
+
+// Stats aggregates what the battery actually did, for sanity reporting.
+type Stats struct {
+ Queries atomic.Int64
+ Answered atomic.Int64
+ Dropped atomic.Int64
+ Servfail atomic.Int64
+ RateLimited atomic.Int64
+ Hijacked atomic.Int64 // existing names falsely answered NXDOMAIN
+}
+
+// tokenBucket is a tiny non-blocking rate limiter used to model a resolver that
+// rate-limits and drops excess traffic.
+type tokenBucket struct {
+ mu sync.Mutex
+ tokens float64
+ max float64
+ refill float64 // tokens per second
+ lastFill time.Time
+}
+
+func newTokenBucket(qps int) *tokenBucket {
+ if qps <= 0 {
+ return nil
+ }
+ return &tokenBucket{
+ tokens: float64(qps),
+ max: float64(qps),
+ refill: float64(qps),
+ lastFill: time.Now(),
+ }
+}
+
+func (b *tokenBucket) allow() bool {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ now := time.Now()
+ b.tokens += now.Sub(b.lastFill).Seconds() * b.refill
+ if b.tokens > b.max {
+ b.tokens = b.max
+ }
+ b.lastFill = now
+ if b.tokens >= 1 {
+ b.tokens--
+ return true
+ }
+ return false
+}
+
+// resolver is a single loopback UDP DNS server modelling a remote resolver.
+type resolver struct {
+ conn *net.UDPConn
+ cfg Config
+ stats *Stats
+ bucket *tokenBucket
+ closeCh chan struct{}
+ wg sync.WaitGroup
+}
+
+// Battery is a running set of simulated resolvers.
+type Battery struct {
+ Addrs []string // resolver addresses in host:port form (loopback)
+ Stats *Stats
+ resolvers []*resolver
+}
+
+// Start launches n simulated resolvers on ephemeral 127.0.0.1 ports.
+func Start(n int, cfg Config) (*Battery, error) {
+ b := &Battery{Stats: &Stats{}}
+
+ for i := 0; i < n; i++ {
+ conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
+ if err != nil {
+ b.Stop()
+ return nil, err
+ }
+ _ = conn.SetReadBuffer(8 * 1024 * 1024)
+ _ = conn.SetWriteBuffer(8 * 1024 * 1024)
+ r := &resolver{
+ conn: conn,
+ cfg: cfg,
+ stats: b.Stats,
+ bucket: newTokenBucket(cfg.QPSPerServer),
+ closeCh: make(chan struct{}),
+ }
+ b.resolvers = append(b.resolvers, r)
+ b.Addrs = append(b.Addrs, conn.LocalAddr().String())
+ r.wg.Add(1)
+ go r.serve()
+ }
+
+ return b, nil
+}
+
+// Stop shuts down all simulated resolvers and waits for their goroutines.
+func (b *Battery) Stop() {
+ for _, r := range b.resolvers {
+ select {
+ case <-r.closeCh:
+ default:
+ close(r.closeCh)
+ }
+ _ = r.conn.Close()
+ }
+ for _, r := range b.resolvers {
+ r.wg.Wait()
+ }
+}
+
+func (r *resolver) serve() {
+ defer r.wg.Done()
+ buf := make([]byte, 4096)
+ for {
+ n, addr, err := r.conn.ReadFromUDP(buf)
+ if err != nil {
+ select {
+ case <-r.closeCh:
+ return
+ default:
+ continue
+ }
+ }
+ pkt := make([]byte, n)
+ copy(pkt, buf[:n])
+ r.wg.Add(1)
+ go r.handle(pkt, addr)
+ }
+}
+
+func (r *resolver) handle(pkt []byte, addr *net.UDPAddr) {
+ defer r.wg.Done()
+
+ r.stats.Queries.Add(1)
+
+ // per-resolver rate cap: drop what we cannot serve, like a throttling
+ // public resolver. The client should rotate to another resolver and retry.
+ if r.bucket != nil && !r.bucket.allow() {
+ r.stats.RateLimited.Add(1)
+ return
+ }
+
+ // simulate packet loss: silently drop, forcing a retransmit on timeout.
+ if r.cfg.LossRate > 0 && rand.Float64() < r.cfg.LossRate {
+ r.stats.Dropped.Add(1)
+ return
+ }
+
+ req := new(dns.Msg)
+ if err := req.Unpack(pkt); err != nil || len(req.Question) == 0 {
+ return
+ }
+ q := req.Question[0]
+
+ // simulate RTT
+ delay := r.cfg.BaseLatency
+ if r.cfg.Jitter > 0 {
+ delay += time.Duration(rand.Int63n(int64(r.cfg.Jitter) + 1))
+ }
+ if delay > 0 {
+ t := time.NewTimer(delay)
+ select {
+ case <-t.C:
+ case <-r.closeCh:
+ t.Stop()
+ return
+ }
+ }
+
+ m := new(dns.Msg)
+ m.SetReply(req)
+
+ switch {
+ case r.cfg.ServfailRate > 0 && rand.Float64() < r.cfg.ServfailRate:
+ m.Rcode = dns.RcodeServerFailure
+ r.stats.Servfail.Add(1)
+ case q.Qtype == dns.TypeA && NameHits(q.Name, r.cfg.HitPercent) && r.cfg.HijackRate > 0 && rand.Float64() < r.cfg.HijackRate:
+ // existing name falsely reported as NXDOMAIN (rate-limit/hijack); the
+ // stub believes it and produces a false negative.
+ m.Rcode = dns.RcodeNameError
+ r.stats.Hijacked.Add(1)
+ case q.Qtype == dns.TypeA && NameHits(q.Name, r.cfg.HitPercent):
+ rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, SyntheticIP(q.Name)))
+ if err == nil {
+ m.Answer = append(m.Answer, rr)
+ }
+ case q.Qtype == dns.TypePTR && NameHits(q.Name, r.cfg.HitPercent):
+ rr, err := dns.NewRR(fmt.Sprintf("%s 60 IN PTR %s", q.Name, SyntheticPTR(q.Name)))
+ if err == nil {
+ m.Answer = append(m.Answer, rr)
+ }
+ default:
+ m.Rcode = dns.RcodeNameError
+ }
+
+ out, err := m.Pack()
+ if err != nil {
+ return
+ }
+ if _, err := r.conn.WriteToUDP(out, addr); err == nil {
+ r.stats.Answered.Add(1)
+ }
+}
+
+// NameHits deterministically decides whether a name resolves, so the hit ratio
+// is stable across runs and across resolvers (every resolver agrees).
+func NameHits(name string, hitPercent int) bool {
+ if hitPercent >= 100 {
+ return true
+ }
+ if hitPercent <= 0 {
+ return false
+ }
+ return int(fnv32(name)%100) < hitPercent
+}
+
+// SyntheticIP returns a deterministic plausible host address for a name.
+func SyntheticIP(name string) string {
+ h := fnv32(name)
+ last := byte(h%254) + 1 // avoid .0/.255
+ return fmt.Sprintf("10.%d.%d.%d", byte(h>>16), byte(h>>8), last)
+}
+
+// SyntheticPTR returns a deterministic plausible hostname for a reverse query.
+func SyntheticPTR(name string) string {
+ return fmt.Sprintf("host-%d.ptr.example.com.", fnv32(name)%100000)
+}
+
+func fnv32(s string) uint32 {
+ const (
+ offset = 2166136261
+ prime = 16777619
+ )
+ h := uint32(offset)
+ for i := 0; i < len(s); i++ {
+ h ^= uint32(s[i])
+ h *= prime
+ }
+ return h
+}
diff --git a/pkg/axfr/axfr.go b/pkg/axfr/axfr.go
new file mode 100644
index 00000000..10e89ed7
--- /dev/null
+++ b/pkg/axfr/axfr.go
@@ -0,0 +1,246 @@
+// Package axfr attempts DNS zone transfers (AXFR, with IXFR fallback) against a
+// zone's authoritative nameservers. A misconfigured nameserver that allows
+// transfers from arbitrary clients returns the entire zone — every owner name —
+// in a single exchange, which is the highest-payoff subdomain enumeration
+// shortcut: no wordlist, no guessing, complete and authoritative.
+//
+// Open AXFR is uncommon on well-run zones (low single-digit percent), but it is
+// nearly free to attempt and total when it lands, so it belongs as a first pass
+// before bruteforce. Secondary/forgotten nameservers are frequently laxer than
+// the primary, so every nameserver of the zone is tried.
+package axfr
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+// Config controls a zone-transfer attempt.
+type Config struct {
+ // Zone is the apex to transfer (e.g. "example.com").
+ Zone string
+ // Resolvers are used to discover the zone's NS set and resolve nameserver
+ // addresses when Nameservers is not supplied (host or host:port).
+ Resolvers []string
+ // Nameservers optionally lists explicit nameserver addresses to try
+ // (host or host:port); when set, NS discovery is skipped.
+ Nameservers []string
+ // Timeout is the per-nameserver transfer timeout. Default 10s.
+ Timeout time.Duration
+ // MaxNames caps the owner names kept from a single transfer, bounding memory
+ // against a hostile server that streams an endless zone. Default 1,000,000.
+ MaxNames int
+ // OnName fires for each newly discovered owner name (deduplicated).
+ OnName func(string)
+ // OnNameserver fires after each nameserver attempt with its outcome.
+ OnNameserver func(ns string, names int, err error)
+}
+
+// Result is the outcome of a successful zone transfer.
+type Result struct {
+ Zone string
+ Nameserver string // nameserver that allowed the transfer
+ Names []string // unique owner names within the zone (no trailing dot)
+ Records int // total resource records transferred
+}
+
+// Attempt tries to transfer the zone from each of its nameservers and returns
+// the first successful transfer. It returns an error only when no nameserver
+// allowed the transfer (the common case for well-configured zones).
+func Attempt(ctx context.Context, cfg Config) (*Result, error) {
+ if strings.TrimSpace(cfg.Zone) == "" {
+ return nil, fmt.Errorf("zone is required")
+ }
+ if cfg.Timeout <= 0 {
+ cfg.Timeout = 10 * time.Second
+ }
+ if cfg.MaxNames <= 0 {
+ cfg.MaxNames = 1_000_000
+ }
+ apex := dns.Fqdn(strings.ToLower(cfg.Zone))
+
+ servers := cfg.Nameservers
+ if len(servers) == 0 {
+ var err error
+ servers, err = discoverNameservers(ctx, apex, cfg.Resolvers, cfg.Timeout)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if len(servers) == 0 {
+ return nil, fmt.Errorf("no nameservers found for %s", cfg.Zone)
+ }
+
+ var lastErr error
+ for _, ns := range servers {
+ res, err := transferFrom(ctx, apex, withPort(ns), cfg)
+ if cfg.OnNameserver != nil {
+ n := 0
+ if res != nil {
+ n = len(res.Names)
+ }
+ cfg.OnNameserver(ns, n, err)
+ }
+ if err == nil && res != nil && len(res.Names) > 0 {
+ res.Zone = cfg.Zone
+ return res, nil
+ }
+ if err != nil {
+ lastErr = err
+ }
+ }
+ if lastErr == nil {
+ lastErr = fmt.Errorf("no nameserver allowed transfer of %s", cfg.Zone)
+ }
+ return nil, lastErr
+}
+
+// transferFrom performs the AXFR (falling back to IXFR) against one nameserver.
+func transferFrom(ctx context.Context, apex, nsAddr string, cfg Config) (*Result, error) {
+ run := func(setup func(*dns.Msg)) (*Result, error) {
+ t := &dns.Transfer{DialTimeout: cfg.Timeout, ReadTimeout: cfg.Timeout, WriteTimeout: cfg.Timeout}
+ m := new(dns.Msg)
+ setup(m)
+ ch, err := t.In(m, nsAddr)
+ if err != nil {
+ return nil, err
+ }
+ res := &Result{Nameserver: nsAddr}
+ seen := map[string]struct{}{}
+ // abandoning `ch` mid-stream would block miekg's sender goroutine forever
+ // on its unbuffered channel send (leaking the goroutine and the TCP conn).
+ // stop() unblocks that send by draining and closes the conn so the sender
+ // returns promptly instead of waiting out another read timeout.
+ stopped := false
+ stop := func() {
+ if stopped {
+ return
+ }
+ stopped = true
+ go func() {
+ for range ch {
+ }
+ }()
+ if t.Conn != nil {
+ _ = t.Close()
+ }
+ }
+ for env := range ch {
+ if env.Error != nil {
+ if len(res.Names) > 0 {
+ return res, nil // partial transfer is still useful
+ }
+ return nil, env.Error
+ }
+ for _, rr := range env.RR {
+ res.Records++
+ owner := strings.TrimSuffix(strings.ToLower(rr.Header().Name), ".")
+ name := dns.Fqdn(strings.ToLower(rr.Header().Name))
+ if !inZone(name, apex) {
+ continue
+ }
+ if _, dup := seen[owner]; dup {
+ continue
+ }
+ seen[owner] = struct{}{}
+ res.Names = append(res.Names, owner)
+ if cfg.OnName != nil {
+ cfg.OnName(owner)
+ }
+ if len(res.Names) >= cfg.MaxNames {
+ stop()
+ return res, nil
+ }
+ }
+ if err := ctx.Err(); err != nil {
+ stop()
+ return res, err
+ }
+ }
+ return res, nil
+ }
+
+ res, err := run(func(m *dns.Msg) { m.SetAxfr(apex) })
+ if err == nil && res != nil && len(res.Names) > 0 {
+ return res, nil
+ }
+ // some servers refuse AXFR but permit IXFR; try it as a fallback.
+ if res2, err2 := run(func(m *dns.Msg) { m.SetIxfr(apex, 0, "", "") }); err2 == nil && res2 != nil && len(res2.Names) > 0 {
+ return res2, nil
+ }
+ return res, err
+}
+
+// discoverNameservers resolves the zone's NS set and their addresses.
+func discoverNameservers(ctx context.Context, apex string, resolvers []string, timeout time.Duration) ([]string, error) {
+ if len(resolvers) == 0 {
+ return nil, fmt.Errorf("resolvers are required to discover nameservers (or pass Nameservers)")
+ }
+ server := withPort(resolvers[0])
+ client := &dns.Client{Timeout: timeout}
+
+ m := new(dns.Msg)
+ m.SetQuestion(apex, dns.TypeNS)
+ m.RecursionDesired = true
+ resp, _, err := client.ExchangeContext(ctx, m, server)
+ if err != nil {
+ return nil, fmt.Errorf("NS lookup for %s failed: %w", apex, err)
+ }
+
+ var nsNames []string
+ for _, rr := range resp.Answer {
+ if ns, ok := rr.(*dns.NS); ok {
+ nsNames = append(nsNames, ns.Ns)
+ }
+ }
+
+ var addrs []string
+ seen := map[string]struct{}{}
+ for _, ns := range nsNames {
+ for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} {
+ am := new(dns.Msg)
+ am.SetQuestion(dns.Fqdn(ns), qt)
+ am.RecursionDesired = true
+ ar, _, aerr := client.ExchangeContext(ctx, am, server)
+ if aerr != nil {
+ continue
+ }
+ for _, rr := range ar.Answer {
+ var ip string
+ switch v := rr.(type) {
+ case *dns.A:
+ ip = v.A.String()
+ case *dns.AAAA:
+ ip = v.AAAA.String()
+ }
+ if ip == "" {
+ continue
+ }
+ if _, dup := seen[ip]; dup {
+ continue
+ }
+ seen[ip] = struct{}{}
+ addrs = append(addrs, ip)
+ }
+ }
+ }
+ return addrs, nil
+}
+
+func inZone(name, apex string) bool {
+ name, apex = dns.Fqdn(strings.ToLower(name)), dns.Fqdn(strings.ToLower(apex))
+ return name == apex || strings.HasSuffix(name, "."+apex)
+}
+
+func withPort(s string) string {
+ s = strings.TrimSpace(s)
+ if _, _, err := net.SplitHostPort(s); err != nil {
+ return net.JoinHostPort(s, "53")
+ }
+ return s
+}
diff --git a/pkg/axfr/axfr_test.go b/pkg/axfr/axfr_test.go
new file mode 100644
index 00000000..fec1137a
--- /dev/null
+++ b/pkg/axfr/axfr_test.go
@@ -0,0 +1,102 @@
+package axfr
+
+import (
+ "context"
+ "net"
+ "testing"
+
+ "github.com/miekg/dns"
+)
+
+// startTCPServer starts a TCP DNS server (AXFR runs over TCP) and returns its addr.
+func startTCPServer(t *testing.T, handler dns.HandlerFunc) (string, func()) {
+ t.Helper()
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ srv := &dns.Server{Listener: l, Handler: handler}
+ go func() { _ = srv.ActivateAndServe() }()
+ return l.Addr().String(), func() { _ = srv.Shutdown() }
+}
+
+func mustRR(t *testing.T, s string) dns.RR {
+ t.Helper()
+ rr, err := dns.NewRR(s)
+ if err != nil {
+ t.Fatalf("NewRR(%q): %v", s, err)
+ }
+ return rr
+}
+
+func TestAttemptAXFR(t *testing.T) {
+ zone := "example.com."
+ records := func() []dns.RR {
+ return []dns.RR{
+ mustRR(t, "example.com. 3600 IN SOA ns1.example.com. admin.example.com. 1 3600 600 86400 60"),
+ mustRR(t, "example.com. 3600 IN NS ns1.example.com."),
+ mustRR(t, "www.example.com. 3600 IN A 93.184.216.34"),
+ mustRR(t, "mail.example.com. 3600 IN A 93.184.216.35"),
+ mustRR(t, "ftp.example.com. 3600 IN CNAME www.example.com."),
+ mustRR(t, "example.com. 3600 IN SOA ns1.example.com. admin.example.com. 1 3600 600 86400 60"),
+ }
+ }
+
+ addr, stop := startTCPServer(t, func(w dns.ResponseWriter, r *dns.Msg) {
+ if r.Question[0].Qtype == dns.TypeAXFR {
+ ch := make(chan *dns.Envelope)
+ tr := new(dns.Transfer)
+ go func() {
+ ch <- &dns.Envelope{RR: records()}
+ close(ch)
+ }()
+ _ = tr.Out(w, r, ch)
+ return
+ }
+ m := new(dns.Msg)
+ m.SetReply(r)
+ m.Rcode = dns.RcodeRefused
+ _ = w.WriteMsg(m)
+ })
+ defer stop()
+
+ var streamed []string
+ res, err := Attempt(context.Background(), Config{
+ Zone: zone,
+ Nameservers: []string{addr},
+ OnName: func(n string) { streamed = append(streamed, n) },
+ })
+ if err != nil {
+ t.Fatalf("Attempt: %v", err)
+ }
+ want := map[string]bool{"www.example.com": false, "mail.example.com": false, "ftp.example.com": false, "example.com": false}
+ for _, n := range res.Names {
+ if _, ok := want[n]; ok {
+ want[n] = true
+ }
+ }
+ for n, found := range want {
+ if !found {
+ t.Errorf("expected %q in transferred names %v", n, res.Names)
+ }
+ }
+ if len(streamed) != len(res.Names) {
+ t.Errorf("OnName fired %d times, result has %d names", len(streamed), len(res.Names))
+ }
+}
+
+func TestAttemptRefused(t *testing.T) {
+ // a server that refuses transfers must yield an error, not a panic/partial.
+ addr, stop := startTCPServer(t, func(w dns.ResponseWriter, r *dns.Msg) {
+ m := new(dns.Msg)
+ m.SetReply(r)
+ m.Rcode = dns.RcodeRefused
+ _ = w.WriteMsg(m)
+ })
+ defer stop()
+
+ _, err := Attempt(context.Background(), Config{Zone: "secure.example.", Nameservers: []string{addr}})
+ if err == nil {
+ t.Fatal("expected error when transfer is refused")
+ }
+}
diff --git a/pkg/checkpoint/checkpoint.go b/pkg/checkpoint/checkpoint.go
new file mode 100644
index 00000000..a9f54c0e
--- /dev/null
+++ b/pkg/checkpoint/checkpoint.go
@@ -0,0 +1,151 @@
+// Package checkpoint provides crash-safe stop/resume for long resolution runs.
+//
+// The model is an append-only log of completed names. A name is "completed" once
+// it reaches a terminal state (an answer or a definitive give-up), at which point
+// the consumer calls Done. On a later run, Open replays the log so the producer
+// can skip names that already finished via Has.
+//
+// Semantics are at-least-once: only names recorded as done are skipped, so any
+// name that was in flight when the process died is simply re-resolved on resume.
+// Nothing is ever lost (no false negatives); at worst a small in-flight window is
+// repeated, which is harmless because downstream dedup absorbs it.
+package checkpoint
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+ "time"
+)
+
+// flushEvery bounds how many completions buffer before a forced flush, so a
+// crash loses at most this many log records (which are re-resolved anyway).
+const flushEvery = 1024
+
+// Checkpoint is a resumable completed-name log. It is safe for concurrent use.
+type Checkpoint struct {
+ mu sync.Mutex
+ done map[string]struct{}
+ f *os.File
+ w *bufio.Writer
+ pending int
+ closed bool
+}
+
+// Open opens (creating if needed) the checkpoint at path and loads any previously
+// completed names from it. Subsequent writes append to the same file.
+func Open(path string) (*Checkpoint, error) {
+ if strings.TrimSpace(path) == "" {
+ return nil, fmt.Errorf("checkpoint path is required")
+ }
+ c := &Checkpoint{done: make(map[string]struct{})}
+
+ // load existing entries (resume)
+ if f, err := os.Open(path); err == nil {
+ sc := bufio.NewScanner(f)
+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for sc.Scan() {
+ if line := strings.TrimSpace(sc.Text()); line != "" {
+ c.done[line] = struct{}{}
+ }
+ }
+ _ = f.Close()
+ if err := sc.Err(); err != nil {
+ return nil, fmt.Errorf("could not read checkpoint: %w", err)
+ }
+ } else if !os.IsNotExist(err) {
+ return nil, fmt.Errorf("could not open checkpoint: %w", err)
+ }
+
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("could not open checkpoint for append: %w", err)
+ }
+ c.f = f
+ c.w = bufio.NewWriterSize(f, 64*1024)
+ return c, nil
+}
+
+// Resumed reports the number of completed names loaded from a prior run.
+func (c *Checkpoint) Resumed() int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return len(c.done)
+}
+
+// Has reports whether name already completed in this or a prior run.
+func (c *Checkpoint) Has(name string) bool {
+ c.mu.Lock()
+ _, ok := c.done[name]
+ c.mu.Unlock()
+ return ok
+}
+
+// Done records name as completed. It returns false if the name was already
+// recorded (so callers can detect duplicates). Records are buffered and flushed
+// periodically; Close guarantees durability of everything recorded.
+func (c *Checkpoint) Done(name string) bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.closed {
+ return false
+ }
+ if _, ok := c.done[name]; ok {
+ return false
+ }
+ c.done[name] = struct{}{}
+ _, _ = c.w.WriteString(name)
+ _ = c.w.WriteByte('\n')
+ c.pending++
+ if c.pending >= flushEvery {
+ _ = c.w.Flush()
+ c.pending = 0
+ }
+ return true
+}
+
+// Flush flushes buffered records to disk.
+func (c *Checkpoint) Flush() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.closed {
+ return nil
+ }
+ c.pending = 0
+ return c.w.Flush()
+}
+
+// Close flushes and closes the underlying file.
+func (c *Checkpoint) Close() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.closed {
+ return nil
+ }
+ c.closed = true
+ if err := c.w.Flush(); err != nil {
+ _ = c.f.Close()
+ return err
+ }
+ return c.f.Close()
+}
+
+// FlushPeriodically flushes the log on the given interval until stop is closed.
+// Useful for bounding data loss on a crash during very long, low-completion runs.
+func (c *Checkpoint) FlushPeriodically(interval time.Duration, stop <-chan struct{}) {
+ if interval <= 0 {
+ return
+ }
+ t := time.NewTicker(interval)
+ defer t.Stop()
+ for {
+ select {
+ case <-stop:
+ return
+ case <-t.C:
+ _ = c.Flush()
+ }
+ }
+}
diff --git a/pkg/checkpoint/checkpoint_test.go b/pkg/checkpoint/checkpoint_test.go
new file mode 100644
index 00000000..a55cbb08
--- /dev/null
+++ b/pkg/checkpoint/checkpoint_test.go
@@ -0,0 +1,128 @@
+package checkpoint
+
+import (
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+func TestDoneAndHas(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "cp.log")
+ c, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Has("a.com") {
+ t.Fatal("fresh checkpoint should not have anything")
+ }
+ if !c.Done("a.com") {
+ t.Fatal("first Done should report newly-completed")
+ }
+ if c.Done("a.com") {
+ t.Fatal("second Done should report already-completed")
+ }
+ if !c.Has("a.com") {
+ t.Fatal("Has should see completed name")
+ }
+ if err := c.Close(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestResume(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "cp.log")
+ c, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, n := range []string{"a.com", "b.com", "c.com"} {
+ c.Done(n)
+ }
+ if err := c.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ // reopen: prior completions must be visible
+ c2, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = c2.Close() }()
+ if c2.Resumed() != 3 {
+ t.Fatalf("expected 3 resumed, got %d", c2.Resumed())
+ }
+ for _, n := range []string{"a.com", "b.com", "c.com"} {
+ if !c2.Has(n) {
+ t.Fatalf("resumed checkpoint missing %q", n)
+ }
+ }
+ if c2.Has("d.com") {
+ t.Fatal("should not have uncompleted name")
+ }
+}
+
+// TestCrashSafety simulates a crash (no Close) after enough completions to force
+// at least one buffer flush; those must survive on reopen.
+func TestCrashSafety(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "cp.log")
+ c, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ const n = flushEvery + 10
+ for i := 0; i < n; i++ {
+ c.Done(itoa(i))
+ }
+ // no Close(): only flushed records (>= flushEvery) are guaranteed durable.
+ c2, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = c2.Close() }()
+ if c2.Resumed() < flushEvery {
+ t.Fatalf("expected at least %d durable records, got %d", flushEvery, c2.Resumed())
+ }
+}
+
+func TestConcurrentDone(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "cp.log")
+ c, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = c.Close() }()
+ var wg sync.WaitGroup
+ for g := 0; g < 8; g++ {
+ wg.Add(1)
+ go func(g int) {
+ defer wg.Done()
+ for i := 0; i < 1000; i++ {
+ c.Done(itoa(g*1000 + i))
+ }
+ }(g)
+ }
+ wg.Wait()
+ if got := c.Resumed(); got != 8000 {
+ t.Fatalf("expected 8000 unique completions, got %d", got)
+ }
+}
+
+func TestOpenRequiresPath(t *testing.T) {
+ if _, err := Open(""); err == nil {
+ t.Fatal("expected error for empty path")
+ }
+}
+
+func itoa(i int) string {
+ if i == 0 {
+ return "0"
+ }
+ var b [20]byte
+ pos := len(b)
+ for i > 0 {
+ pos--
+ b[pos] = byte('0' + i%10)
+ i /= 10
+ }
+ return "host-" + string(b[pos:])
+}
diff --git a/pkg/iterative/bench_test.go b/pkg/iterative/bench_test.go
new file mode 100644
index 00000000..c6226acf
--- /dev/null
+++ b/pkg/iterative/bench_test.go
@@ -0,0 +1,130 @@
+package iterative
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+)
+
+// buildBenchHierarchy creates a wide tree: one root, one .com TLD, and `domains`
+// registrable domains each served by its own authoritative server with `hosts`
+// names. This models a real bruteforce workload (many names under relatively
+// few registrable domains), where the delegation cache should collapse to ~1
+// upstream query per name after warmup.
+func buildBenchHierarchy(domains, hosts int) (*memHierarchy, []string) {
+ h := newHierarchy()
+ const rootIP = "127.0.0.1"
+ const comIP = "127.0.1.1"
+
+ root := h.server(rootIP).authoritative(".")
+ root.delegate("com.", memNS{name: "a.gtld.net.", ip: comIP})
+
+ com := h.server(comIP).authoritative("com.")
+
+ var names []string
+ for d := 0; d < domains; d++ {
+ zone := fmt.Sprintf("d%d.com.", d)
+ authIP := ip4(4096 + d) // distinct 127.0.x.y per domain
+ com.delegate(zone, memNS{name: fmt.Sprintf("ns.%s", zone), ip: authIP})
+ auth := h.server(authIP).authoritative(zone)
+ auth.a(fmt.Sprintf("ns.%s", zone), authIP)
+ for n := 0; n < hosts; n++ {
+ name := fmt.Sprintf("host%d.d%d.com", n, d)
+ auth.a(canonical(name), fmt.Sprintf("10.%d.%d.%d", byte(d>>8), byte(d), n%254+1))
+ names = append(names, name)
+ }
+ }
+ return h, names
+}
+
+// TestIterativeBenchmark reports throughput, TTFR, and the cache win
+// (upstream queries per name). Gated behind ITER_BENCH=1 so it doesn't run in
+// normal CI. No real traffic: the hierarchy is fully in-memory.
+//
+// ITER_BENCH=1 go test ./pkg/iterative -run TestIterativeBenchmark -v
+func TestIterativeBenchmark(t *testing.T) {
+ if os.Getenv("ITER_BENCH") == "" {
+ t.Skip("set ITER_BENCH=1 to run the iterative resolver benchmark")
+ }
+ domains, hosts, concurrency := 200, 500, 256
+ h, names := buildBenchHierarchy(domains, hosts)
+
+ r, err := New(Options{
+ RootServers: []string{"127.0.0.1"},
+ QueryType: dns.TypeA,
+ Concurrency: concurrency,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.newExchanger = h.factory()
+
+ in := make(chan string, 4096)
+ go func() {
+ defer close(in)
+ for _, n := range names {
+ in <- n
+ }
+ }()
+
+ var resolved, ttfrOnce atomic.Int64
+ start := time.Now()
+ var ttfr time.Duration
+ err = r.ResolveStream(context.Background(), in, StreamConfig{
+ OnResult: func(res *resolve.Result) {
+ if len(res.A) > 0 {
+ if ttfrOnce.CompareAndSwap(0, 1) {
+ ttfr = time.Since(start)
+ }
+ resolved.Add(1)
+ }
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ wall := time.Since(start)
+
+ total := len(names)
+ upstream := h.queries.Load()
+ t.Logf("names=%d domains=%d hosts/domain=%d concurrency=%d", total, domains, hosts, concurrency)
+ t.Logf("resolved=%d wall=%s qps=%.0f ttfr=%s", resolved.Load(), wall.Round(time.Millisecond), float64(total)/wall.Seconds(), ttfr.Round(time.Microsecond))
+ t.Logf("upstream queries=%d queries/name=%.3f (cache win: ->1.0 means root/TLD/zone walked once)", upstream, float64(upstream)/float64(total))
+
+ if resolved.Load() != int64(total) {
+ t.Fatalf("expected all %d names resolved, got %d", total, resolved.Load())
+ }
+}
+
+// BenchmarkIterativeStream is a standard go benchmark of warm-cache resolution
+// throughput (algorithm + cache + concurrency overhead, no network).
+func BenchmarkIterativeStream(b *testing.B) {
+ h, names := buildBenchHierarchy(50, 200)
+ r, _ := New(Options{RootServers: []string{"127.0.0.1"}, QueryType: dns.TypeA, Concurrency: 128})
+ r.newExchanger = h.factory()
+
+ // warm the cache once
+ warm := make(chan string, len(names))
+ for _, n := range names {
+ warm <- n
+ }
+ close(warm)
+ _ = r.ResolveStream(context.Background(), warm, StreamConfig{OnResult: func(*resolve.Result) {}})
+
+ b.ResetTimer()
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ in := make(chan string, len(names))
+ for _, n := range names {
+ in <- n
+ }
+ close(in)
+ _ = r.ResolveStream(context.Background(), in, StreamConfig{OnResult: func(*resolve.Result) {}})
+ }
+}
diff --git a/pkg/iterative/cache.go b/pkg/iterative/cache.go
new file mode 100644
index 00000000..4319ab39
--- /dev/null
+++ b/pkg/iterative/cache.go
@@ -0,0 +1,109 @@
+package iterative
+
+import (
+ "net"
+ "strings"
+ "time"
+
+ lru "github.com/hashicorp/golang-lru/v2"
+)
+
+// nsEntry is a single nameserver of a delegation: its name plus any glue
+// addresses learned from the parent zone (empty when the delegation is
+// glueless and the address must be resolved separately).
+type nsEntry struct {
+ name string
+ addrs []net.IP
+}
+
+// delegation is the set of nameservers authoritative for (or one step closer
+// to) a zone, as learned from a referral. It is the unit cached and shared
+// across all in-flight resolutions, which is what amortizes the cost of
+// re-walking root/TLD for every name (the key win over a per-name resolver).
+type delegation struct {
+ zone string // canonical zone (lowercase, trailing dot)
+ ns []nsEntry // nameservers, some possibly with glue
+ expiry time.Time // zero == never expires (root hints)
+}
+
+func (d *delegation) expired(now time.Time) bool {
+ return !d.expiry.IsZero() && now.After(d.expiry)
+}
+
+// cache is a bounded, TTL-aware store of delegations keyed by canonical zone.
+// It is safe for concurrent use (the underlying LRU is locked) and shared by
+// every worker so that common ancestors (root, TLDs, popular zones) are walked
+// once and reused.
+type cache struct {
+ lru *lru.Cache[string, *delegation]
+}
+
+func newCache(size int) *cache {
+ if size <= 0 {
+ size = 1 << 16
+ }
+ l, _ := lru.New[string, *delegation](size)
+ return &cache{lru: l}
+}
+
+func (c *cache) put(d *delegation) {
+ if d == nil || d.zone == "" {
+ return
+ }
+ c.lru.Add(canonical(d.zone), d)
+}
+
+// best returns the deepest (closest to name) non-expired cached delegation,
+// walking ancestor labels of name. It returns nil when nothing is cached.
+func (c *cache) best(name string) *delegation {
+ now := time.Now()
+ for z := canonical(name); ; z = parentZone(z) {
+ if d, ok := c.lru.Get(z); ok {
+ if d.expired(now) {
+ c.lru.Remove(z)
+ } else {
+ return d
+ }
+ }
+ if z == "." {
+ return nil
+ }
+ }
+}
+
+// canonical lowercases a name and ensures a single trailing dot. The root is ".".
+func canonical(name string) string {
+ if name == "" || name == "." {
+ return "."
+ }
+ name = strings.ToLower(name)
+ if !strings.HasSuffix(name, ".") {
+ name += "."
+ }
+ return name
+}
+
+// parentZone returns the parent of a canonical zone ("a.b.c." -> "b.c."); the
+// parent of a TLD (or root) is the root ".".
+func parentZone(zone string) string {
+ if zone == "." || zone == "" {
+ return "."
+ }
+ zone = strings.TrimSuffix(zone, ".")
+ i := strings.IndexByte(zone, '.')
+ if i < 0 {
+ return "."
+ }
+ return zone[i+1:] + "."
+}
+
+// inBailiwick reports whether child is equal to or a subdomain of parent. It is
+// used to reject out-of-bailiwick referrals and glue (a core anti-poisoning
+// check): an authority can only delegate names within its own zone.
+func inBailiwick(child, parent string) bool {
+ child, parent = canonical(child), canonical(parent)
+ if parent == "." {
+ return true
+ }
+ return child == parent || strings.HasSuffix(child, "."+parent)
+}
diff --git a/pkg/iterative/cache_test.go b/pkg/iterative/cache_test.go
new file mode 100644
index 00000000..69abf5cb
--- /dev/null
+++ b/pkg/iterative/cache_test.go
@@ -0,0 +1,70 @@
+package iterative
+
+import (
+ "testing"
+ "time"
+)
+
+func TestCanonicalAndParent(t *testing.T) {
+ cases := map[string]string{
+ "WWW.Example.COM": "www.example.com.",
+ "example.com.": "example.com.",
+ "": ".",
+ ".": ".",
+ }
+ for in, want := range cases {
+ if got := canonical(in); got != want {
+ t.Errorf("canonical(%q)=%q want %q", in, got, want)
+ }
+ }
+ parents := map[string]string{
+ "a.b.c.": "b.c.",
+ "example.com.": "com.",
+ "com.": ".",
+ ".": ".",
+ }
+ for in, want := range parents {
+ if got := parentZone(in); got != want {
+ t.Errorf("parentZone(%q)=%q want %q", in, got, want)
+ }
+ }
+}
+
+func TestInBailiwick(t *testing.T) {
+ yes := [][2]string{{"a.example.com", "example.com"}, {"example.com", "example.com"}, {"x.y.com", "com"}, {"anything", "."}}
+ no := [][2]string{{"example.org", "example.com"}, {"com", "example.com"}, {"notexample.com", "example.com"}}
+ for _, c := range yes {
+ if !inBailiwick(c[0], c[1]) {
+ t.Errorf("inBailiwick(%q,%q) = false, want true", c[0], c[1])
+ }
+ }
+ for _, c := range no {
+ if inBailiwick(c[0], c[1]) {
+ t.Errorf("inBailiwick(%q,%q) = true, want false", c[0], c[1])
+ }
+ }
+}
+
+func TestCacheDeepestAncestor(t *testing.T) {
+ c := newCache(1024)
+ c.put(&delegation{zone: "com."})
+ c.put(&delegation{zone: "example.com."})
+
+ if d := c.best("www.example.com"); d == nil || d.zone != "example.com." {
+ t.Fatalf("expected deepest match example.com., got %#v", d)
+ }
+ if d := c.best("host.other.com"); d == nil || d.zone != "com." {
+ t.Fatalf("expected com., got %#v", d)
+ }
+ if d := c.best("nothing.org"); d != nil {
+ t.Fatalf("expected nil for uncached tree, got %#v", d)
+ }
+}
+
+func TestCacheTTLExpiry(t *testing.T) {
+ c := newCache(1024)
+ c.put(&delegation{zone: "example.com.", expiry: time.Now().Add(-time.Second)})
+ if d := c.best("a.example.com"); d != nil {
+ t.Fatalf("expired delegation should not be returned, got %#v", d)
+ }
+}
diff --git a/pkg/iterative/hierarchy_test.go b/pkg/iterative/hierarchy_test.go
new file mode 100644
index 00000000..c1bfb3a6
--- /dev/null
+++ b/pkg/iterative/hierarchy_test.go
@@ -0,0 +1,183 @@
+package iterative
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/netip"
+ "sync/atomic"
+
+ "github.com/miekg/dns"
+)
+
+// memHierarchy is an in-memory authoritative DNS tree used to test and
+// benchmark the iterative resolver without any real sockets or internet
+// traffic. Each "server" is addressed by a synthetic loopback IP; the resolver
+// reaches it through the injected exchanger, which routes by destination IP and
+// role-plays the appropriate authoritative behaviour (referral / answer /
+// NODATA / NXDOMAIN).
+type memHierarchy struct {
+ servers map[string]*memServer // keyed by IP string
+ queries atomic.Int64 // total queries served (for cache-win assertions)
+}
+
+type memNS struct {
+ name string
+ ip string // "" == glueless
+}
+
+type memServer struct {
+ ip string
+ authZones []string
+ delegations map[string][]memNS // childZone -> nameservers
+ records map[string]map[uint16][]string
+}
+
+func newHierarchy() *memHierarchy { return &memHierarchy{servers: map[string]*memServer{}} }
+
+func (h *memHierarchy) server(ip string) *memServer {
+ s, ok := h.servers[ip]
+ if !ok {
+ s = &memServer{ip: ip, delegations: map[string][]memNS{}, records: map[string]map[uint16][]string{}}
+ h.servers[ip] = s
+ }
+ return s
+}
+
+func (s *memServer) authoritative(zones ...string) *memServer {
+ for _, z := range zones {
+ s.authZones = append(s.authZones, canonical(z))
+ }
+ return s
+}
+
+func (s *memServer) delegate(child string, ns ...memNS) *memServer {
+ s.delegations[canonical(child)] = append(s.delegations[canonical(child)], ns...)
+ return s
+}
+
+func (s *memServer) rr(name string, qtype uint16, data ...string) *memServer {
+ name = canonical(name)
+ if s.records[name] == nil {
+ s.records[name] = map[uint16][]string{}
+ }
+ s.records[name][qtype] = append(s.records[name][qtype], data...)
+ return s
+}
+
+func (s *memServer) a(name string, ips ...string) *memServer { return s.rr(name, dns.TypeA, ips...) }
+func (s *memServer) cname(name, target string) *memServer { return s.rr(name, dns.TypeCNAME, target) }
+
+func (s *memServer) hasAny(name string) bool {
+ _, ok := s.records[canonical(name)]
+ return ok
+}
+
+// exchange implements the exchanger interface for the resolver.
+func (h *memHierarchy) exchange(ctx context.Context, server netip.AddrPort, msg *dns.Msg) (*dns.Msg, error) {
+ h.queries.Add(1)
+ srv := h.servers[server.Addr().Unmap().String()]
+ m := new(dns.Msg)
+ m.SetReply(msg)
+ m.RecursionAvailable = false
+ if srv == nil {
+ return nil, errTimeout // unreachable server (no response)
+ }
+ q := msg.Question[0]
+ qname := canonical(q.Name)
+ qtype := q.Qtype
+
+ // most specific authoritative zone for the name
+ authZone := ""
+ for _, z := range srv.authZones {
+ if inBailiwick(qname, z) && len(z) > len(authZone) {
+ authZone = z
+ }
+ }
+ // most specific delegation that is an ancestor of the name
+ bestChild := ""
+ for child := range srv.delegations {
+ if inBailiwick(qname, child) && len(child) > len(bestChild) {
+ bestChild = child
+ }
+ }
+
+ // referral: a delegation more specific than what we serve authoritatively
+ if bestChild != "" && len(bestChild) > len(authZone) {
+ for _, ns := range srv.delegations[bestChild] {
+ m.Ns = append(m.Ns, mustRR(fmt.Sprintf("%s 3600 IN NS %s", bestChild, ns.name)))
+ if ns.ip != "" {
+ m.Extra = append(m.Extra, mustRR(fmt.Sprintf("%s 3600 IN A %s", ns.name, ns.ip)))
+ }
+ }
+ return m, nil
+ }
+
+ if authZone == "" {
+ m.Rcode = dns.RcodeServerFailure
+ return m, nil
+ }
+ m.Authoritative = true
+
+ if recs := srv.records[qname][qtype]; len(recs) > 0 {
+ for _, d := range recs {
+ m.Answer = append(m.Answer, mustRR(rrString(qname, qtype, d)))
+ }
+ return m, nil
+ }
+ // CNAME indirection
+ if cns := srv.records[qname][dns.TypeCNAME]; len(cns) > 0 && qtype != dns.TypeCNAME {
+ tgt := canonical(cns[0])
+ m.Answer = append(m.Answer, mustRR(fmt.Sprintf("%s 3600 IN CNAME %s", qname, tgt)))
+ // include in-zone target records (as a real authoritative server would)
+ if inBailiwick(tgt, authZone) {
+ for _, d := range srv.records[tgt][qtype] {
+ m.Answer = append(m.Answer, mustRR(rrString(tgt, qtype, d)))
+ }
+ }
+ return m, nil
+ }
+
+ // NODATA (name exists, type doesn't) vs NXDOMAIN
+ if !srv.hasAny(qname) {
+ m.Rcode = dns.RcodeNameError
+ }
+ m.Ns = append(m.Ns, mustRR(fmt.Sprintf("%s 3600 IN SOA ns.%s hostmaster.%s 1 3600 600 86400 60", authZone, authZone, authZone)))
+ return m, nil
+}
+
+func (h *memHierarchy) close() {}
+
+// factory returns a newExchanger function that hands every worker the shared
+// in-memory hierarchy (no per-worker socket).
+func (h *memHierarchy) factory() func() (exchanger, error) {
+ return func() (exchanger, error) { return h, nil }
+}
+
+func rrString(name string, qtype uint16, data string) string {
+ switch qtype {
+ case dns.TypeA:
+ return fmt.Sprintf("%s 60 IN A %s", name, data)
+ case dns.TypeAAAA:
+ return fmt.Sprintf("%s 60 IN AAAA %s", name, data)
+ case dns.TypeTXT:
+ return fmt.Sprintf("%s 60 IN TXT \"%s\"", name, data)
+ case dns.TypeNS:
+ return fmt.Sprintf("%s 60 IN NS %s", name, data)
+ case dns.TypeMX:
+ return fmt.Sprintf("%s 60 IN MX 10 %s", name, data)
+ default:
+ return fmt.Sprintf("%s 60 IN A %s", name, data)
+ }
+}
+
+func mustRR(s string) dns.RR {
+ rr, err := dns.NewRR(s)
+ if err != nil {
+ panic(err)
+ }
+ return rr
+}
+
+// ip4 generates distinct synthetic loopback IPs for hierarchy servers.
+func ip4(n int) string { return net.IPv4(127, 0, byte(n>>8), byte(n)).String() }
diff --git a/pkg/iterative/parse.go b/pkg/iterative/parse.go
new file mode 100644
index 00000000..5bd881f6
--- /dev/null
+++ b/pkg/iterative/parse.go
@@ -0,0 +1,165 @@
+package iterative
+
+import (
+ "strings"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+)
+
+// chaseCNAME follows a CNAME chain present in answer starting at start and
+// returns the final target name plus the chain of CNAME targets traversed. If
+// there is no CNAME for start, it returns (start, nil).
+func chaseCNAME(answer []dns.RR, start string) (string, []string) {
+ cur := canonical(start)
+ var chain []string
+ seen := map[string]struct{}{cur: {}}
+ for {
+ var next string
+ for _, rr := range answer {
+ c, ok := rr.(*dns.CNAME)
+ if !ok {
+ continue
+ }
+ if canonical(c.Header().Name) == cur {
+ next = canonical(c.Target)
+ break
+ }
+ }
+ if next == "" {
+ return cur, chain
+ }
+ if _, dup := seen[next]; dup {
+ return cur, chain
+ }
+ seen[next] = struct{}{}
+ chain = append(chain, strings.TrimSuffix(next, "."))
+ cur = next
+ }
+}
+
+// answersType reports whether answer contains at least one record of qtype
+// owned by name.
+func answersType(answer []dns.RR, name string, qtype uint16) bool {
+ name = canonical(name)
+ for _, rr := range answer {
+ if rr.Header().Rrtype == qtype && canonical(rr.Header().Name) == name {
+ return true
+ }
+ }
+ return false
+}
+
+// buildResult converts a final authoritative response into a resolve.Result,
+// collecting every record type so the output formatter can render faithfully.
+// cnameChain holds CNAME targets traversed before the final answer.
+func buildResult(name string, qtype uint16, resp *dns.Msg, server string, cnameChain []string) *resolve.Result {
+ r := &resolve.Result{
+ Name: strings.TrimSuffix(canonical(name), "."),
+ Type: qtype,
+ Rcode: resp.Rcode,
+ Resolver: server,
+ Msg: resp,
+ Timestamp: time.Now(),
+ CNAME: append([]string{}, cnameChain...),
+ }
+ // Only harvest records owned by the queried name or a name reached through
+ // its CNAME chain. Without this an in-bailiwick-but-hostile (or lame)
+ // authoritative could staple unrelated A/AAAA records for other owners into
+ // this name's result.
+ validOwners := map[string]struct{}{canonical(name): {}}
+ for _, c := range cnameChain {
+ validOwners[canonical(c)] = struct{}{}
+ }
+ for changed := true; changed; {
+ changed = false
+ for _, rr := range resp.Answer {
+ c, ok := rr.(*dns.CNAME)
+ if !ok {
+ continue
+ }
+ if _, in := validOwners[canonical(c.Header().Name)]; !in {
+ continue
+ }
+ if tgt := canonical(c.Target); !mapHas(validOwners, tgt) {
+ validOwners[tgt] = struct{}{}
+ changed = true
+ }
+ }
+ }
+ for _, rr := range resp.Answer {
+ if _, ok := validOwners[canonical(rr.Header().Name)]; !ok {
+ continue
+ }
+ switch v := rr.(type) {
+ case *dns.A:
+ r.A = append(r.A, v.A.String())
+ case *dns.AAAA:
+ r.AAAA = append(r.AAAA, v.AAAA.String())
+ case *dns.CNAME:
+ tgt := strings.TrimSuffix(canonical(v.Target), ".")
+ if !contains(r.CNAME, tgt) {
+ r.CNAME = append(r.CNAME, tgt)
+ }
+ case *dns.NS:
+ r.NS = append(r.NS, strings.TrimSuffix(canonical(v.Ns), "."))
+ case *dns.PTR:
+ r.PTR = append(r.PTR, strings.TrimSuffix(canonical(v.Ptr), "."))
+ case *dns.MX:
+ r.MX = append(r.MX, strings.TrimSuffix(canonical(v.Mx), "."))
+ case *dns.TXT:
+ r.TXT = append(r.TXT, strings.Join(v.Txt, ""))
+ case *dns.SOA:
+ r.SOA = append(r.SOA, strings.TrimSuffix(canonical(v.Ns), "."))
+ }
+ }
+ if len(r.CNAME) == 0 {
+ r.CNAME = nil
+ }
+ return r
+}
+
+// mergeCNAME stitches a CNAME-followed sub-resolution back onto the original
+// query name, preserving the original name/type but carrying the final answer's
+// records and the full CNAME chain.
+func mergeCNAME(name string, qtype uint16, chain []string, server string, sub *resolve.Result) *resolve.Result {
+ out := &resolve.Result{
+ Name: strings.TrimSuffix(canonical(name), "."),
+ Type: qtype,
+ Rcode: sub.Rcode,
+ Resolver: server,
+ Msg: sub.Msg,
+ Timestamp: sub.Timestamp,
+ A: sub.A,
+ AAAA: sub.AAAA,
+ PTR: sub.PTR,
+ MX: sub.MX,
+ TXT: sub.TXT,
+ NS: sub.NS,
+ SOA: sub.SOA,
+ }
+ cn := append([]string{}, chain...)
+ cn = append(cn, sub.CNAME...)
+ if len(cn) > 0 {
+ out.CNAME = cn
+ }
+ if out.Resolver == "" {
+ out.Resolver = sub.Resolver
+ }
+ return out
+}
+
+func mapHas(m map[string]struct{}, k string) bool {
+ _, ok := m[k]
+ return ok
+}
+
+func contains(s []string, v string) bool {
+ for _, x := range s {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/iterative/poison_test.go b/pkg/iterative/poison_test.go
new file mode 100644
index 00000000..7275dd92
--- /dev/null
+++ b/pkg/iterative/poison_test.go
@@ -0,0 +1,73 @@
+package iterative
+
+import (
+ "context"
+ "testing"
+
+ "github.com/miekg/dns"
+)
+
+// TestReferralBailiwickRejection ensures a parent that tries to delegate (or
+// glue) a name outside its own zone is ignored, so it cannot redirect the
+// resolver to an attacker-controlled server (cache poisoning).
+func TestReferralBailiwickRejection(t *testing.T) {
+ r, _ := New(Options{RootServers: []string{"127.0.0.1"}})
+
+ resp := new(dns.Msg)
+ // querying under com.; a hostile com server tries to delegate evil.org.
+ resp.Ns = []dns.RR{
+ mustRR("evil.org. 3600 IN NS ns.evil.org."),
+ mustRR("example.com. 3600 IN NS ns.example.com."), // legitimate, in-bailiwick
+ }
+ resp.Extra = []dns.RR{
+ mustRR("ns.evil.org. 3600 IN A 6.6.6.6"), // out-of-bailiwick glue
+ mustRR("ns.example.com. 3600 IN A 127.0.4.0"), // in-bailiwick glue
+ }
+
+ s := &session{r: r}
+ child := s.parseReferral(resp, "com.", "www.example.com.")
+ if child == nil {
+ t.Fatal("expected a valid in-bailiwick referral")
+ }
+ if child.zone != "example.com." {
+ t.Fatalf("accepted wrong delegation zone %q (out-of-bailiwick leak?)", child.zone)
+ }
+ for _, ns := range child.ns {
+ if ns.name == "ns.evil.org." {
+ t.Fatal("accepted out-of-bailiwick nameserver")
+ }
+ for _, ip := range ns.addrs {
+ if ip.String() == "6.6.6.6" {
+ t.Fatal("accepted out-of-bailiwick glue")
+ }
+ }
+ }
+}
+
+// TestReferralMustBeCloser ensures a referral to the same or a higher zone is
+// rejected (prevents infinite referral loops).
+func TestReferralMustBeCloser(t *testing.T) {
+ r, _ := New(Options{RootServers: []string{"127.0.0.1"}})
+ resp := new(dns.Msg)
+ resp.Ns = []dns.RR{mustRR("com. 3600 IN NS a.gtld.net.")}
+ s := &session{r: r}
+ if child := s.parseReferral(resp, "com.", "www.example.com."); child != nil {
+ t.Fatalf("referral to same zone com. should be rejected, got %#v", child)
+ }
+}
+
+func TestCNAMELoopDetected(t *testing.T) {
+ h := newHierarchy()
+ h.server("127.0.0.1").authoritative(".").delegate("com.", memNS{name: "ns.com.", ip: "127.0.2.0"})
+ ex := h.server("127.0.2.0").authoritative("com.", "loop.com.")
+ ex.cname("a.loop.com.", "b.loop.com.")
+ ex.cname("b.loop.com.", "a.loop.com.")
+
+ r, _ := New(Options{RootServers: []string{"127.0.0.1"}})
+ r.newExchanger = h.factory()
+
+ _, err := r.Resolve(context.Background(), "a.loop.com", dns.TypeA)
+ if err != ErrCNAMELoop {
+ t.Fatalf("expected ErrCNAMELoop, got %v", err)
+ }
+}
diff --git a/pkg/iterative/resolver.go b/pkg/iterative/resolver.go
new file mode 100644
index 00000000..f53eec19
--- /dev/null
+++ b/pkg/iterative/resolver.go
@@ -0,0 +1,458 @@
+// Package iterative implements a high-throughput iterative DNS resolver that
+// recurses from the root servers itself, caching delegations (NS + glue) in a
+// shared, bounded cache. Unlike a stub resolver it does not depend on
+// third-party recursive resolvers, which is the root cause of the
+// false-negative / poisoning problems that plague large bruteforce runs: there
+// is no resolver list to curate, no rate-limited public resolver returning a
+// bogus NOERROR/NXDOMAIN, and every answer comes straight from the zone's
+// authoritative servers.
+//
+// The cache is the performance lever. The first name in a zone walks
+// root -> TLD -> authoritative; every subsequent name in that zone (or any
+// already-seen ancestor) reuses the cached delegation and costs a single round
+// trip to the authoritative server. Across a typical bruteforce workload (many
+// names under few registrable domains) this collapses to ~1 query per name
+// after warmup.
+//
+// Anti-poisoning: referrals and glue are accepted only when in-bailiwick (a
+// server may only delegate names within its own zone), responses are matched
+// by transaction id + question, and replies are accepted only from the address
+// the query was sent to.
+package iterative
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math/rand/v2"
+ "net"
+ "net/netip"
+ "strings"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+)
+
+// Resolver errors.
+var (
+ ErrMaxReferrals = errors.New("iterative: too many referral hops")
+ ErrMaxDepth = errors.New("iterative: max recursion depth exceeded")
+ ErrNoNameserver = errors.New("iterative: no responsive nameserver for zone")
+ ErrCNAMELoop = errors.New("iterative: CNAME loop detected")
+)
+
+// QueryInfo describes a single authoritative query for the hooks.
+type QueryInfo struct {
+ Name string // name being looked up (no trailing dot)
+ Type uint16
+ Zone string // delegation zone the query targets
+ Server string // nameserver address (ip:port)
+ Depth int // glue-resolution recursion depth
+}
+
+// Hooks are optional observation points for SDK/observability use. All may be
+// nil. They fire on the resolution path and must be cheap and concurrency-safe.
+type Hooks struct {
+ // OnQuery fires before each authoritative query is sent.
+ OnQuery func(QueryInfo)
+ // OnReferral fires when a referral is accepted and cached (descending a level).
+ OnReferral func(parentZone, childZone string, ns []string)
+ // OnCacheHit fires when resolution starts from a cached delegation (not root).
+ OnCacheHit func(name, zone string)
+ // OnResponse fires for every accepted authoritative response (read-only).
+ OnResponse func(QueryInfo, *dns.Msg)
+}
+
+// Options configures the iterative resolver.
+type Options struct {
+ // QueryType is the record type to resolve (default dns.TypeA).
+ QueryType uint16
+ // Timeout is the per-attempt query timeout (default 3s).
+ Timeout time.Duration
+ // Retries is the number of UDP retransmits to the same server before
+ // moving to the next nameserver (default 2).
+ Retries int
+ // Concurrency is the number of parallel workers for ResolveStream
+ // (default 100). Each worker reuses a single UDP socket.
+ Concurrency int
+ // MaxReferrals caps referral hops per name (loop guard, default 30).
+ MaxReferrals int
+ // MaxDepth caps nested glue-resolution recursion (default 8).
+ MaxDepth int
+ // UDPSize is the EDNS0 advertised payload size (default 1232). <512 disables EDNS0.
+ UDPSize int
+ // IPv6 enables using IPv6 glue and transport in addition to IPv4.
+ IPv6 bool
+ // DisableTCPFallback disables retrying truncated (TC) answers over TCP.
+ DisableTCPFallback bool
+ // CacheSize bounds the delegation cache (default 65536 zones).
+ CacheSize int
+ // Port is the destination port for nameservers (default 53). Overridable
+ // for testing against a loopback hierarchy.
+ Port int
+ // RootServers overrides the built-in root hints with "ip" or "ip:port"
+ // addresses (testing / split-horizon roots).
+ RootServers []string
+ // Hooks holds optional lifecycle callbacks.
+ Hooks Hooks
+}
+
+func (o *Options) setDefaults() {
+ if o.QueryType == 0 {
+ o.QueryType = dns.TypeA
+ }
+ if o.Timeout <= 0 {
+ o.Timeout = 3 * time.Second
+ }
+ if o.Retries < 0 {
+ o.Retries = 0
+ } else if o.Retries == 0 {
+ o.Retries = 2
+ }
+ if o.Concurrency <= 0 {
+ o.Concurrency = 100
+ }
+ if o.MaxReferrals <= 0 {
+ o.MaxReferrals = 30
+ }
+ if o.MaxDepth <= 0 {
+ o.MaxDepth = 8
+ }
+ if o.UDPSize == 0 {
+ o.UDPSize = 1232
+ }
+ if o.CacheSize <= 0 {
+ o.CacheSize = 1 << 16
+ }
+ if o.Port <= 0 {
+ o.Port = 53
+ }
+}
+
+// exchanger performs a single query/response with one nameserver. The default
+// implementation uses UDP with TCP fallback; tests inject an in-memory hierarchy.
+type exchanger interface {
+ exchange(ctx context.Context, server netip.AddrPort, msg *dns.Msg) (*dns.Msg, error)
+ close()
+}
+
+// Resolver is a shared, concurrency-safe iterative resolver. Create one with
+// New and reuse it across many names; the delegation cache is shared by all
+// resolutions and is what makes repeated lookups cheap.
+type Resolver struct {
+ opts Options
+ cache *cache
+ root *delegation
+
+ // newExchanger builds a per-worker exchanger (own socket). Overridable in tests.
+ newExchanger func() (exchanger, error)
+}
+
+// New creates a Resolver with the given options.
+func New(opts Options) (*Resolver, error) {
+ opts.setDefaults()
+ r := &Resolver{
+ opts: opts,
+ cache: newCache(opts.CacheSize),
+ }
+ r.root = r.buildRoot()
+ r.newExchanger = func() (exchanger, error) { return newUDPExchanger(&r.opts) }
+ return r, nil
+}
+
+func (r *Resolver) buildRoot() *delegation {
+ if len(r.opts.RootServers) == 0 {
+ return defaultRootDelegation(r.opts.IPv6)
+ }
+ // Custom roots (testing / split-horizon). Accept "ip" or "ip:port"; an
+ // explicit port overrides the resolver's default port (last one wins, and
+ // custom root sets are normally uniform).
+ d := &delegation{zone: "."}
+ for i, s := range r.opts.RootServers {
+ host := s
+ if ap, err := netip.ParseAddrPort(s); err == nil {
+ host = ap.Addr().String()
+ r.opts.Port = int(ap.Port())
+ }
+ ip := net.ParseIP(host)
+ ns := nsEntry{name: fmt.Sprintf("root-%d.", i)}
+ if ip != nil {
+ ns.addrs = append(ns.addrs, ip)
+ }
+ d.ns = append(d.ns, ns)
+ }
+ return d
+}
+
+// addrPort converts a glue IP to a netip.AddrPort using the resolver's port.
+func (r *Resolver) addrPort(ip net.IP) (netip.AddrPort, bool) {
+ a, ok := netip.AddrFromSlice(ip)
+ if !ok {
+ return netip.AddrPort{}, false
+ }
+ a = a.Unmap()
+ if a.Is6() && !r.opts.IPv6 {
+ return netip.AddrPort{}, false
+ }
+ return netip.AddrPortFrom(a, uint16(r.opts.Port)), true
+}
+
+// Resolve performs a one-off iterative resolution of name/qtype. For bulk work
+// prefer ResolveStream, which reuses sockets and shares cache warmth across a
+// pool of workers.
+func (r *Resolver) Resolve(ctx context.Context, name string, qtype uint16) (*resolve.Result, error) {
+ if qtype == 0 {
+ qtype = r.opts.QueryType
+ }
+ ex, err := r.newExchanger()
+ if err != nil {
+ return nil, err
+ }
+ defer ex.close()
+ s := &session{r: r, ex: ex}
+ return s.resolve(ctx, name, qtype)
+}
+
+// session is a single worker's resolution context: it owns one exchanger
+// (socket) reused across the sequential queries a resolution requires.
+type session struct {
+ r *Resolver
+ ex exchanger
+}
+
+func (s *session) resolve(ctx context.Context, name string, qtype uint16) (*resolve.Result, error) {
+ return s.resolveDepth(ctx, name, qtype, 0, map[string]struct{}{})
+}
+
+func (s *session) resolveDepth(ctx context.Context, name string, qtype uint16, depth int, cnameSeen map[string]struct{}) (*resolve.Result, error) {
+ if depth > s.r.opts.MaxDepth {
+ return nil, ErrMaxDepth
+ }
+ sname := canonical(name)
+
+ del := s.r.cache.best(sname)
+ if del == nil {
+ del = s.r.root
+ } else if s.r.opts.Hooks.OnCacheHit != nil {
+ s.r.opts.Hooks.OnCacheHit(strings.TrimSuffix(sname, "."), del.zone)
+ }
+ zone := del.zone
+
+ var cnameChain []string
+
+ for hops := 0; hops < s.r.opts.MaxReferrals; hops++ {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ resp, server, err := s.queryZone(ctx, del, zone, sname, qtype, depth)
+ if err != nil {
+ return nil, err
+ }
+ if s.r.opts.Hooks.OnResponse != nil {
+ s.r.opts.Hooks.OnResponse(QueryInfo{Name: strings.TrimSuffix(sname, "."), Type: qtype, Zone: zone, Server: server, Depth: depth}, resp)
+ }
+
+ // definitive negative
+ if resp.Rcode == dns.RcodeNameError {
+ return buildResult(name, qtype, resp, server, cnameChain), nil
+ }
+
+ // follow any CNAME chain present in this answer
+ final, cnames := chaseCNAME(resp.Answer, sname)
+ if len(cnames) > 0 {
+ cnameChain = append(cnameChain, cnames...)
+ }
+
+ // direct answer of the requested type for the (possibly chased) name?
+ if answersType(resp.Answer, final, qtype) {
+ return buildResult(name, qtype, resp, server, cnameChain), nil
+ }
+
+ // CNAME points outside what this answer resolves: restart for the target.
+ if final != sname && qtype != dns.TypeCNAME && qtype != dns.TypeANY {
+ if _, seen := cnameSeen[final]; seen {
+ return nil, ErrCNAMELoop
+ }
+ cnameSeen[final] = struct{}{}
+ sub, err := s.resolveDepth(ctx, final, qtype, depth+1, cnameSeen)
+ if err != nil {
+ return nil, err
+ }
+ return mergeCNAME(name, qtype, cnameChain, server, sub), nil
+ }
+
+ // referral to a closer zone?
+ if child := s.parseReferral(resp, zone, sname); child != nil {
+ s.r.cache.put(child)
+ if s.r.opts.Hooks.OnReferral != nil {
+ s.r.opts.Hooks.OnReferral(zone, child.zone, nsNames(child))
+ }
+ del = child
+ zone = child.zone
+ continue
+ }
+
+ // NOERROR with no answer and no usable referral == NODATA (name exists,
+ // type doesn't) or an empty/lame response we can't progress past.
+ return buildResult(name, qtype, resp, server, cnameChain), nil
+ }
+ return nil, ErrMaxReferrals
+}
+
+// queryZone tries the nameservers of a delegation until one returns a usable
+// response. Glueless nameservers have their addresses resolved on demand.
+func (s *session) queryZone(ctx context.Context, del *delegation, zone, sname string, qtype uint16, depth int) (*dns.Msg, string, error) {
+ order := rand.Perm(len(del.ns))
+
+ // first pass: nameservers that already have glue (no extra round trips)
+ for _, gluedOnly := range []bool{true, false} {
+ for _, idx := range order {
+ ns := del.ns[idx]
+ addrs := ns.addrs
+ if len(addrs) == 0 {
+ if gluedOnly {
+ continue
+ }
+ // glueless: resolve the nameserver's address from the root.
+ addrs = s.resolveNSAddrs(ctx, ns.name, depth)
+ if len(addrs) == 0 {
+ continue
+ }
+ } else if !gluedOnly {
+ // already tried in the glued pass
+ continue
+ }
+ for _, ip := range addrs {
+ ap, ok := s.r.addrPort(ip)
+ if !ok {
+ continue
+ }
+ if s.r.opts.Hooks.OnQuery != nil {
+ s.r.opts.Hooks.OnQuery(QueryInfo{Name: strings.TrimSuffix(sname, "."), Type: qtype, Zone: zone, Server: ap.String(), Depth: depth})
+ }
+ resp, err := s.ex.exchange(ctx, ap, s.newQuery(sname, qtype))
+ if err != nil {
+ continue
+ }
+ return resp, ap.String(), nil
+ }
+ }
+ }
+ return nil, "", ErrNoNameserver
+}
+
+// resolveNSAddrs resolves the A (and AAAA when enabled) addresses of a glueless
+// nameserver, bounded by depth to prevent runaway recursion.
+func (s *session) resolveNSAddrs(ctx context.Context, nsName string, depth int) []net.IP {
+ if depth+1 > s.r.opts.MaxDepth {
+ return nil
+ }
+ var out []net.IP
+ if res, err := s.resolveDepth(ctx, nsName, dns.TypeA, depth+1, map[string]struct{}{}); err == nil {
+ for _, a := range res.A {
+ if ip := net.ParseIP(a); ip != nil {
+ out = append(out, ip)
+ }
+ }
+ }
+ if s.r.opts.IPv6 {
+ if res, err := s.resolveDepth(ctx, nsName, dns.TypeAAAA, depth+1, map[string]struct{}{}); err == nil {
+ for _, a := range res.AAAA {
+ if ip := net.ParseIP(a); ip != nil {
+ out = append(out, ip)
+ }
+ }
+ }
+ }
+ return out
+}
+
+// newQuery builds an iterative (RD=0) query message with EDNS0.
+func (s *session) newQuery(sname string, qtype uint16) *dns.Msg {
+ m := new(dns.Msg)
+ m.Id = dns.Id()
+ m.RecursionDesired = false
+ m.Question = []dns.Question{{Name: sname, Qtype: qtype, Qclass: dns.ClassINET}}
+ if s.r.opts.UDPSize >= 512 {
+ m.SetEdns0(uint16(s.r.opts.UDPSize), false)
+ }
+ return m
+}
+
+// parseReferral extracts a closer, in-bailiwick delegation from a response's
+// authority (NS) and additional (glue) sections. Returns nil when the response
+// is not a usable referral (no NS, out-of-bailiwick, or not closer than zone).
+func (s *session) parseReferral(resp *dns.Msg, parentZone, sname string) *delegation {
+ var child string
+ nsByOwner := map[string][]string{}
+ var minTTL uint32 = 0xffffffff
+ for _, rr := range resp.Ns {
+ ns, ok := rr.(*dns.NS)
+ if !ok {
+ continue
+ }
+ owner := canonical(ns.Header().Name)
+ // the referral zone must be within the parent and a strict descendant
+ // (progress), and an ancestor of the queried name.
+ if !inBailiwick(owner, parentZone) || owner == canonical(parentZone) {
+ continue
+ }
+ if !inBailiwick(sname, owner) {
+ continue
+ }
+ child = owner
+ nsByOwner[owner] = append(nsByOwner[owner], canonical(ns.Ns))
+ if ns.Header().Ttl < minTTL {
+ minTTL = ns.Header().Ttl
+ }
+ }
+ if child == "" {
+ return nil
+ }
+
+ // collect in-bailiwick glue for the chosen child's nameservers
+ glue := map[string][]net.IP{}
+ for _, rr := range resp.Extra {
+ var name string
+ var ip net.IP
+ switch a := rr.(type) {
+ case *dns.A:
+ name, ip = canonical(a.Header().Name), a.A
+ case *dns.AAAA:
+ if !s.r.opts.IPv6 {
+ continue
+ }
+ name, ip = canonical(a.Header().Name), a.AAAA
+ default:
+ continue
+ }
+ // accept glue only within the parent zone's bailiwick (anti-poisoning).
+ if !inBailiwick(name, parentZone) {
+ continue
+ }
+ glue[name] = append(glue[name], ip)
+ }
+
+ d := &delegation{zone: child}
+ if minTTL == 0xffffffff || minTTL < 1 {
+ minTTL = 60
+ }
+ d.expiry = time.Now().Add(time.Duration(minTTL) * time.Second)
+ for _, nsname := range nsByOwner[child] {
+ d.ns = append(d.ns, nsEntry{name: nsname, addrs: glue[nsname]})
+ }
+ if len(d.ns) == 0 {
+ return nil
+ }
+ return d
+}
+
+func nsNames(d *delegation) []string {
+ out := make([]string, 0, len(d.ns))
+ for _, ns := range d.ns {
+ out = append(out, ns.name)
+ }
+ return out
+}
diff --git a/pkg/iterative/resolver_test.go b/pkg/iterative/resolver_test.go
new file mode 100644
index 00000000..c1f6789d
--- /dev/null
+++ b/pkg/iterative/resolver_test.go
@@ -0,0 +1,194 @@
+package iterative
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+)
+
+// buildTestHierarchy wires a small but representative tree:
+//
+// root(127.0.0.1) -> com.(127.0.2.0), net.(127.0.3.0)
+// com. -> example.com.(127.0.4.0, glued)
+// -> glueless.com. (NS ns.ext.net., NO glue)
+// net. -> ext.net.(127.0.5.0, glued)
+// example.com. : www A, many hostN A, alias CNAME->www, (NODATA/NXDOMAIN otherwise)
+// ext.net. : ns.ext.net. A (so glueless delegation resolves), svc A
+func buildTestHierarchy() *memHierarchy {
+ h := newHierarchy()
+ const (
+ rootIP = "127.0.0.1"
+ comIP = "127.0.2.0"
+ netIP = "127.0.3.0"
+ exIP = "127.0.4.0"
+ extIP = "127.0.5.0"
+ )
+ h.server(rootIP).authoritative(".").
+ delegate("com.", memNS{name: "a.gtld.net.", ip: comIP}).
+ delegate("net.", memNS{name: "a.gtld-net.net.", ip: netIP})
+
+ h.server(comIP).authoritative("com.").
+ delegate("example.com.", memNS{name: "ns.example.com.", ip: exIP}).
+ delegate("glueless.com.", memNS{name: "ns.ext.net."}) // glueless: no IP
+
+ h.server(netIP).authoritative("net.").
+ delegate("ext.net.", memNS{name: "ns.ext.net.", ip: extIP})
+
+ ex := h.server(exIP).authoritative("example.com.")
+ ex.a("www.example.com.", "93.184.216.34")
+ ex.cname("alias.example.com.", "www.example.com.")
+ for i := 0; i < 50; i++ {
+ ex.a(fmt.Sprintf("host%d.example.com.", i), fmt.Sprintf("198.51.100.%d", i+1))
+ }
+
+ extn := h.server(extIP).authoritative("ext.net.", "glueless.com.")
+ extn.a("ns.ext.net.", extIP)
+ extn.a("svc.glueless.com.", "203.0.113.7")
+ return h
+}
+
+func newTestResolver(t *testing.T, h *memHierarchy, hooks Hooks) *Resolver {
+ t.Helper()
+ r, err := New(Options{
+ RootServers: []string{"127.0.0.1"},
+ QueryType: dns.TypeA,
+ Concurrency: 8,
+ Hooks: hooks,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.newExchanger = h.factory()
+ return r
+}
+
+func TestIterativeBasicResolution(t *testing.T) {
+ h := buildTestHierarchy()
+ r := newTestResolver(t, h, Hooks{})
+
+ res, err := r.Resolve(context.Background(), "www.example.com", dns.TypeA)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(res.A) != 1 || res.A[0] != "93.184.216.34" {
+ t.Fatalf("unexpected A records: %#v", res.A)
+ }
+ if res.Rcode != dns.RcodeSuccess {
+ t.Fatalf("rcode = %d, want NOERROR", res.Rcode)
+ }
+}
+
+func TestIterativeCNAME(t *testing.T) {
+ h := buildTestHierarchy()
+ r := newTestResolver(t, h, Hooks{})
+
+ res, err := r.Resolve(context.Background(), "alias.example.com", dns.TypeA)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(res.A) != 1 || res.A[0] != "93.184.216.34" {
+ t.Fatalf("CNAME target A not resolved: %#v", res.A)
+ }
+ if len(res.CNAME) == 0 || res.CNAME[0] != "www.example.com" {
+ t.Fatalf("expected CNAME chain to www.example.com, got %#v", res.CNAME)
+ }
+}
+
+func TestIterativeNXDOMAIN(t *testing.T) {
+ h := buildTestHierarchy()
+ r := newTestResolver(t, h, Hooks{})
+
+ res, err := r.Resolve(context.Background(), "nope.example.com", dns.TypeA)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Rcode != dns.RcodeNameError {
+ t.Fatalf("rcode = %d, want NXDOMAIN", res.Rcode)
+ }
+ if len(res.A) != 0 {
+ t.Fatalf("NXDOMAIN should have no A records: %#v", res.A)
+ }
+}
+
+func TestIterativeGluelessDelegation(t *testing.T) {
+ h := buildTestHierarchy()
+ r := newTestResolver(t, h, Hooks{})
+
+ // svc.glueless.com is served by ns.ext.net., which has NO glue at com.;
+ // the resolver must resolve the nameserver's address first.
+ res, err := r.Resolve(context.Background(), "svc.glueless.com", dns.TypeA)
+ if err != nil {
+ t.Fatalf("glueless resolution failed: %v", err)
+ }
+ if len(res.A) != 1 || res.A[0] != "203.0.113.7" {
+ t.Fatalf("unexpected A for glueless: %#v", res.A)
+ }
+}
+
+func TestIterativeCacheReuseReducesQueries(t *testing.T) {
+ h := buildTestHierarchy()
+ r := newTestResolver(t, h, Hooks{})
+ ctx := context.Background()
+
+ // cold: root -> com -> example.com -> answer
+ if _, err := r.Resolve(ctx, "host0.example.com", dns.TypeA); err != nil {
+ t.Fatal(err)
+ }
+ cold := h.queries.Load()
+
+ // warm: example.com delegation cached, expect a single authoritative query
+ before := h.queries.Load()
+ if _, err := r.Resolve(ctx, "host1.example.com", dns.TypeA); err != nil {
+ t.Fatal(err)
+ }
+ warm := h.queries.Load() - before
+
+ if cold < 3 {
+ t.Fatalf("expected cold path to take >=3 queries (root,tld,auth), got %d", cold)
+ }
+ if warm != 1 {
+ t.Fatalf("expected warm path to take exactly 1 query (cached delegation), got %d", warm)
+ }
+}
+
+func TestIterativeStreamConcurrent(t *testing.T) {
+ h := buildTestHierarchy()
+ r := newTestResolver(t, h, Hooks{})
+
+ names := make(chan string)
+ go func() {
+ defer close(names)
+ for i := 0; i < 50; i++ {
+ names <- fmt.Sprintf("host%d.example.com", i)
+ }
+ }()
+
+ var mu sync.Mutex
+ got := map[string]string{}
+ err := r.ResolveStream(context.Background(), names, StreamConfig{
+ OnResult: func(res *resolve.Result) {
+ mu.Lock()
+ if len(res.A) > 0 {
+ got[res.Name] = res.A[0]
+ }
+ mu.Unlock()
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 50 {
+ t.Fatalf("expected 50 resolved names, got %d", len(got))
+ }
+ for i := 0; i < 50; i++ {
+ name := fmt.Sprintf("host%d.example.com", i)
+ want := fmt.Sprintf("198.51.100.%d", i+1)
+ if got[name] != want {
+ t.Errorf("%s = %q, want %q", name, got[name], want)
+ }
+ }
+}
diff --git a/pkg/iterative/roots.go b/pkg/iterative/roots.go
new file mode 100644
index 00000000..c2406243
--- /dev/null
+++ b/pkg/iterative/roots.go
@@ -0,0 +1,49 @@
+package iterative
+
+import "net"
+
+// rootServer is a single root nameserver hint (name + addresses).
+type rootServer struct {
+ name string
+ v4 string
+ v6 string
+}
+
+// rootHints is the static list of the 13 DNS root servers (IANA root hints).
+// They bootstrap iterative resolution: the resolver starts here when its
+// delegation cache has no closer ancestor for a name. Addresses change very
+// rarely; if one is stale the resolver simply rotates to another root.
+var rootHints = []rootServer{
+ {"a.root-servers.net.", "198.41.0.4", "2001:503:ba3e::2:30"},
+ {"b.root-servers.net.", "199.9.14.201", "2001:500:200::b"},
+ {"c.root-servers.net.", "192.33.4.12", "2001:500:2::c"},
+ {"d.root-servers.net.", "199.7.91.13", "2001:500:2d::d"},
+ {"e.root-servers.net.", "192.203.230.10", "2001:500:a8::e"},
+ {"f.root-servers.net.", "192.5.5.241", "2001:500:2f::f"},
+ {"g.root-servers.net.", "192.112.36.4", "2001:500:12::d0d"},
+ {"h.root-servers.net.", "198.97.190.53", "2001:500:1::53"},
+ {"i.root-servers.net.", "192.36.148.17", "2001:7fe::53"},
+ {"j.root-servers.net.", "192.58.128.30", "2001:503:c27::2:30"},
+ {"k.root-servers.net.", "193.0.14.129", "2001:7fd::1"},
+ {"l.root-servers.net.", "199.7.83.42", "2001:500:9f::42"},
+ {"m.root-servers.net.", "202.12.27.33", "2001:dc3::35"},
+}
+
+// defaultRootDelegation builds the "." delegation from the static hints. wantV6
+// controls whether IPv6 glue is included.
+func defaultRootDelegation(wantV6 bool) *delegation {
+ d := &delegation{zone: "."}
+ for _, r := range rootHints {
+ ns := nsEntry{name: r.name}
+ if ip := net.ParseIP(r.v4); ip != nil {
+ ns.addrs = append(ns.addrs, ip)
+ }
+ if wantV6 {
+ if ip := net.ParseIP(r.v6); ip != nil {
+ ns.addrs = append(ns.addrs, ip)
+ }
+ }
+ d.ns = append(d.ns, ns)
+ }
+ return d
+}
diff --git a/pkg/iterative/stream.go b/pkg/iterative/stream.go
new file mode 100644
index 00000000..7b94dba3
--- /dev/null
+++ b/pkg/iterative/stream.go
@@ -0,0 +1,95 @@
+package iterative
+
+import (
+ "context"
+ "strings"
+ "sync"
+
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+)
+
+// StreamConfig wires a bulk iterative resolution.
+type StreamConfig struct {
+ // OnResult is called for every name that produced a final response
+ // (success, NODATA, or NXDOMAIN). Must be concurrency-safe.
+ OnResult func(*resolve.Result)
+ // OnError is called when a name could not be resolved (no responsive
+ // nameserver, loop, context cancelled). Must be concurrency-safe.
+ OnError func(name string, err error)
+ // QueryType overrides the resolver's default query type for this stream.
+ QueryType uint16
+}
+
+// ResolveStream consumes names from the channel and resolves them iteratively
+// using a pool of Concurrency workers. Every worker reuses a single UDP socket
+// and shares the resolver's delegation cache, so cache warmth (root, TLDs,
+// popular zones) is amortized across the whole workload. It blocks until the
+// input channel is closed and all in-flight work drains, or ctx is cancelled.
+func (r *Resolver) ResolveStream(ctx context.Context, names <-chan string, cfg StreamConfig) error {
+ qtype := cfg.QueryType
+ if qtype == 0 {
+ qtype = r.opts.QueryType
+ }
+
+ var wg sync.WaitGroup
+ wg.Add(r.opts.Concurrency)
+ for i := 0; i < r.opts.Concurrency; i++ {
+ go func() {
+ defer wg.Done()
+ ex, err := r.newExchanger()
+ if err != nil {
+ // a worker that cannot open a socket simply drains its share;
+ // other workers continue. Report once per failed name. Stop on
+ // cancellation so an idle producer can't wedge the drain.
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case name, ok := <-names:
+ if !ok {
+ return
+ }
+ if cfg.OnError != nil {
+ cfg.OnError(strings.TrimSpace(name), err)
+ }
+ }
+ }
+ }
+ defer ex.close()
+ s := &session{r: r, ex: ex}
+ for {
+ // Select on ctx.Done() as well as the channel: a plain range
+ // blocks on an idle-but-open producer, so a cancelled ctx would
+ // never unblock the worker and ResolveStream would hang.
+ var (
+ name string
+ ok bool
+ )
+ select {
+ case <-ctx.Done():
+ return
+ case name, ok = <-names:
+ if !ok {
+ return
+ }
+ }
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ res, rerr := s.resolve(ctx, name, qtype)
+ if rerr != nil {
+ if cfg.OnError != nil {
+ cfg.OnError(name, rerr)
+ }
+ continue
+ }
+ if cfg.OnResult != nil {
+ cfg.OnResult(res)
+ }
+ }
+ }()
+ }
+ wg.Wait()
+ return ctx.Err()
+}
diff --git a/pkg/iterative/udp.go b/pkg/iterative/udp.go
new file mode 100644
index 00000000..a87fc2e8
--- /dev/null
+++ b/pkg/iterative/udp.go
@@ -0,0 +1,127 @@
+package iterative
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/netip"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+// udpExchanger performs authoritative queries over a single reused, unconnected
+// UDP socket (the client-side equivalent of zdns's socket reuse: one socket per
+// worker, reused for every destination, avoiding per-query socket setup). It
+// verifies the reply's source address and transaction id, and falls back to TCP
+// on truncation.
+type udpExchanger struct {
+ opts *Options
+ conn *net.UDPConn
+ buf []byte
+}
+
+func newUDPExchanger(opts *Options) (exchanger, error) {
+ network := "udp4"
+ if opts.IPv6 {
+ network = "udp"
+ }
+ conn, err := net.ListenUDP(network, nil)
+ if err != nil {
+ return nil, err
+ }
+ _ = conn.SetReadBuffer(4 * 1024 * 1024)
+ _ = conn.SetWriteBuffer(4 * 1024 * 1024)
+ return &udpExchanger{opts: opts, conn: conn, buf: make([]byte, 64*1024)}, nil
+}
+
+func (u *udpExchanger) close() {
+ if u.conn != nil {
+ _ = u.conn.Close()
+ }
+}
+
+func (u *udpExchanger) exchange(ctx context.Context, server netip.AddrPort, msg *dns.Msg) (*dns.Msg, error) {
+ packed, err := msg.Pack()
+ if err != nil {
+ return nil, err
+ }
+ dst := net.UDPAddrFromAddrPort(server)
+
+ for attempt := 0; attempt <= u.opts.Retries; attempt++ {
+ deadline := time.Now().Add(u.opts.Timeout)
+ if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
+ deadline = d
+ }
+ if _, err := u.conn.WriteToUDP(packed, dst); err != nil {
+ return nil, err
+ }
+ _ = u.conn.SetReadDeadline(deadline)
+
+ // read until a matching reply arrives or the deadline expires; stray
+ // or spoofed packets (wrong source / id / question) are ignored.
+ for {
+ n, from, rerr := u.conn.ReadFromUDP(u.buf)
+ if rerr != nil {
+ if ne, ok := rerr.(net.Error); ok && ne.Timeout() {
+ break // retransmit
+ }
+ return nil, rerr
+ }
+ // On a dual-stack ("udp") socket an IPv4 server's reply arrives as a
+ // 4-in-6 address, so compare against the unmapped form; otherwise every
+ // IPv4 response is dropped when IPv6 mode is enabled.
+ fromAP := from.AddrPort()
+ fromNorm := netip.AddrPortFrom(fromAP.Addr().Unmap(), fromAP.Port())
+ if !fromNorm.Addr().IsValid() || fromNorm != server {
+ continue // source-address verification (anti off-path spoofing)
+ }
+ resp := new(dns.Msg)
+ if resp.Unpack(u.buf[:n]) != nil {
+ continue
+ }
+ if resp.Id != msg.Id || !sameQuestion(resp, msg) {
+ continue
+ }
+ if resp.Truncated && !u.opts.DisableTCPFallback {
+ if tcp, terr := u.exchangeTCP(ctx, server, msg); terr == nil {
+ return tcp, nil
+ }
+ }
+ return resp, nil
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ }
+ return nil, errTimeout
+}
+
+func (u *udpExchanger) exchangeTCP(ctx context.Context, server netip.AddrPort, msg *dns.Msg) (*dns.Msg, error) {
+ d := net.Dialer{Timeout: u.opts.Timeout}
+ conn, err := d.DialContext(ctx, "tcp", server.String())
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = conn.Close() }()
+ co := &dns.Conn{Conn: conn}
+ if dl, ok := ctx.Deadline(); ok {
+ _ = conn.SetDeadline(dl)
+ } else {
+ _ = conn.SetDeadline(time.Now().Add(u.opts.Timeout))
+ }
+ if err := co.WriteMsg(msg); err != nil {
+ return nil, err
+ }
+ return co.ReadMsg()
+}
+
+var errTimeout = errors.New("iterative: query timed out")
+
+func sameQuestion(a, b *dns.Msg) bool {
+ if len(a.Question) != 1 || len(b.Question) != 1 {
+ return false
+ }
+ qa, qb := a.Question[0], b.Question[0]
+ return qa.Qtype == qb.Qtype && qa.Qclass == qb.Qclass && canonical(qa.Name) == canonical(qb.Name)
+}
diff --git a/pkg/massdns/massdns.go b/pkg/massdns/massdns.go
index 016f8fe7..701d78ad 100644
--- a/pkg/massdns/massdns.go
+++ b/pkg/massdns/massdns.go
@@ -20,39 +20,51 @@ type Options struct {
Domains []string
// Retries is the number of retries for dns
Retries int
- // MassdnsPath is the path to the binary
- MassdnsPath string
- // Threads is the hashmap size for massdns
+ // Threads is the number of concurrent in-flight dns queries
Threads int
- // InputFile is the file to use for massdns input
- InputFile string
// ResolversFile is the file with the resolvers
ResolversFile string
// TrustedResolvers is the file with the trusted resolvers
TrustedResolvers string
- // TempDir is a temporary directory for storing massdns misc files
+ // TempDir is a temporary directory for storing misc files
TempDir string
- // OutputFile is the file to use for massdns output
+ // OutputFile is the file to write results to
OutputFile string
// Json is format ouput to ndjson format
Json bool
// WildcardsThreads is the number of wildcards concurrent threads
WildcardsThreads int
- // MassdnsRaw perform wildcards filtering from an existing massdns output file
+ // MassdnsRaw performs wildcards filtering from an existing massdns output file
MassdnsRaw string
// StrictWildcard controls whether the wildcard check should be performed on each result
StrictWildcard bool
// WildcardOutputFile is the file where the list of wildcards is dumped
WildcardOutputFile string
- // MassDnsCmd supports massdns flags
- MassDnsCmd string
- // KeepStderr controls whether to capture and store massdns stderr output
- KeepStderr bool
- // BatchSize controls the number of lines per chunk for incremental processing
- BatchSize int
// FilterInternalIPs controls whether to filter out internal/private IP addresses
FilterInternalIPs bool
+ // Native resolver tuning (forwarded to pkg/resolve).
+ QueryType string // DNS record type to resolve (A, AAAA, ...). Default A.
+ BatchMode string // sendmmsg/recvmmsg batching: off | on | adaptive
+ SocketCount int // UDP sockets per run (0 = scale to cores)
+ UDPSize int // EDNS0 advertised UDP payload size (0 = default; <512 disables)
+ QPS int // outbound query rate limit (0 = unlimited)
+ NoRecurse bool // send non-recursive queries (RD=0)
+ Sticky bool // do not rotate resolver on retry
+ ResolverHealth bool // per-resolver health scoring / de-weighting
+ AdaptiveConcurrency bool // shrink/grow in-flight cap based on packet loss
+ CrossCheck bool // re-verify positive answers on a second resolver
+ ExtendedInput bool // parse "name [resolver ...]" input lines
+ NoVerifyIP bool // disable reply source-IP verification
+ NoTCPFallback bool // disable TCP fallback on truncated answers
+ // Iterative resolves from the root servers directly (no recursive resolver
+ // list needed), caching delegations. Removes the public-resolver dependency.
+ Iterative bool
+
+ // Distributed resolution and resume.
+ Shard string // "m/n": process only shard m of n
+ ResumeFile string // checkpoint file for crash-safe stop/resume
+
OnResult func(*retryabledns.DNSData)
}
diff --git a/pkg/massdns/process.go b/pkg/massdns/process.go
index d68119d6..875dd930 100644
--- a/pkg/massdns/process.go
+++ b/pkg/massdns/process.go
@@ -8,15 +8,18 @@ import (
"fmt"
"io"
"os"
- "os/exec"
- "strconv"
"strings"
"sync/atomic"
"time"
+ "github.com/miekg/dns"
"github.com/projectdiscovery/dnsx/libs/dnsx"
"github.com/projectdiscovery/gologger"
+ "github.com/projectdiscovery/shuffledns/pkg/checkpoint"
+ "github.com/projectdiscovery/shuffledns/pkg/iterative"
"github.com/projectdiscovery/shuffledns/pkg/parser"
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+ "github.com/projectdiscovery/shuffledns/pkg/shard"
"github.com/projectdiscovery/shuffledns/pkg/store"
"github.com/projectdiscovery/shuffledns/pkg/wildcards"
"github.com/projectdiscovery/utils/batcher"
@@ -27,68 +30,29 @@ import (
"github.com/weppos/publicsuffix-go/publicsuffix"
)
-// runs massdns binary with the specified options
-func (instance *Instance) RunWithContext(ctx context.Context) (stdout, stderr string, took time.Duration, err error) {
- start := time.Now()
-
- // Create temporary file for massdns output
- stdoutFile, err := os.CreateTemp(instance.options.TempDir, "massdns-stdout-")
- if err != nil {
- return "", "", 0, fmt.Errorf("could not create temp file for massdns output: %w", err)
- }
- defer func() {
- _ = stdoutFile.Close()
- }()
-
- // Handle stderr based on KeepStderr option
- var stderrFile *os.File
- if instance.options.KeepStderr {
- stderrFile, err = os.CreateTemp(instance.options.TempDir, "massdns-stderr-")
- if err != nil {
- return "", "", 0, fmt.Errorf("could not create temp file for massdns stderr: %w", err)
- }
- defer func() {
- _ = stderrFile.Close()
- }()
- }
-
- // Run the command on a temp file and wait for the output
- args := []string{"-r", instance.options.ResolversFile, "-o", "Snl", "--retry", "REFUSED", "--retry", "SERVFAIL", "-t", "A", instance.options.InputFile, "-s", strconv.Itoa(instance.options.Threads)}
- if instance.options.MassDnsCmd != "" {
- args = append(args, strings.Fields(instance.options.MassDnsCmd)...)
- }
-
- cmd := exec.CommandContext(ctx, instance.options.MassdnsPath, args...)
- cmd.Stdout = stdoutFile
-
- // Set stderr based on KeepStderr option
- if instance.options.KeepStderr {
- cmd.Stderr = stderrFile
- } else {
- // Discard stderr by sending it to /dev/null
- cmd.Stderr = nil
- }
-
- err = cmd.Run()
-
- // Return stderr filename only if it was captured
- stderrFilename := ""
- if instance.options.KeepStderr {
- stderrFilename = stderrFile.Name()
+// parseBatchMode maps a CLI string to the resolver batching mode.
+func parseBatchMode(s string) resolve.BatchMode {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case "on", "enabled", "true":
+ return resolve.BatchEnabled
+ case "adaptive", "auto":
+ return resolve.BatchAdaptive
+ default:
+ return resolve.BatchDisabled
}
-
- return stdoutFile.Name(), stderrFilename, time.Since(start), err
}
+// Run processes an existing raw massdns output file for wildcard filtering.
+// This is the backward-compatible path for the -ri/--raw-input flag; live
+// resolution no longer shells out to massdns and is handled by the streaming
+// methods below.
func (instance *Instance) Run(ctx context.Context) error {
- // Process a created list or the massdns input
- inputFile := instance.options.InputFile
- if instance.options.MassdnsRaw != "" {
- inputFile = instance.options.MassdnsRaw
+ if instance.options.MassdnsRaw == "" {
+ return errors.New("streaming processing should be used for new resolution runs")
}
- // Check for blank input file or non-existent input file
- blank, err := fileutil.IsEmpty(inputFile)
+ // Check for blank or non-existent input file
+ blank, err := fileutil.IsEmpty(instance.options.MassdnsRaw)
if err != nil {
return err
}
@@ -96,113 +60,257 @@ func (instance *Instance) Run(ctx context.Context) error {
return errors.New("blank input file specified")
}
- // Check if we need to run massdns
- if instance.options.MassdnsRaw == "" {
- // This case is now handled by the streaming methods in the runner
- // The Run method is only called for raw massdns output processing
- return errors.New("streaming processing should be used for new massdns runs")
- }
-
- // Create a store for storing ip metadata
shstore, err := store.New(instance.options.TempDir)
if err != nil {
return fmt.Errorf("could not create store: %w", err)
}
defer shstore.Close()
- // parse the input file
gologger.Info().Msgf("Started parsing massdns input\n")
now := time.Now()
- err = instance.parseMassDNSOutputFile(instance.options.MassdnsRaw, shstore)
- if err != nil {
+ if err := instance.parseMassDNSOutputFile(instance.options.MassdnsRaw, shstore); err != nil {
return fmt.Errorf("could not parse massdns input: %w", err)
}
gologger.Info().Msgf("Massdns input parsing completed in %s\n", time.Since(now))
- if instance.options.AutoExtractRootDomains {
- gologger.Info().Msgf("Started extracting root domains\n")
- now := time.Now()
- err = instance.autoExtractRootDomains(shstore)
+ return instance.postProcess(shstore)
+}
+
+// resolveNames resolves a stream of hostnames produced by the produce function
+// using the native asynchronous resolver, storing successful answers in the
+// store. No temporary files are created and no external binary is invoked.
+//
+// When configured, the input is partitioned by shard (distributed coordination)
+// and filtered against a resume checkpoint (crash-safe stop/resume) before being
+// dispatched; both filters run in the producer goroutine, off the resolver's hot
+// path.
+func (instance *Instance) resolveNames(ctx context.Context, shstore *store.Store, produce func(out chan<- string) error) error {
+ // Iterative mode recurses from the root servers and needs no resolver list;
+ // the stub path requires one.
+ var resolvers []string
+ var err error
+ if !instance.options.Iterative {
+ resolvers, err = wildcards.LoadResolversFromFile(instance.options.ResolversFile)
if err != nil {
- return fmt.Errorf("could not extract root domains: %w", err)
+ return fmt.Errorf("could not load resolvers: %w", err)
}
- gologger.Info().Msgf("Root domain extraction completed in %s\n", time.Since(now))
}
- // Perform wildcard filtering only if domain name has been specified
- if len(instance.options.Domains) > 0 {
- gologger.Info().Msgf("Started removing wildcards records\n")
- now := time.Now()
- err = instance.filterWildcards(shstore)
+ shardCfg, err := shard.Parse(instance.options.Shard)
+ if err != nil {
+ return err
+ }
+
+ var ckpt *checkpoint.Checkpoint
+ if instance.options.ResumeFile != "" {
+ ckpt, err = checkpoint.Open(instance.options.ResumeFile)
if err != nil {
- return fmt.Errorf("could not filter wildcards: %w", err)
+ return fmt.Errorf("could not open resume checkpoint: %w", err)
+ }
+ defer func() { _ = ckpt.Close() }()
+ if n := ckpt.Resumed(); n > 0 {
+ gologger.Info().Msgf("Resuming: skipping %d already-completed names\n", n)
}
- gologger.Info().Msgf("Wildcard removal completed in %s\n", time.Since(now))
}
- gologger.Info().Msgf("Finished enumeration, started writing output\n")
+ var resolved atomic.Int64
- // Write the final elaborated list out
- now = time.Now()
- err = instance.writeOutput(shstore)
- if err != nil {
- return fmt.Errorf("could not write output: %w", err)
+ // The shuffledns runner keys its store by IP address, so only address
+ // record types are meaningful here; other types (NS, MX, TXT, ...) would
+ // resolve successfully but be silently dropped at store time. Reject them
+ // with a clear pointer to cmd/resolve, which renders arbitrary types.
+ qtype := dns.TypeA
+ if instance.options.QueryType != "" {
+ t, ok := dns.StringToType[strings.ToUpper(instance.options.QueryType)]
+ if !ok {
+ return fmt.Errorf("unknown query type %q", instance.options.QueryType)
+ }
+ if t != dns.TypeA && t != dns.TypeAAAA {
+ return fmt.Errorf("query type %q is not supported by the bruteforce runner (only A/AAAA); use the resolve command for arbitrary record types", instance.options.QueryType)
+ }
+ qtype = t
}
- gologger.Info().Msgf("Output written in %s\n", time.Since(now))
- return nil
-}
-// runChunk runs massdns on a specific chunk file
-func (instance *Instance) runChunk(ctx context.Context, chunkFile string) (stdout, stderr string, took time.Duration, err error) {
- start := time.Now()
+ markDone := func(name string) {
+ if ckpt != nil {
+ _ = ckpt.Done(name)
+ }
+ }
- // Create temporary file for massdns output
- stdoutFile, err := os.CreateTemp(instance.options.TempDir, "massdns-chunk-stdout-")
- if err != nil {
- return "", "", 0, fmt.Errorf("could not create temp file for massdns output: %w", err)
+ // storeResult is the engine-agnostic result handler: it marks the name done
+ // (for resume), then stores any address answers keyed by IP. Shared by both
+ // the stub resolver and the iterative-from-root resolver.
+ storeResult := func(name string, rcode int, a, aaaa []string) {
+ markDone(name)
+ if rcode != dns.RcodeSuccess {
+ return
+ }
+ ips := a
+ if len(aaaa) > 0 {
+ ips = append(append([]string{}, a...), aaaa...)
+ }
+ if len(ips) == 0 {
+ return
+ }
+ for _, ip := range ips {
+ if instance.shouldFilterIP(ip) {
+ continue
+ }
+ _ = shstore.Append(ip, name)
+ }
+ resolved.Add(1)
}
- defer func() {
- _ = stdoutFile.Close()
- }()
- // Handle stderr based on KeepStderr option
- var stderrFile *os.File
- if instance.options.KeepStderr {
- stderrFile, err = os.CreateTemp(instance.options.TempDir, "massdns-chunk-stderr-")
- if err != nil {
- return "", "", 0, fmt.Errorf("could not create temp file for massdns stderr: %w", err)
+ // admit applies shard ownership and resume skipping to the name. For
+ // extended-input lines ("name resolver ..."), only the leading name is keyed.
+ admit := func(line string) bool {
+ name := line
+ if instance.options.ExtendedInput {
+ if i := strings.IndexAny(name, " \t"); i >= 0 {
+ name = name[:i]
+ }
}
- defer func() {
- _ = stderrFile.Close()
+ if !shardCfg.Owns(name) {
+ return false
+ }
+ if ckpt != nil && ckpt.Has(name) {
+ return false
+ }
+ return true
+ }
+
+ // produceErr is written by the producer goroutine before it closes its
+ // output channel; the channel-close -> Run-drains -> Run-returns chain
+ // establishes happens-before, so the read below (after Run) is safe.
+ var produceErr error
+
+ input := make(chan string, 4096)
+ if !shardCfg.Enabled() && ckpt == nil {
+ // fast path: no filtering, producer writes straight to the resolver.
+ go func() {
+ defer close(input)
+ produceErr = produce(input)
+ }()
+ } else {
+ raw := make(chan string, 4096)
+ go func() {
+ defer close(raw)
+ produceErr = produce(raw)
+ }()
+ go func() {
+ defer close(input)
+ for name := range raw {
+ if admit(name) {
+ input <- name
+ }
+ }
}()
}
- // Run the command on the chunk file
- args := []string{"-r", instance.options.ResolversFile, "-o", "Snl", "--retry", "REFUSED", "--retry", "SERVFAIL", "-t", "A", chunkFile, "-s", strconv.Itoa(instance.options.Threads)}
- if instance.options.MassDnsCmd != "" {
- args = append(args, strings.Fields(instance.options.MassDnsCmd)...)
+ if err := instance.runEngine(ctx, resolvers, qtype, input, storeResult, markDone); err != nil {
+ return err
+ }
+ if produceErr != nil {
+ return fmt.Errorf("could not read input: %w", produceErr)
}
- cmd := exec.CommandContext(ctx, instance.options.MassdnsPath, args...)
- cmd.Stdout = stdoutFile
+ gologger.Info().Msgf("Resolved %d hosts\n", resolved.Load())
+ return nil
+}
- // Set stderr based on KeepStderr option
- if instance.options.KeepStderr {
- cmd.Stderr = stderrFile
- } else {
- // Discard stderr by sending it to /dev/null
- cmd.Stderr = nil
+// runEngine consumes names from input using the configured resolution engine:
+// the iterative-from-root resolver (no resolver list required) when
+// Options.Iterative is set, otherwise the asynchronous stub resolver against
+// the supplied recursive resolvers. Both feed answers to storeResult.
+func (instance *Instance) runEngine(ctx context.Context, resolvers []string, qtype uint16, input <-chan string, storeResult func(name string, rcode int, a, aaaa []string), markDone func(string)) error {
+ if instance.options.Iterative {
+ // Each iterative worker holds one reused UDP socket; clamp the stub
+ // thread count (which can be very large) to avoid fd exhaustion.
+ workers := instance.options.Threads
+ if workers <= 0 {
+ workers = 200
+ } else if workers > 1024 {
+ workers = 1024
+ }
+ ir, err := iterative.New(iterative.Options{
+ QueryType: qtype,
+ Concurrency: workers,
+ IPv6: qtype == dns.TypeAAAA,
+ })
+ if err != nil {
+ return fmt.Errorf("could not create iterative resolver: %w", err)
+ }
+ gologger.Info().Msgf("Using iterative-from-root resolver (no recursive resolvers needed)\n")
+ return ir.ResolveStream(ctx, input, iterative.StreamConfig{
+ QueryType: qtype,
+ OnResult: func(r *resolve.Result) {
+ storeResult(r.Name, r.Rcode, r.A, r.AAAA)
+ },
+ OnError: func(name string, _ error) {
+ markDone(name)
+ },
+ })
+ }
+
+ client, err := resolve.New(resolve.Options{
+ Resolvers: resolvers,
+ QueryType: qtype,
+ MaxRetries: instance.options.Retries,
+ Concurrency: instance.options.Threads,
+ QPS: instance.options.QPS,
+ Batch: parseBatchMode(instance.options.BatchMode),
+ SocketCount: instance.options.SocketCount,
+ UDPSize: instance.options.UDPSize,
+ NoRecurse: instance.options.NoRecurse,
+ Sticky: instance.options.Sticky,
+ ResolverHealth: instance.options.ResolverHealth,
+ AdaptiveConcurrency: instance.options.AdaptiveConcurrency,
+ CrossCheck: instance.options.CrossCheck,
+ ExtendedInput: instance.options.ExtendedInput,
+ DisableIPVerification: instance.options.NoVerifyIP,
+ DisableTCPFallback: instance.options.NoTCPFallback,
+ OnResult: func(r resolve.Result) {
+ storeResult(r.Name, r.Rcode, r.A, r.AAAA)
+ },
+ OnError: func(name string, _ error) {
+ markDone(name)
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("could not create resolver: %w", err)
}
+ defer client.Close()
+ return client.Run(ctx, input)
+}
- err = cmd.Run()
+// postProcess performs the common steps after the store has been populated:
+// optional root-domain extraction, wildcard removal, and output writing.
+func (instance *Instance) postProcess(shstore *store.Store) error {
+ if instance.options.AutoExtractRootDomains {
+ gologger.Info().Msgf("Started extracting root domains\n")
+ now := time.Now()
+ if err := instance.autoExtractRootDomains(shstore); err != nil {
+ return fmt.Errorf("could not extract root domains: %w", err)
+ }
+ gologger.Info().Msgf("Root domain extraction completed in %s\n", time.Since(now))
+ }
- // Return stderr filename only if it was captured
- stderrFilename := ""
- if instance.options.KeepStderr {
- stderrFilename = stderrFile.Name()
+ if len(instance.options.Domains) > 0 {
+ gologger.Info().Msgf("Started removing wildcards records\n")
+ now := time.Now()
+ if err := instance.filterWildcards(shstore); err != nil {
+ return fmt.Errorf("could not filter wildcards: %w", err)
+ }
+ gologger.Info().Msgf("Wildcard removal completed in %s\n", time.Since(now))
}
- return stdoutFile.Name(), stderrFilename, time.Since(start), err
+ gologger.Info().Msgf("Finished enumeration, started writing output\n")
+ now := time.Now()
+ if err := instance.writeOutput(shstore); err != nil {
+ return fmt.Errorf("could not write output: %w", err)
+ }
+ gologger.Info().Msgf("Output written in %s\n", time.Since(now))
+ return nil
}
type item struct {
@@ -210,6 +318,8 @@ type item struct {
domain string
}
+// parseMassDNSOutputFile parses a raw massdns output file (used by the
+// -ri/--raw-input compatibility path) into the store.
func (instance *Instance) parseMassDNSOutputFile(tmpFile string, store *store.Store) error {
flushToDisk := func(ip string, domains []string) error {
if err := store.Append(ip, domains...); err != nil {
@@ -238,7 +348,6 @@ func (instance *Instance) parseMassDNSOutputFile(tmpFile string, store *store.St
err := parser.ParseFile(tmpFile, func(domain string, ips []string) error {
for _, ip := range ips {
- // Filter out 0.0.0.0 always, and internal IPs if flag is set
if instance.shouldFilterIP(ip) {
continue
}
@@ -248,7 +357,6 @@ func (instance *Instance) parseMassDNSOutputFile(tmpFile string, store *store.St
})
bulkWriter.Stop()
-
bulkWriter.WaitDone()
if err != nil {
@@ -412,12 +520,13 @@ func (instance *Instance) writeOutput(store *store.Store) error {
defer swg.Done()
if dnsResolver != nil {
- if resp, err := dnsResolver.QueryOne(hostname); err != nil || len(resp.A) == 0 {
+ if resp, err := dnsResolver.QueryOne(hostname); err != nil || (len(resp.A) == 0 && len(resp.AAAA) == 0) {
gologger.Info().Msgf("not resolved with trusted resolver - skipping: %s", hostname)
return
} else {
// perform a last check on wildcards ip in case some hosts sneaked due to bad resolvers
- for _, ip := range resp.A {
+ addrs := append(append([]string{}, resp.A...), resp.AAAA...)
+ for _, ip := range addrs {
if instance.wildcardStore.Has(ip) {
gologger.Info().Msgf("resolved with trusted resolver but is a wildcard - skipping: %s", hostname)
return
@@ -470,292 +579,86 @@ func (instance *Instance) writeOutput(store *store.Store) error {
return nil
}
-// ProcessDomainStreaming processes domain bruteforce using streaming with batcher
+// ProcessDomainStreaming resolves a domain bruteforce by streaming generated
+// permutations directly into the native resolver (no chunk files).
func (instance *Instance) ProcessDomainStreaming(ctx context.Context, wordlistFile *os.File) error {
- // Create a store for storing ip metadata
shstore, err := store.New(instance.options.TempDir)
if err != nil {
return fmt.Errorf("could not create store: %w", err)
}
defer shstore.Close()
- // Create batcher for streaming permutations
- chunkNum := 0
- permutationCount := 0
-
- bulkWriter := batcher.New[string](
- batcher.WithMaxCapacity[string](instance.options.BatchSize),
- batcher.WithFlushInterval[string](10*time.Second),
- batcher.WithFlushCallback[string](func(permutations []string) {
- chunkNum++
- if len(permutations) == 0 {
- return
- }
-
- gologger.Info().Msgf("Processing chunk %d (%d permutations, total: %d)\n",
- chunkNum, len(permutations), permutationCount)
-
- // Create temporary chunk file
- chunkFile, err := os.CreateTemp(instance.options.TempDir, fmt.Sprintf("chunk-%d-", chunkNum))
- if err != nil {
- gologger.Error().Msgf("Could not create chunk file: %s\n", err)
- return
- }
-
- // Write permutations to chunk file
- writer := bufio.NewWriter(chunkFile)
- for _, permutation := range permutations {
- _, err := writer.WriteString(permutation + "\n")
- if err != nil {
- gologger.Error().Msgf("Could not write to chunk file: %s\n", err)
- _ = chunkFile.Close()
- _ = os.Remove(chunkFile.Name())
- return
- }
- }
- _ = writer.Flush()
- _ = chunkFile.Close()
+ gologger.Info().Msgf("Started bruteforce resolution with native resolver\n")
+ now := time.Now()
- // Run massdns on this chunk
- chunkStart := time.Now()
- stdoutFile, stderrFile, took, err := instance.runChunk(ctx, chunkFile.Name())
- if err != nil {
- gologger.Error().Msgf("Could not execute massdns on chunk %d: %s\n", chunkNum, err)
- _ = os.Remove(chunkFile.Name())
- return
+ var permutationCount atomic.Int64
+ err = instance.resolveNames(ctx, shstore, func(out chan<- string) error {
+ scanner := bufio.NewScanner(wordlistFile)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ // RFC4343 - case insensitive domain
+ text := strings.ToLower(strings.TrimSpace(scanner.Text()))
+ if text == "" {
+ continue
}
-
- gologger.Info().Msgf("Chunk %d massdns execution took %s\n", chunkNum, took)
-
- // Parse the chunk output immediately
- parseStart := time.Now()
- err = instance.parseMassDNSOutputFile(stdoutFile, shstore)
- if err != nil {
- gologger.Error().Msgf("Could not parse massdns output for chunk %d: %s\n", chunkNum, err)
- _ = os.Remove(chunkFile.Name())
- _ = os.Remove(stdoutFile)
- if stderrFile != "" {
- _ = os.Remove(stderrFile)
+ for _, domain := range instance.options.Domains {
+ // Stop on cancellation so the producer can't wedge on a full
+ // channel once the resolver has stopped draining (interrupt).
+ select {
+ case out <- text + "." + domain:
+ permutationCount.Add(1)
+ case <-ctx.Done():
+ return ctx.Err()
}
- return
}
-
- gologger.Info().Msgf("Chunk %d parsing completed in %s\n", chunkNum, time.Since(parseStart))
-
- // Clean up chunk files immediately
- _ = os.Remove(chunkFile.Name())
- _ = os.Remove(stdoutFile)
- if stderrFile != "" {
- _ = os.Remove(stderrFile)
- }
-
- gologger.Info().Msgf("Chunk %d completed in %s\n", chunkNum, time.Since(chunkStart))
- }),
- )
-
- bulkWriter.Run()
-
- // Read wordlist and generate permutations on-the-fly
- scanner := bufio.NewScanner(wordlistFile)
- for scanner.Scan() {
- // RFC4343 - case insensitive domain
- text := strings.ToLower(scanner.Text())
- if text == "" {
- continue
- }
-
- // Generate permutations for each domain
- for _, domain := range instance.options.Domains {
- permutation := text + "." + domain
- bulkWriter.Append(permutation)
- permutationCount++
- }
- }
-
- // Stop the batcher and wait for completion
- bulkWriter.Stop()
- bulkWriter.WaitDone()
-
- if err := scanner.Err(); err != nil {
- return fmt.Errorf("error reading wordlist: %w", err)
- }
-
- gologger.Info().Msgf("Total permutations generated: %d\n", permutationCount)
-
- // Perform post-processing steps
- if instance.options.AutoExtractRootDomains {
- gologger.Info().Msgf("Started extracting root domains\n")
- now := time.Now()
- err = instance.autoExtractRootDomains(shstore)
- if err != nil {
- return fmt.Errorf("could not extract root domains: %w", err)
- }
- gologger.Info().Msgf("Root domain extraction completed in %s\n", time.Since(now))
- }
-
- // Perform wildcard filtering only if domain name has been specified
- if len(instance.options.Domains) > 0 {
- gologger.Info().Msgf("Started removing wildcards records\n")
- now := time.Now()
- err = instance.filterWildcards(shstore)
- if err != nil {
- return fmt.Errorf("could not filter wildcards: %w", err)
}
- gologger.Info().Msgf("Wildcard removal completed in %s\n", time.Since(now))
- }
-
- gologger.Info().Msgf("Finished enumeration, started writing output\n")
-
- // Write the final elaborated list out
- now := time.Now()
- err = instance.writeOutput(shstore)
+ return scanner.Err()
+ })
if err != nil {
- return fmt.Errorf("could not write output: %w", err)
+ return fmt.Errorf("could not resolve permutations: %w", err)
}
- gologger.Info().Msgf("Output written in %s\n", time.Since(now))
- return nil
+ gologger.Info().Msgf("Resolved %d permutations in %s\n", permutationCount.Load(), time.Since(now))
+
+ return instance.postProcess(shstore)
}
-// ProcessSubdomainsStreaming processes subdomain list using streaming with batcher
+// ProcessSubdomainsStreaming resolves a list of subdomains by streaming them
+// directly into the native resolver (no chunk files).
func (instance *Instance) ProcessSubdomainsStreaming(ctx context.Context, subdomainReader io.Reader) error {
- // Create a store for storing ip metadata
shstore, err := store.New(instance.options.TempDir)
if err != nil {
return fmt.Errorf("could not create store: %w", err)
}
defer shstore.Close()
- // Create batcher for streaming subdomains
- chunkNum := 0
- subdomainCount := 0
-
- bulkWriter := batcher.New[string](
- batcher.WithMaxCapacity[string](instance.options.BatchSize),
- batcher.WithFlushInterval[string](10*time.Second),
- batcher.WithFlushCallback[string](func(subdomains []string) {
- chunkNum++
- if len(subdomains) == 0 {
- return
- }
-
- gologger.Info().Msgf("Processing chunk %d (%d subdomains, total: %d)\n",
- chunkNum, len(subdomains), subdomainCount)
-
- // Create temporary chunk file
- chunkFile, err := os.CreateTemp(instance.options.TempDir, fmt.Sprintf("chunk-%d-", chunkNum))
- if err != nil {
- gologger.Error().Msgf("Could not create chunk file: %s\n", err)
- return
- }
-
- // Write subdomains to chunk file
- writer := bufio.NewWriter(chunkFile)
- for _, subdomain := range subdomains {
- _, err := writer.WriteString(subdomain + "\n")
- if err != nil {
- gologger.Error().Msgf("Could not write to chunk file: %s\n", err)
- _ = chunkFile.Close()
- _ = os.Remove(chunkFile.Name())
- return
- }
- }
- _ = writer.Flush()
- _ = chunkFile.Close()
-
- // Run massdns on this chunk
- chunkStart := time.Now()
- stdoutFile, stderrFile, took, err := instance.runChunk(ctx, chunkFile.Name())
- if err != nil {
- gologger.Error().Msgf("Could not execute massdns on chunk %d: %s\n", chunkNum, err)
- _ = os.Remove(chunkFile.Name())
- return
- }
-
- gologger.Info().Msgf("Chunk %d massdns execution took %s\n", chunkNum, took)
+ gologger.Info().Msgf("Started resolving subdomains with native resolver\n")
+ now := time.Now()
- // Parse the chunk output immediately
- parseStart := time.Now()
- err = instance.parseMassDNSOutputFile(stdoutFile, shstore)
- if err != nil {
- gologger.Error().Msgf("Could not parse massdns output for chunk %d: %s\n", chunkNum, err)
- _ = os.Remove(chunkFile.Name())
- _ = os.Remove(stdoutFile)
- if stderrFile != "" {
- _ = os.Remove(stderrFile)
- }
- return
+ var subdomainCount atomic.Int64
+ err = instance.resolveNames(ctx, shstore, func(out chan<- string) error {
+ scanner := bufio.NewScanner(subdomainReader)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ // RFC4343 - case insensitive domain
+ subdomain := strings.ToLower(strings.TrimSpace(scanner.Text()))
+ if subdomain == "" {
+ continue
}
-
- gologger.Info().Msgf("Chunk %d parsing completed in %s\n", chunkNum, time.Since(parseStart))
-
- // Clean up chunk files immediately
- _ = os.Remove(chunkFile.Name())
- _ = os.Remove(stdoutFile)
- if stderrFile != "" {
- _ = os.Remove(stderrFile)
+ select {
+ case out <- subdomain:
+ subdomainCount.Add(1)
+ case <-ctx.Done():
+ return ctx.Err()
}
-
- gologger.Info().Msgf("Chunk %d completed in %s\n", chunkNum, time.Since(chunkStart))
- }),
- )
-
- bulkWriter.Run()
-
- // Read subdomains and stream them to batcher
- scanner := bufio.NewScanner(subdomainReader)
- for scanner.Scan() {
- // RFC4343 - case insensitive domain
- subdomain := strings.ToLower(strings.TrimSpace(scanner.Text()))
- if subdomain == "" {
- continue
}
-
- bulkWriter.Append(subdomain)
- subdomainCount++
- }
-
- // Stop the batcher and wait for completion
- bulkWriter.Stop()
- bulkWriter.WaitDone()
-
- if err := scanner.Err(); err != nil {
- return fmt.Errorf("error reading subdomains: %w", err)
- }
-
- gologger.Info().Msgf("Total subdomains processed: %d\n", subdomainCount)
-
- // Perform post-processing steps
- if instance.options.AutoExtractRootDomains {
- gologger.Info().Msgf("Started extracting root domains\n")
- now := time.Now()
- err = instance.autoExtractRootDomains(shstore)
- if err != nil {
- return fmt.Errorf("could not extract root domains: %w", err)
- }
- gologger.Info().Msgf("Root domain extraction completed in %s\n", time.Since(now))
- }
-
- // Perform wildcard filtering only if domain name has been specified
- if len(instance.options.Domains) > 0 {
- gologger.Info().Msgf("Started removing wildcards records\n")
- now := time.Now()
- err = instance.filterWildcards(shstore)
- if err != nil {
- return fmt.Errorf("could not filter wildcards: %w", err)
- }
- gologger.Info().Msgf("Wildcard removal completed in %s\n", time.Since(now))
- }
-
- gologger.Info().Msgf("Finished enumeration, started writing output\n")
-
- // Write the final elaborated list out
- now := time.Now()
- err = instance.writeOutput(shstore)
+ return scanner.Err()
+ })
if err != nil {
- return fmt.Errorf("could not write output: %w", err)
+ return fmt.Errorf("could not resolve subdomains: %w", err)
}
- gologger.Info().Msgf("Output written in %s\n", time.Since(now))
- return nil
+ gologger.Info().Msgf("Resolved input of %d subdomains in %s\n", subdomainCount.Load(), time.Since(now))
+
+ return instance.postProcess(shstore)
}
diff --git a/pkg/output/binary.go b/pkg/output/binary.go
new file mode 100644
index 00000000..30af0b36
--- /dev/null
+++ b/pkg/output/binary.go
@@ -0,0 +1,135 @@
+package output
+
+import (
+ "encoding/binary"
+ "fmt"
+ "io"
+ "net"
+ "strconv"
+ "time"
+
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+)
+
+// massdns binary format (see massdns binfile_write_head / OUTPUT_BINARY).
+//
+// The file is platform-descriptive: a header records native sizes/offsets so
+// readers such as scripts/dnsparse.py can parse records written by this writer.
+// We emit a stable, documented layout that matches common Linux sockaddr field
+// offsets so files are interchangeable with Linux massdns when written there.
+
+const (
+ binaryVersion = uint32(0)
+ binarySockaddrSize = 128
+ binaryFamilyOffset = 0
+ binaryFamilySize = 2
+ binaryPortSize = 2
+ binaryTimeSize = 8
+ binarySizeTSize = 8
+ binaryAFInet = uint16(2) // AF_INET on Linux
+ binaryAFInet6 = uint16(10) // AF_INET6 on Linux
+ binarySinAddrOff = 4
+ binarySinPortOff = 2
+ binarySin6AddrOff = 8
+ binarySin6PortOff = 2
+)
+
+func writeBinaryHeader(w io.Writer) error {
+ ne := binary.NativeEndian
+ if _, err := w.Write([]byte("massdns\x00")); err != nil {
+ return err
+ }
+ if err := binary.Write(w, ne, uint32(0x12345678)); err != nil {
+ return err
+ }
+ if err := binary.Write(w, ne, binaryVersion); err != nil {
+ return err
+ }
+ if _, err := w.Write([]byte{byte(binarySizeTSize)}); err != nil {
+ return err
+ }
+ fields := []uint64{
+ binaryTimeSize,
+ binarySockaddrSize,
+ binaryFamilyOffset,
+ binaryFamilySize,
+ binaryPortSize,
+ }
+ for _, v := range fields {
+ if err := binary.Write(w, ne, v); err != nil {
+ return err
+ }
+ }
+ if err := binary.Write(w, ne, binaryAFInet); err != nil {
+ return err
+ }
+ if err := binary.Write(w, ne, uint64(binarySinAddrOff)); err != nil {
+ return err
+ }
+ if err := binary.Write(w, ne, uint64(binarySinPortOff)); err != nil {
+ return err
+ }
+ if err := binary.Write(w, ne, binaryAFInet6); err != nil {
+ return err
+ }
+ if err := binary.Write(w, ne, uint64(binarySin6AddrOff)); err != nil {
+ return err
+ }
+ if err := binary.Write(w, ne, uint64(binarySin6PortOff)); err != nil {
+ return err
+ }
+ return nil
+}
+
+func writeBinaryRecord(w io.Writer, r resolve.Result) error {
+ if r.Msg == nil {
+ return nil
+ }
+ raw, err := r.Msg.Pack()
+ if err != nil {
+ return err
+ }
+ if len(raw) > 0xffff {
+ return fmt.Errorf("dns message too large for binary format: %d", len(raw))
+ }
+
+ ts := r.Timestamp
+ if ts.IsZero() {
+ ts = time.Now()
+ }
+ ne := binary.NativeEndian
+ if err := binary.Write(w, ne, uint64(ts.Unix())); err != nil {
+ return err
+ }
+
+ var ss [binarySockaddrSize]byte
+ host, portStr, splitErr := net.SplitHostPort(r.Resolver)
+ if splitErr != nil {
+ host = r.Resolver
+ }
+ port := uint16(53)
+ if portStr != "" {
+ if p, convErr := strconv.Atoi(portStr); convErr == nil && p > 0 && p < 65536 {
+ port = uint16(p)
+ }
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ if v4 := ip.To4(); v4 != nil {
+ ne.PutUint16(ss[binaryFamilyOffset:], binaryAFInet)
+ binary.BigEndian.PutUint16(ss[binarySinPortOff:], port)
+ copy(ss[binarySinAddrOff:], v4)
+ } else {
+ ne.PutUint16(ss[binaryFamilyOffset:], binaryAFInet6)
+ binary.BigEndian.PutUint16(ss[binarySin6PortOff:], port)
+ copy(ss[binarySin6AddrOff:], ip.To16())
+ }
+ }
+ if _, err := w.Write(ss[:]); err != nil {
+ return err
+ }
+ if err := binary.Write(w, ne, uint16(len(raw))); err != nil {
+ return err
+ }
+ _, err = w.Write(raw)
+ return err
+}
diff --git a/pkg/output/binary_test.go b/pkg/output/binary_test.go
new file mode 100644
index 00000000..0bb3dc8e
--- /dev/null
+++ b/pkg/output/binary_test.go
@@ -0,0 +1,69 @@
+package output
+
+import (
+ "bytes"
+ "encoding/binary"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBinaryOutputRoundTripHeaderAndRecord(t *testing.T) {
+ r := sampleResult()
+ r.Timestamp = time.Unix(1513458347, 0)
+ r.Resolver = "192.0.2.1:53"
+
+ var buf bytes.Buffer
+ w, err := NewWriterWithConfig(&buf, Config{Format: "B"})
+ require.NoError(t, err)
+ require.NoError(t, w.Write(r))
+ require.NoError(t, w.Flush())
+
+ data := buf.Bytes()
+ require.True(t, bytes.HasPrefix(data, []byte("massdns\x00")))
+
+ // magic(8) + endian(4) + version(4) + size_len(1) = 17
+ off := 8
+ require.Equal(t, uint32(0x12345678), binary.NativeEndian.Uint32(data[off:off+4]))
+ off += 4
+ require.Equal(t, uint32(0), binary.NativeEndian.Uint32(data[off:off+4]))
+ off += 4
+ require.Equal(t, byte(8), data[off])
+ off++
+
+ // skip descriptive sizes/offsets (5*8 + family/offsets…)
+ // After size_len: time, sockaddr, family_off, family_size, port_size (5 uint64)
+ off += 5 * 8
+ // family_inet (2) + sin_addr_off (8) + sin_port_off (8)
+ off += 2 + 8 + 8
+ // family_inet6 (2) + sin6_addr_off (8) + sin6_port_off (8)
+ off += 2 + 8 + 8
+
+ // record: time(8) + sockaddr(128) + len(2) + payload
+ require.Greater(t, len(data), off+8+128+2)
+ ts := binary.NativeEndian.Uint64(data[off : off+8])
+ require.Equal(t, uint64(1513458347), ts)
+ off += 8
+ ss := data[off : off+128]
+ off += 128
+ require.Equal(t, binaryAFInet, binary.NativeEndian.Uint16(ss[0:2]))
+ require.Equal(t, uint16(53), binary.BigEndian.Uint16(ss[binarySinPortOff:binarySinPortOff+2]))
+ require.Equal(t, netIPv4(192, 0, 2, 1), ss[binarySinAddrOff:binarySinAddrOff+4])
+
+ msgLen := binary.NativeEndian.Uint16(data[off : off+2])
+ off += 2
+ raw := data[off : off+int(msgLen)]
+ msg := new(dns.Msg)
+ require.NoError(t, msg.Unpack(raw))
+ require.Equal(t, "example.com.", msg.Question[0].Name)
+}
+
+func netIPv4(a, b, c, d byte) []byte { return []byte{a, b, c, d} }
+
+func TestParseFormatBinary(t *testing.T) {
+ f, err := ParseFormat("B")
+ require.NoError(t, err)
+ require.Equal(t, modeBinary, f.mode)
+}
diff --git a/pkg/output/output.go b/pkg/output/output.go
new file mode 100644
index 00000000..ed8c33e6
--- /dev/null
+++ b/pkg/output/output.go
@@ -0,0 +1,598 @@
+// Package output renders resolver results in massdns-compatible output formats.
+//
+// It mirrors the massdns -o flag: a format string whose first recognized
+// major letter selects the mode (S simple, F full, L list, J ndjson) and whose
+// remaining letters toggle mode-specific options. The goal is drop-in parity so
+// existing massdns post-processing pipelines keep working against the native
+// resolver.
+//
+// Supported major modes:
+//
+// S - simple text (default; "name. TYPE data" per record, the form shuffledns parses)
+// F - full text (dig-like packet dump with ;; Server/Size/Unix time header)
+// L - domain list (tab-separated query/type/data, one record per line)
+// J - ndjson (one JSON object per reply)
+// B - binary (massdns binary packet stream; readable by dnsparse.py)
+package output
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+)
+
+type mode int
+
+const (
+ modeSimple mode = iota
+ modeFull
+ modeList
+ modeNDJSON
+ modeBinary
+)
+
+// Format is a parsed massdns -o specification.
+type Format struct {
+ mode mode
+
+ // simple-mode section selectors
+ answer bool // n
+ authority bool // u
+ additional bool // d
+
+ // simple-mode modifiers
+ ttlClass bool // t: include TTL and class
+ question bool // q: print the question line
+ meta bool // r: prepend resolver, unix ts and rcode to the question
+ sepReplies bool // l: blank line between replies
+ matchQ bool // m: only records whose owner matches the question name
+ indent bool // i: indent reply records by a tab
+ sepSections bool // s: blank line between sections
+
+ listIncludeEmpty bool // 0 (list mode): include NOERROR replies without answers
+ ndjsonFailures bool // e (ndjson mode): emit a record for terminal failures
+}
+
+// ParseFormat parses a massdns-style format string (e.g. "Snl", "F", "J").
+// An empty string defaults to simple answer-record output ("Sn").
+func ParseFormat(spec string) (*Format, error) {
+ f := &Format{mode: modeSimple}
+ modeSet := false
+ for _, r := range spec {
+ switch r {
+ case 'S':
+ f.mode, modeSet = modeSimple, true
+ case 'F':
+ f.mode, modeSet = modeFull, true
+ case 'L':
+ f.mode, modeSet = modeList, true
+ case 'J':
+ f.mode, modeSet = modeNDJSON, true
+ case 'B':
+ f.mode, modeSet = modeBinary, true
+ case 'n':
+ f.answer = true
+ case 'u':
+ f.authority = true
+ case 'd':
+ f.additional = true
+ case 't':
+ f.ttlClass = true
+ case 'q':
+ f.question = true
+ case 'r':
+ f.meta = true
+ case 'l':
+ f.sepReplies = true
+ case 'm':
+ f.matchQ = true
+ case 'i':
+ f.indent = true
+ case 's':
+ f.sepSections = true
+ case '0':
+ f.listIncludeEmpty = true
+ case 'e':
+ f.ndjsonFailures = true
+ case ' ', '\t':
+ // ignore separators
+ default:
+ return nil, fmt.Errorf("unknown output flag %q", string(r))
+ }
+ }
+ _ = modeSet
+ // Simple mode with no explicit section defaults to the answer section, which
+ // matches plain `-o S`.
+ if f.mode == modeSimple && !f.answer && !f.authority && !f.additional {
+ f.answer = true
+ }
+ return f, nil
+}
+
+// EmitsFailures reports whether terminal (post-retry) failures should be handed
+// to WriteFailure. Only ndjson with the 'e' flag records them.
+func (f *Format) EmitsFailures() bool { return f.mode == modeNDJSON && f.ndjsonFailures }
+
+// Config bundles a format spec with response-filtering options. It is the
+// SDK-friendly way to build a Writer with massdns --filter/--ignore semantics
+// and the "only the queried record type" behaviour (massdns issue #1).
+type Config struct {
+ // Format is the massdns-style -o spec (see ParseFormat). Empty = "Sn".
+ Format string
+ // FilterRcodes, when non-empty, restricts output to replies whose response
+ // code is in the set (massdns --filter). Names are response-code strings or
+ // numbers, e.g. "NOERROR", "NXDOMAIN", "3".
+ FilterRcodes []string
+ // IgnoreRcodes drops replies whose response code is in the set (massdns
+ // --ignore). Applied after FilterRcodes.
+ IgnoreRcodes []string
+ // OnlyQueryType emits only answer records whose type matches the queried
+ // type, dropping CNAME/glue chains from the output (massdns issue #1).
+ OnlyQueryType bool
+ // FlushEach flushes after every written reply (massdns --flush).
+ FlushEach bool
+}
+
+// rcodeValue resolves a response-code name or number to its numeric value.
+func rcodeValue(s string) (int, bool) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return 0, false
+ }
+ if v, ok := dns.StringToRcode[strings.ToUpper(s)]; ok {
+ return v, true
+ }
+ if n, err := strconv.Atoi(s); err == nil {
+ return n, true
+ }
+ return 0, false
+}
+
+func rcodeSet(names []string) (map[int]struct{}, error) {
+ if len(names) == 0 {
+ return nil, nil
+ }
+ set := make(map[int]struct{}, len(names))
+ for _, n := range names {
+ v, ok := rcodeValue(n)
+ if !ok {
+ return nil, fmt.Errorf("invalid response code %q", n)
+ }
+ set[v] = struct{}{}
+ }
+ return set, nil
+}
+
+// Writer renders results to an underlying writer. It is safe for concurrent use
+// (the resolver delivers results from multiple goroutines).
+type Writer struct {
+ mu sync.Mutex
+ bw *bufio.Writer
+ f *Format
+
+ filter map[int]struct{} // only these rcodes (nil = all)
+ ignore map[int]struct{} // drop these rcodes
+ onlyQType bool // emit only answer records matching the question type
+ flushEach bool
+ binHeader bool // binary mode header already written
+}
+
+// NewWriter builds a Writer for the given format spec.
+func NewWriter(w io.Writer, spec string) (*Writer, error) {
+ return NewWriterWithConfig(w, Config{Format: spec})
+}
+
+// NewWriterWithConfig builds a Writer from a full Config (format + filters).
+func NewWriterWithConfig(w io.Writer, cfg Config) (*Writer, error) {
+ f, err := ParseFormat(cfg.Format)
+ if err != nil {
+ return nil, err
+ }
+ filter, err := rcodeSet(cfg.FilterRcodes)
+ if err != nil {
+ return nil, err
+ }
+ ignore, err := rcodeSet(cfg.IgnoreRcodes)
+ if err != nil {
+ return nil, err
+ }
+ return &Writer{
+ bw: bufio.NewWriterSize(w, 64*1024),
+ f: f,
+ filter: filter,
+ ignore: ignore,
+ onlyQType: cfg.OnlyQueryType,
+ flushEach: cfg.FlushEach,
+ }, nil
+}
+
+// suppressed reports whether a reply with the given rcode should be dropped by
+// the configured --filter/--ignore sets.
+func (w *Writer) suppressed(rcode int) bool {
+ if w.filter != nil {
+ if _, ok := w.filter[rcode]; !ok {
+ return true
+ }
+ }
+ if w.ignore != nil {
+ if _, ok := w.ignore[rcode]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+// Flush flushes buffered output.
+func (w *Writer) Flush() error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.bw.Flush()
+}
+
+// Write renders a single result.
+func (w *Writer) Write(r resolve.Result) error {
+ if w.suppressed(r.Rcode) {
+ return nil
+ }
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ var err error
+ switch w.f.mode {
+ case modeSimple:
+ err = w.writeSimple(r)
+ case modeFull:
+ err = w.writeFull(r)
+ case modeList:
+ err = w.writeList(r)
+ case modeNDJSON:
+ err = w.writeNDJSON(r)
+ case modeBinary:
+ err = w.writeBinary(r)
+ }
+ if err == nil && w.flushEach {
+ err = w.bw.Flush()
+ }
+ return err
+}
+
+func (w *Writer) writeBinary(r resolve.Result) error {
+ if !w.binHeader {
+ if err := writeBinaryHeader(w.bw); err != nil {
+ return err
+ }
+ w.binHeader = true
+ }
+ return writeBinaryRecord(w.bw, r)
+}
+
+// answerRecords returns the answer section, optionally filtered to records whose
+// type matches the queried type (the OnlyQueryType behaviour).
+func (w *Writer) answerRecords(r resolve.Result) []dns.RR {
+ if r.Msg == nil {
+ return nil
+ }
+ if !w.onlyQType {
+ return r.Msg.Answer
+ }
+ out := make([]dns.RR, 0, len(r.Msg.Answer))
+ for _, rr := range r.Msg.Answer {
+ if rr.Header().Rrtype == r.Type {
+ out = append(out, rr)
+ }
+ }
+ return out
+}
+
+// WriteFailure records a terminal query failure (ndjson 'e' flag only).
+func (w *Writer) WriteFailure(name string, qtype uint16, reason string) error {
+ if !w.f.EmitsFailures() {
+ return nil
+ }
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ rec := jsonReply{
+ Name: dns.Fqdn(name),
+ Type: typeString(qtype),
+ Class: "IN",
+ Status: "ERROR",
+ Error: reason,
+ }
+ return w.encodeJSON(rec)
+}
+
+// ---- simple ----
+
+func (w *Writer) writeSimple(r resolve.Result) error {
+ qname := questionName(r)
+
+ if w.f.meta {
+ // resolver, unix timestamp and rcode prepended to the question
+ if _, err := fmt.Fprintf(w.bw, "%s %d %s %s %s %s\n",
+ emptyDash(r.Resolver), r.Timestamp.Unix(), rcodeString(r.Rcode),
+ qname, "IN", typeString(r.Type)); err != nil {
+ return err
+ }
+ } else if w.f.question {
+ if _, err := fmt.Fprintf(w.bw, "%s %s %s\n", qname, "IN", typeString(r.Type)); err != nil {
+ return err
+ }
+ }
+
+ wrote := false
+ emit := func(rrs []dns.RR) error {
+ if w.f.sepSections && wrote && len(rrs) > 0 {
+ if err := w.bw.WriteByte('\n'); err != nil {
+ return err
+ }
+ }
+ for _, rr := range rrs {
+ if w.f.matchQ && !strings.EqualFold(rr.Header().Name, qname) {
+ continue
+ }
+ if w.f.indent {
+ if err := w.bw.WriteByte('\t'); err != nil {
+ return err
+ }
+ }
+ if _, err := w.bw.WriteString(w.simpleLine(rr)); err != nil {
+ return err
+ }
+ wrote = true
+ }
+ return nil
+ }
+
+ if r.Msg != nil {
+ if w.f.answer {
+ if err := emit(w.answerRecords(r)); err != nil {
+ return err
+ }
+ }
+ if w.f.authority {
+ if err := emit(r.Msg.Ns); err != nil {
+ return err
+ }
+ }
+ if w.f.additional {
+ if err := emit(filterOPT(r.Msg.Extra)); err != nil {
+ return err
+ }
+ }
+ }
+
+ // 'l': separate replies with a blank line (the classic -o Snl form).
+ if w.f.sepReplies {
+ if err := w.bw.WriteByte('\n'); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// simpleLine renders one RR as "name TYPE data" (or "name TTL CLASS TYPE data"
+// with the 't' flag), terminated by a newline.
+func (w *Writer) simpleLine(rr dns.RR) string {
+ h := rr.Header()
+ data := rdata(rr)
+ if w.f.ttlClass {
+ return fmt.Sprintf("%s %d %s %s %s\n", h.Name, h.Ttl, classString(h.Class), typeString(h.Rrtype), data)
+ }
+ return fmt.Sprintf("%s %s %s\n", h.Name, typeString(h.Rrtype), data)
+}
+
+// ---- full ----
+
+func (w *Writer) writeFull(r resolve.Result) error {
+ size := 0
+ body := ""
+ if r.Msg != nil {
+ size = r.Msg.Len()
+ body = r.Msg.String()
+ }
+ if _, err := fmt.Fprintf(w.bw, ";; Server: %s\n;; Size: %d\n;; Unix time: %d\n%s\n\n",
+ emptyDash(r.Resolver), size, r.Timestamp.Unix(), body); err != nil {
+ return err
+ }
+ return nil
+}
+
+// ---- list ----
+
+func (w *Writer) writeList(r resolve.Result) error {
+ if r.Msg == nil {
+ return nil
+ }
+ qname := questionName(r)
+ answers := w.answerRecords(r)
+ if len(answers) == 0 {
+ if w.f.listIncludeEmpty && r.Rcode == dns.RcodeSuccess {
+ if _, err := fmt.Fprintf(w.bw, "%s\n", strings.TrimSuffix(qname, ".")); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ for _, rr := range answers {
+ h := rr.Header()
+ if _, err := fmt.Fprintf(w.bw, "%s\t%s\t%s\n",
+ strings.TrimSuffix(h.Name, "."), typeString(h.Rrtype), rdata(rr)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// ---- ndjson ----
+
+type jsonRecord struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Class string `json:"class"`
+ TTL uint32 `json:"ttl"`
+ Data string `json:"data"`
+}
+
+type jsonData struct {
+ Answers []jsonRecord `json:"answers"`
+ Authorities []jsonRecord `json:"authorities,omitempty"`
+ Additionals []jsonRecord `json:"additionals,omitempty"`
+}
+
+type jsonReply struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Class string `json:"class"`
+ Status string `json:"status"`
+ RxTs int64 `json:"rx_ts,omitempty"`
+ Resolver string `json:"resolver,omitempty"`
+ Flags []string `json:"flags,omitempty"`
+ Data jsonData `json:"data"`
+ Error string `json:"error,omitempty"`
+}
+
+func (w *Writer) writeNDJSON(r resolve.Result) error {
+ rec := jsonReply{
+ Name: questionName(r),
+ Type: typeString(r.Type),
+ Class: "IN",
+ Status: rcodeString(r.Rcode),
+ Resolver: r.Resolver,
+ }
+ if !r.Timestamp.IsZero() {
+ rec.RxTs = r.Timestamp.Unix()
+ }
+ if r.Msg != nil {
+ rec.Flags = msgFlags(r.Msg)
+ rec.Data.Answers = jsonRecords(w.answerRecords(r))
+ rec.Data.Authorities = jsonRecords(r.Msg.Ns)
+ rec.Data.Additionals = jsonRecords(filterOPT(r.Msg.Extra))
+ }
+ if rec.Data.Answers == nil {
+ rec.Data.Answers = []jsonRecord{}
+ }
+ return w.encodeJSON(rec)
+}
+
+func (w *Writer) encodeJSON(rec jsonReply) error {
+ b, err := json.Marshal(rec)
+ if err != nil {
+ return err
+ }
+ if _, err := w.bw.Write(b); err != nil {
+ return err
+ }
+ return w.bw.WriteByte('\n')
+}
+
+func jsonRecords(rrs []dns.RR) []jsonRecord {
+ if len(rrs) == 0 {
+ return nil
+ }
+ out := make([]jsonRecord, 0, len(rrs))
+ for _, rr := range rrs {
+ h := rr.Header()
+ out = append(out, jsonRecord{
+ Name: h.Name,
+ Type: typeString(h.Rrtype),
+ Class: classString(h.Class),
+ TTL: h.Ttl,
+ Data: rdata(rr),
+ })
+ }
+ return out
+}
+
+// ---- helpers ----
+
+// rdata returns the record-specific data portion of an RR (everything after the
+// "name ttl class type" header that miekg/dns prints).
+func rdata(rr dns.RR) string {
+ full := rr.String()
+ header := rr.Header().String()
+ return strings.TrimPrefix(full, header)
+}
+
+// filterOPT drops EDNS0 OPT pseudo-records from the additional section; they are
+// transport metadata, not answer data, and massdns does not print them.
+func filterOPT(rrs []dns.RR) []dns.RR {
+ if len(rrs) == 0 {
+ return nil
+ }
+ out := rrs[:0:0]
+ for _, rr := range rrs {
+ if _, ok := rr.(*dns.OPT); ok {
+ continue
+ }
+ out = append(out, rr)
+ }
+ return out
+}
+
+func msgFlags(m *dns.Msg) []string {
+ var f []string
+ if m.Response {
+ f = append(f, "qr")
+ }
+ if m.Authoritative {
+ f = append(f, "aa")
+ }
+ if m.Truncated {
+ f = append(f, "tc")
+ }
+ if m.RecursionDesired {
+ f = append(f, "rd")
+ }
+ if m.RecursionAvailable {
+ f = append(f, "ra")
+ }
+ if m.AuthenticatedData {
+ f = append(f, "ad")
+ }
+ if m.CheckingDisabled {
+ f = append(f, "cd")
+ }
+ return f
+}
+
+// questionName returns the query name with a trailing dot (massdns convention).
+func questionName(r resolve.Result) string {
+ if r.Msg != nil && len(r.Msg.Question) > 0 {
+ return r.Msg.Question[0].Name
+ }
+ return dns.Fqdn(r.Name)
+}
+
+func typeString(t uint16) string {
+ if s, ok := dns.TypeToString[t]; ok {
+ return s
+ }
+ return fmt.Sprintf("TYPE%d", t)
+}
+
+func classString(c uint16) string {
+ if s, ok := dns.ClassToString[c]; ok {
+ return s
+ }
+ return fmt.Sprintf("CLASS%d", c)
+}
+
+func rcodeString(rc int) string {
+ if s, ok := dns.RcodeToString[rc]; ok {
+ return s
+ }
+ return fmt.Sprintf("RCODE%d", rc)
+}
+
+func emptyDash(s string) string {
+ if s == "" {
+ return "-"
+ }
+ return s
+}
diff --git a/pkg/output/output_test.go b/pkg/output/output_test.go
new file mode 100644
index 00000000..d8fe4135
--- /dev/null
+++ b/pkg/output/output_test.go
@@ -0,0 +1,261 @@
+package output
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/pkg/parser"
+ "github.com/projectdiscovery/shuffledns/pkg/resolve"
+)
+
+// sampleResult builds a resolve.Result with a fully populated *dns.Msg for
+// example.com -> 93.184.216.34 (A) with an authority NS record.
+func sampleResult() resolve.Result {
+ m := new(dns.Msg)
+ m.SetQuestion("example.com.", dns.TypeA)
+ m.Response = true
+ m.RecursionDesired = true
+ m.RecursionAvailable = true
+ a, _ := dns.NewRR("example.com. 45929 IN A 93.184.216.34")
+ m.Answer = []dns.RR{a}
+ ns, _ := dns.NewRR("example.com. 24852 IN NS a.iana-servers.net.")
+ m.Ns = []dns.RR{ns}
+
+ return resolve.Result{
+ Name: "example.com",
+ Type: dns.TypeA,
+ Rcode: dns.RcodeSuccess,
+ A: []string{"93.184.216.34"},
+ Resolver: "192.0.2.1:53",
+ Msg: m,
+ Timestamp: time.Unix(1513458347, 0),
+ }
+}
+
+func render(t *testing.T, spec string, r resolve.Result) string {
+ t.Helper()
+ var buf bytes.Buffer
+ w, err := NewWriter(&buf, spec)
+ if err != nil {
+ t.Fatalf("NewWriter(%q): %v", spec, err)
+ }
+ if err := w.Write(r); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ if err := w.Flush(); err != nil {
+ t.Fatalf("Flush: %v", err)
+ }
+ return buf.String()
+}
+
+func TestSimpleDefault(t *testing.T) {
+ got := render(t, "S", sampleResult())
+ want := "example.com. A 93.184.216.34\n"
+ if got != want {
+ t.Fatalf("got %q want %q", got, want)
+ }
+}
+
+func TestSimpleSnlRoundTripsThroughParser(t *testing.T) {
+ // The classic massdns -o Snl form must be consumable by pkg/parser.
+ got := render(t, "Snl", sampleResult())
+ if !strings.HasSuffix(got, "\n\n") {
+ t.Fatalf("Snl should end replies with a blank line: %q", got)
+ }
+
+ var domain string
+ var ips []string
+ err := parser.ParseReader(strings.NewReader(got), func(d string, ip []string) error {
+ domain = d
+ ips = ip
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if domain != "example.com" {
+ t.Fatalf("parser domain: got %q", domain)
+ }
+ if len(ips) != 1 || ips[0] != "93.184.216.34" {
+ t.Fatalf("parser ips: got %v", ips)
+ }
+}
+
+func TestSimpleTTLClass(t *testing.T) {
+ got := render(t, "St", sampleResult())
+ want := "example.com. 45929 IN A 93.184.216.34\n"
+ if got != want {
+ t.Fatalf("got %q want %q", got, want)
+ }
+}
+
+func TestSimpleQuestionAndAuthority(t *testing.T) {
+ got := render(t, "Snuq", sampleResult())
+ if !strings.Contains(got, "example.com. IN A\n") {
+ t.Fatalf("missing question line: %q", got)
+ }
+ if !strings.Contains(got, "example.com. A 93.184.216.34\n") {
+ t.Fatalf("missing answer: %q", got)
+ }
+ if !strings.Contains(got, "example.com. NS a.iana-servers.net.\n") {
+ t.Fatalf("missing authority: %q", got)
+ }
+}
+
+func TestSimpleMeta(t *testing.T) {
+ got := render(t, "Snr", sampleResult())
+ if !strings.HasPrefix(got, "192.0.2.1:53 1513458347 NOERROR example.com. IN A\n") {
+ t.Fatalf("meta prefix wrong: %q", got)
+ }
+}
+
+func TestFull(t *testing.T) {
+ got := render(t, "F", sampleResult())
+ for _, want := range []string{
+ ";; Server: 192.0.2.1:53",
+ ";; Unix time: 1513458347",
+ "ANSWER SECTION",
+ "93.184.216.34",
+ } {
+ if !strings.Contains(got, want) {
+ t.Fatalf("full output missing %q in:\n%s", want, got)
+ }
+ }
+}
+
+func TestList(t *testing.T) {
+ got := render(t, "L", sampleResult())
+ want := "example.com\tA\t93.184.216.34\n"
+ if got != want {
+ t.Fatalf("got %q want %q", got, want)
+ }
+}
+
+func TestNDJSON(t *testing.T) {
+ got := render(t, "J", sampleResult())
+ var rec struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Class string `json:"class"`
+ Status string `json:"status"`
+ Resolver string `json:"resolver"`
+ RxTs int64 `json:"rx_ts"`
+ Data struct {
+ Answers []struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ TTL uint32 `json:"ttl"`
+ Data string `json:"data"`
+ } `json:"answers"`
+ } `json:"data"`
+ Flags []string `json:"flags"`
+ }
+ if err := json.Unmarshal([]byte(strings.TrimSpace(got)), &rec); err != nil {
+ t.Fatalf("invalid ndjson %q: %v", got, err)
+ }
+ if rec.Name != "example.com." || rec.Type != "A" || rec.Status != "NOERROR" {
+ t.Fatalf("bad header fields: %+v", rec)
+ }
+ if rec.Resolver != "192.0.2.1:53" || rec.RxTs != 1513458347 {
+ t.Fatalf("bad meta: %+v", rec)
+ }
+ if len(rec.Data.Answers) != 1 || rec.Data.Answers[0].Data != "93.184.216.34" || rec.Data.Answers[0].TTL != 45929 {
+ t.Fatalf("bad answers: %+v", rec.Data.Answers)
+ }
+}
+
+func TestNDJSONFailure(t *testing.T) {
+ var buf bytes.Buffer
+ w, err := NewWriter(&buf, "Je")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := w.WriteFailure("dead.example.com", dns.TypeA, "resolution failed"); err != nil {
+ t.Fatal(err)
+ }
+ _ = w.Flush()
+ if !strings.Contains(buf.String(), `"status":"ERROR"`) || !strings.Contains(buf.String(), "dead.example.com.") {
+ t.Fatalf("bad failure record: %q", buf.String())
+ }
+
+ // without 'e', failures are suppressed
+ var buf2 bytes.Buffer
+ w2, _ := NewWriter(&buf2, "J")
+ _ = w2.WriteFailure("dead.example.com", dns.TypeA, "x")
+ _ = w2.Flush()
+ if buf2.Len() != 0 {
+ t.Fatalf("expected no failure output without 'e', got %q", buf2.String())
+ }
+}
+
+func renderCfg(t *testing.T, cfg Config, r resolve.Result) string {
+ t.Helper()
+ var buf bytes.Buffer
+ w, err := NewWriterWithConfig(&buf, cfg)
+ if err != nil {
+ t.Fatalf("NewWriterWithConfig: %v", err)
+ }
+ if err := w.Write(r); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ _ = w.Flush()
+ return buf.String()
+}
+
+func TestFilterRcode(t *testing.T) {
+ r := sampleResult() // NOERROR
+ // only NXDOMAIN allowed -> NOERROR reply suppressed
+ if got := renderCfg(t, Config{Format: "S", FilterRcodes: []string{"NXDOMAIN"}}, r); got != "" {
+ t.Fatalf("expected suppression, got %q", got)
+ }
+ // NOERROR allowed -> emitted
+ if got := renderCfg(t, Config{Format: "S", FilterRcodes: []string{"NOERROR"}}, r); got == "" {
+ t.Fatal("expected output for allowed rcode")
+ }
+}
+
+func TestIgnoreRcode(t *testing.T) {
+ r := sampleResult() // NOERROR
+ if got := renderCfg(t, Config{Format: "S", IgnoreRcodes: []string{"NOERROR"}}, r); got != "" {
+ t.Fatalf("expected NOERROR to be ignored, got %q", got)
+ }
+}
+
+func TestOnlyQueryType(t *testing.T) {
+ // build a result whose answer has a CNAME plus the queried A record
+ m := new(dns.Msg)
+ m.SetQuestion("www.example.com.", dns.TypeA)
+ cname, _ := dns.NewRR("www.example.com. 300 IN CNAME example.com.")
+ a, _ := dns.NewRR("example.com. 300 IN A 93.184.216.34")
+ m.Answer = []dns.RR{cname, a}
+ r := resolve.Result{Name: "www.example.com", Type: dns.TypeA, Rcode: dns.RcodeSuccess, Msg: m}
+
+ full := renderCfg(t, Config{Format: "S"}, r)
+ if !strings.Contains(full, "CNAME") {
+ t.Fatalf("expected CNAME in unfiltered output: %q", full)
+ }
+ only := renderCfg(t, Config{Format: "S", OnlyQueryType: true}, r)
+ if strings.Contains(only, "CNAME") {
+ t.Fatalf("only-type output should drop CNAME: %q", only)
+ }
+ if !strings.Contains(only, "A 93.184.216.34") {
+ t.Fatalf("only-type output should keep the A record: %q", only)
+ }
+}
+
+func TestInvalidRcodeConfig(t *testing.T) {
+ var buf bytes.Buffer
+ if _, err := NewWriterWithConfig(&buf, Config{Format: "S", FilterRcodes: []string{"BOGUS"}}); err == nil {
+ t.Fatal("expected error for invalid rcode name")
+ }
+}
+
+func TestUnknownFlag(t *testing.T) {
+ if _, err := ParseFormat("SZ"); err == nil {
+ t.Fatal("expected error for unknown flag")
+ }
+}
diff --git a/pkg/ptr/ptr.go b/pkg/ptr/ptr.go
new file mode 100644
index 00000000..25ec25e4
--- /dev/null
+++ b/pkg/ptr/ptr.go
@@ -0,0 +1,204 @@
+// Package ptr generates reverse-DNS (PTR) query names from IP targets, the
+// native equivalent of massdns's scripts/ptr.py. Targets may be single IPs,
+// CIDR blocks, or inclusive "start-end" ranges, in IPv4 or IPv6.
+//
+// Each emitted name is the reversed in-addr.arpa (IPv4) or ip6.arpa (IPv6)
+// label that a resolver expects for a PTR lookup; e.g. 1.2.3.4 becomes
+// 4.3.2.1.in-addr.arpa. These names are fed straight into the resolver with
+// query type PTR.
+package ptr
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/miekg/dns"
+)
+
+// minIPv6Prefix bounds IPv6 CIDR expansion so a careless /32 doesn't try to
+// enumerate an astronomically large space. /112 is 65536 addresses.
+const minIPv6Prefix = 112
+
+// ReverseName returns the reverse-DNS name (in-addr.arpa / ip6.arpa) for ip.
+func ReverseName(ip net.IP) (string, error) {
+ if ip == nil {
+ return "", fmt.Errorf("nil ip")
+ }
+ return dns.ReverseAddr(ip.String())
+}
+
+// Expand walks every IP described by targets and calls emit with its reverse
+// name. emit may return false to stop early. Targets are parsed as, in order:
+// an inclusive range "a-b", a CIDR "ip/bits", or a single IP.
+func Expand(targets []string, emit func(name string) bool) error {
+ for _, t := range targets {
+ t = strings.TrimSpace(t)
+ if t == "" {
+ continue
+ }
+ stop, err := expandOne(t, emit)
+ if err != nil {
+ return err
+ }
+ if stop {
+ return nil
+ }
+ }
+ return nil
+}
+
+// Stream feeds reverse names produced from targets into out, honouring ctx
+// cancellation. It does not close out (the caller owns it).
+func Stream(ctx context.Context, targets []string, out chan<- string) error {
+ return Expand(targets, func(name string) bool {
+ select {
+ case <-ctx.Done():
+ return false
+ case out <- name:
+ return true
+ }
+ })
+}
+
+// expandOne handles a single target token. It returns stop=true when emit asked
+// to halt.
+func expandOne(t string, emit func(string) bool) (bool, error) {
+ switch {
+ case strings.Contains(t, "-"):
+ return expandRange(t, emit)
+ case strings.Contains(t, "/"):
+ return expandCIDR(t, emit)
+ default:
+ ip := net.ParseIP(t)
+ if ip == nil {
+ return false, fmt.Errorf("invalid IP %q", t)
+ }
+ return emitIP(ip, emit), nil
+ }
+}
+
+func expandRange(t string, emit func(string) bool) (bool, error) {
+ parts := strings.SplitN(t, "-", 2)
+ startIP := net.ParseIP(strings.TrimSpace(parts[0]))
+ endIP := net.ParseIP(strings.TrimSpace(parts[1]))
+ if startIP == nil || endIP == nil {
+ return false, fmt.Errorf("invalid range %q", t)
+ }
+ start, sv4 := normalize(startIP)
+ end, ev4 := normalize(endIP)
+ if sv4 != ev4 {
+ return false, fmt.Errorf("range %q mixes IPv4 and IPv6", t)
+ }
+ if compareBytes(start, end) > 0 {
+ return false, fmt.Errorf("range %q start is after end", t)
+ }
+ cur := dupIP(start)
+ for {
+ if !emit(reverse(cur, sv4)) {
+ return true, nil
+ }
+ if compareBytes(cur, end) == 0 {
+ return false, nil
+ }
+ inc(cur)
+ }
+}
+
+func expandCIDR(t string, emit func(string) bool) (bool, error) {
+ _, ipnet, err := net.ParseCIDR(t)
+ if err != nil {
+ return false, err
+ }
+ ones, bits := ipnet.Mask.Size()
+ isV4 := bits == 32
+ if !isV4 && ones < minIPv6Prefix {
+ return false, fmt.Errorf("IPv6 CIDR %q too large; use /%d or longer", t, minIPv6Prefix)
+ }
+ // network address (already masked by ParseCIDR) and the last address in the
+ // block: last = network | ^mask.
+ first := dupIP(maskedBase(ipnet.IP, isV4))
+ mask := ipnet.Mask
+ last := dupIP(first)
+ for i := range last {
+ last[i] |= ^mask[i]
+ }
+ cur := dupIP(first)
+ for {
+ if !emit(reverse(cur, isV4)) {
+ return true, nil
+ }
+ if compareBytes(cur, last) == 0 {
+ return false, nil
+ }
+ inc(cur)
+ }
+}
+
+// maskedBase normalizes a CIDR network IP to its 4- or 16-byte form.
+func maskedBase(ip net.IP, v4 bool) []byte {
+ if v4 {
+ return dupIP(ip.To4())
+ }
+ return dupIP(ip.To16())
+}
+
+func emitIP(ip net.IP, emit func(string) bool) bool {
+ b, v4 := normalize(ip)
+ return !emit(reverse(b, v4))
+}
+
+// reverse builds the reverse-DNS name for a normalized address. v4 selects the
+// 4-byte vs 16-byte interpretation.
+func reverse(b []byte, v4 bool) string {
+ var ip net.IP
+ if v4 {
+ ip = net.IPv4(b[0], b[1], b[2], b[3])
+ } else {
+ ip = make(net.IP, len(b))
+ copy(ip, b)
+ }
+ name, err := dns.ReverseAddr(ip.String())
+ if err != nil {
+ return ""
+ }
+ return name
+}
+
+// normalize returns the address in its minimal byte form (4 bytes for IPv4,
+// 16 for IPv6) and whether it is IPv4.
+func normalize(ip net.IP) ([]byte, bool) {
+ if v4 := ip.To4(); v4 != nil {
+ return dupIP(v4), true
+ }
+ return dupIP(ip.To16()), false
+}
+
+func dupIP(ip net.IP) []byte {
+ b := make([]byte, len(ip))
+ copy(b, ip)
+ return b
+}
+
+// inc increments a big-endian byte-encoded address in place.
+func inc(b []byte) {
+ for i := len(b) - 1; i >= 0; i-- {
+ b[i]++
+ if b[i] != 0 {
+ break
+ }
+ }
+}
+
+func compareBytes(a, b []byte) int {
+ for i := range a {
+ switch {
+ case a[i] < b[i]:
+ return -1
+ case a[i] > b[i]:
+ return 1
+ }
+ }
+ return 0
+}
diff --git a/pkg/ptr/ptr_test.go b/pkg/ptr/ptr_test.go
new file mode 100644
index 00000000..9a752e62
--- /dev/null
+++ b/pkg/ptr/ptr_test.go
@@ -0,0 +1,121 @@
+package ptr
+
+import (
+ "context"
+ "net"
+ "testing"
+)
+
+func collect(t *testing.T, targets ...string) []string {
+ t.Helper()
+ var got []string
+ if err := Expand(targets, func(name string) bool {
+ got = append(got, name)
+ return true
+ }); err != nil {
+ t.Fatalf("Expand(%v): %v", targets, err)
+ }
+ return got
+}
+
+func TestReverseName(t *testing.T) {
+ name, err := ReverseName(net.ParseIP("1.2.3.4"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if name != "4.3.2.1.in-addr.arpa." {
+ t.Fatalf("got %q", name)
+ }
+}
+
+func TestSingleIP(t *testing.T) {
+ got := collect(t, "192.0.2.5")
+ if len(got) != 1 || got[0] != "5.2.0.192.in-addr.arpa." {
+ t.Fatalf("got %v", got)
+ }
+}
+
+func TestExpandCIDRv4(t *testing.T) {
+ got := collect(t, "192.0.2.0/30")
+ want := []string{
+ "0.2.0.192.in-addr.arpa.",
+ "1.2.0.192.in-addr.arpa.",
+ "2.2.0.192.in-addr.arpa.",
+ "3.2.0.192.in-addr.arpa.",
+ }
+ if len(got) != len(want) {
+ t.Fatalf("count: got %d want %d (%v)", len(got), len(want), got)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("idx %d: got %q want %q", i, got[i], want[i])
+ }
+ }
+}
+
+func TestExpandRange(t *testing.T) {
+ got := collect(t, "192.0.2.1-192.0.2.3")
+ if len(got) != 3 {
+ t.Fatalf("count: got %d (%v)", len(got), got)
+ }
+ if got[0] != "1.2.0.192.in-addr.arpa." || got[2] != "3.2.0.192.in-addr.arpa." {
+ t.Fatalf("got %v", got)
+ }
+}
+
+func TestExpandCIDRv6Small(t *testing.T) {
+ got := collect(t, "2001:db8::/126")
+ if len(got) != 4 {
+ t.Fatalf("count: got %d (%v)", len(got), got)
+ }
+ for _, n := range got {
+ if len(n) < len("ip6.arpa.") || n[len(n)-len("ip6.arpa."):] != "ip6.arpa." {
+ t.Fatalf("not an ip6.arpa name: %q", n)
+ }
+ }
+}
+
+func TestExpandCIDRv6TooLarge(t *testing.T) {
+ err := Expand([]string{"2001:db8::/32"}, func(string) bool { return true })
+ if err == nil {
+ t.Fatal("expected error for oversized IPv6 CIDR")
+ }
+}
+
+func TestEarlyStop(t *testing.T) {
+ count := 0
+ err := Expand([]string{"10.0.0.0/8"}, func(string) bool {
+ count++
+ return count < 5 // stop after 5
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != 5 {
+ t.Fatalf("expected early stop at 5, got %d", count)
+ }
+}
+
+func TestInvalidTarget(t *testing.T) {
+ if err := Expand([]string{"not-an-ip"}, func(string) bool { return true }); err == nil {
+ t.Fatal("expected error for invalid target")
+ }
+}
+
+func TestStreamCancel(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ out := make(chan string, 2)
+ done := make(chan error, 1)
+ go func() { done <- Stream(ctx, []string{"10.0.0.0/8"}, out) }()
+
+ <-out // consume one
+ cancel()
+ // drain so Stream isn't blocked on send
+ go func() {
+ for range out {
+ }
+ }()
+ if err := <-done; err != nil {
+ t.Fatalf("Stream returned error: %v", err)
+ }
+}
diff --git a/pkg/resolve/bench_test.go b/pkg/resolve/bench_test.go
new file mode 100644
index 00000000..69bf843b
--- /dev/null
+++ b/pkg/resolve/bench_test.go
@@ -0,0 +1,179 @@
+package resolve
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/shuffledns/internal/simdns"
+)
+
+// This file benchmarks the native resolver against a battery of loopback UDP
+// DNS servers that simulate remote resolvers (see internal/simdns). No traffic
+// leaves the host. It is gated behind RESOLVE_BENCH so `go test ./...` stays
+// fast. Example:
+//
+// RESOLVE_BENCH=1 go test ./pkg/resolve -run TestResolverBenchmark -v \
+// -bench.names 200000 -bench.resolvers 16 -bench.hit 5
+
+var (
+ benchNames = flag.Int("bench.names", 50000, "number of names to resolve per scenario")
+ benchResolvers = flag.Int("bench.resolvers", 8, "number of simulated loopback resolvers")
+ benchHit = flag.Int("bench.hit", 5, "percentage of names that resolve (rest are NXDOMAIN)")
+ benchConc = flag.Int("bench.concurrency", 10000, "client in-flight concurrency")
+ benchSockets = flag.Int("bench.sockets", 8, "client udp socket count")
+ benchBatch = flag.String("bench.batch", "off", "batching mode: off | on | adaptive")
+ benchHealth = flag.Bool("bench.health", false, "enable per-resolver health scoring")
+ benchAdaptConc = flag.Bool("bench.adaptconc", false, "enable adaptive concurrency")
+ benchHooks = flag.Bool("bench.hooks", false, "attach no-op lifecycle hooks (measure hook overhead)")
+)
+
+// noopHooks returns a full set of no-op lifecycle hooks for overhead testing.
+func noopHooks() Hooks {
+ return Hooks{
+ OnQuery: func(QueryInfo) {},
+ OnRetry: func(QueryInfo) {},
+ OnResponse: func(QueryInfo, *dns.Msg) {},
+ OnTimeout: func(QueryInfo) {},
+ OnTruncated: func(QueryInfo) {},
+ OnCrossCheckFailed: func(string, []string, []string) {},
+ OnResolverState: func(string, bool) {},
+ }
+}
+
+func benchBatchMode(s string) BatchMode {
+ switch s {
+ case "on":
+ return BatchEnabled
+ case "adaptive":
+ return BatchAdaptive
+ default:
+ return BatchDisabled
+ }
+}
+
+// scenario pairs a human-readable name with simulated network conditions.
+type scenario struct {
+ name string
+ cfg simdns.Config
+}
+
+func benchScenarios(hit int) []scenario {
+ return []scenario{
+ {"lan-fast", simdns.Config{BaseLatency: 200 * time.Microsecond, Jitter: 300 * time.Microsecond, HitPercent: hit}},
+ {"wan-typical", simdns.Config{BaseLatency: 15 * time.Millisecond, Jitter: 10 * time.Millisecond, LossRate: 0.005, HitPercent: hit}},
+ {"wan-lossy", simdns.Config{BaseLatency: 25 * time.Millisecond, Jitter: 20 * time.Millisecond, LossRate: 0.05, ServfailRate: 0.02, HitPercent: hit}},
+ {"rate-limited", simdns.Config{BaseLatency: 10 * time.Millisecond, Jitter: 10 * time.Millisecond, QPSPerServer: 3000, HitPercent: hit}},
+ }
+}
+
+func TestResolverBenchmark(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping resolver benchmark in -short mode")
+ }
+ if os.Getenv("RESOLVE_BENCH") == "" {
+ t.Skip("set RESOLVE_BENCH=1 to run the loopback resolver benchmark")
+ }
+
+ hit := *benchHit
+
+ t.Logf("names=%d resolvers=%d hit=%d%% concurrency=%d sockets=%d",
+ *benchNames, *benchResolvers, hit, *benchConc, *benchSockets)
+ t.Logf("%-14s %10s %10s %10s %10s %10s %10s",
+ "scenario", "ttfr", "wall", "qps", "resolved", "nxdomain", "failed")
+
+ for _, sc := range benchScenarios(hit) {
+ runBenchmarkScenario(t, sc)
+ }
+}
+
+func runBenchmarkScenario(t *testing.T, sc scenario) {
+ t.Helper()
+
+ battery, err := simdns.Start(*benchResolvers, sc.cfg)
+ if err != nil {
+ t.Fatalf("could not start sim resolvers: %v", err)
+ }
+ defer battery.Stop()
+
+ // size the per-attempt timeout to a few RTTs so lossy scenarios still
+ // complete via retransmission instead of stalling.
+ rtt := sc.cfg.BaseLatency + sc.cfg.Jitter
+ timeout := 6 * rtt
+ if timeout < 500*time.Millisecond {
+ timeout = 500 * time.Millisecond
+ }
+
+ var resolved, nxdomain, failed atomic.Int64
+ var ttfrNanos atomic.Int64 // 0 until first result
+ start := time.Now()
+
+ var hooks Hooks
+ if *benchHooks {
+ hooks = noopHooks()
+ }
+
+ client, err := New(Options{
+ Resolvers: battery.Addrs,
+ Concurrency: *benchConc,
+ SocketCount: *benchSockets,
+ Timeout: timeout,
+ MaxRetries: 5,
+ Batch: benchBatchMode(*benchBatch),
+ ResolverHealth: *benchHealth,
+ AdaptiveConcurrency: *benchAdaptConc,
+ Hooks: hooks,
+ OnResult: func(r Result) {
+ ttfrNanos.CompareAndSwap(0, int64(time.Since(start)))
+ switch {
+ case r.Rcode == dns.RcodeSuccess && len(r.A) > 0:
+ resolved.Add(1)
+ case r.Rcode == dns.RcodeNameError:
+ nxdomain.Add(1)
+ default:
+ failed.Add(1)
+ }
+ },
+ OnError: func(string, error) { failed.Add(1) },
+ })
+ if err != nil {
+ t.Fatalf("could not create client: %v", err)
+ }
+ defer client.Close()
+
+ total := *benchNames
+ input := make(chan string, 4096)
+ go func() {
+ defer close(input)
+ for i := 0; i < total; i++ {
+ input <- fmt.Sprintf("host%d.bench.example.com", i)
+ }
+ }()
+
+ if err := client.Run(context.Background(), input); err != nil {
+ t.Fatalf("run failed: %v", err)
+ }
+ wall := time.Since(start)
+
+ qps := float64(total) / wall.Seconds()
+ ttfr := time.Duration(ttfrNanos.Load())
+
+ t.Logf("%-14s %10s %10s %10.0f %10d %10d %10d",
+ sc.name,
+ ttfr.Round(100*time.Microsecond),
+ wall.Round(time.Millisecond),
+ qps,
+ resolved.Load(),
+ nxdomain.Load(),
+ failed.Load(),
+ )
+ s := battery.Stats
+ t.Logf(" server-side: queries=%d answered=%d dropped=%d servfail=%d ratelimited=%d",
+ s.Queries.Load(), s.Answered.Load(), s.Dropped.Load(),
+ s.Servfail.Load(), s.RateLimited.Load())
+}
diff --git a/pkg/resolve/health.go b/pkg/resolve/health.go
new file mode 100644
index 00000000..29e7e695
--- /dev/null
+++ b/pkg/resolve/health.go
@@ -0,0 +1,72 @@
+package resolve
+
+import (
+ "math"
+ "math/rand/v2"
+ "sync/atomic"
+)
+
+// resolverHealth tracks a smoothed success rate per resolver so that failing or
+// rate-limiting resolvers are de-weighted in selection and recover over time.
+// This addresses massdns's own TODO of avoiding resolvers that refuse or stop
+// answering after a while.
+type resolverHealth struct {
+ n int
+ weight []atomic.Uint64 // float64 bits, EWMA success in [0,1]; starts at 1.0
+}
+
+const (
+ healthAlpha = 0.1 // EWMA smoothing for per-resolver success rate
+ healthThreshold = 0.5 // success-rate boundary between healthy and unhealthy
+)
+
+func newResolverHealth(n int) *resolverHealth {
+ h := &resolverHealth{n: n, weight: make([]atomic.Uint64, n)}
+ for i := range h.weight {
+ h.weight[i].Store(math.Float64bits(1.0))
+ }
+ return h
+}
+
+func (h *resolverHealth) score(i int) float64 {
+ return math.Float64frombits(h.weight[i].Load())
+}
+
+// record nudges a resolver's success EWMA toward 1 (answered) or 0 (failed).
+// It returns whether the resolver crossed the healthy threshold and the new
+// healthy state, so callers can emit state-change events.
+func (h *resolverHealth) record(i int, ok bool) (transitioned, healthy bool) {
+ if i < 0 || i >= h.n {
+ return false, true
+ }
+ var target float64
+ if ok {
+ target = 1.0
+ }
+ for {
+ oldBits := h.weight[i].Load()
+ old := math.Float64frombits(oldBits)
+ nw := old + healthAlpha*(target-old)
+ if h.weight[i].CompareAndSwap(oldBits, math.Float64bits(nw)) {
+ wasHealthy := old >= healthThreshold
+ nowHealthy := nw >= healthThreshold
+ return wasHealthy != nowHealthy, nowHealthy
+ }
+ }
+}
+
+// pick selects a resolver using the power-of-two-choices: sample two at random
+// and keep the healthier one. This is lock-free, needs no running totals, and
+// naturally steers load away from unhealthy resolvers while still probing them
+// occasionally (so they can recover).
+func (h *resolverHealth) pick() int {
+ if h.n == 1 {
+ return 0
+ }
+ a := rand.IntN(h.n)
+ b := rand.IntN(h.n)
+ if h.score(b) > h.score(a) {
+ return b
+ }
+ return a
+}
diff --git a/pkg/resolve/limiter.go b/pkg/resolve/limiter.go
new file mode 100644
index 00000000..6497e5f4
--- /dev/null
+++ b/pkg/resolve/limiter.go
@@ -0,0 +1,78 @@
+package resolve
+
+import (
+ "context"
+ "time"
+)
+
+// limiter is a simple token-bucket rate limiter that refills at a fixed QPS.
+// take() blocks until a token is available. It is intentionally lightweight to
+// avoid a hard dependency while keeping outbound query rate bounded.
+type limiter struct {
+ tokens chan struct{}
+ qps int
+ stopCh chan struct{}
+}
+
+func newLimiter(qps int) *limiter {
+ if qps <= 0 {
+ return nil
+ }
+ // burst capacity of one second worth of tokens, capped for memory sanity
+ burst := qps
+ if burst > 100000 {
+ burst = 100000
+ }
+ return &limiter{
+ tokens: make(chan struct{}, burst),
+ qps: qps,
+ stopCh: make(chan struct{}),
+ }
+}
+
+func (l *limiter) start(ctx context.Context) {
+ // refill in small slices to smooth out bursts
+ slices := 100
+ perTick := l.qps / slices
+ if perTick < 1 {
+ perTick = 1
+ slices = l.qps
+ }
+ interval := time.Second / time.Duration(slices)
+ if interval <= 0 {
+ interval = time.Millisecond
+ }
+
+ go func() {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-l.stopCh:
+ return
+ case <-ticker.C:
+ for i := 0; i < perTick; i++ {
+ select {
+ case l.tokens <- struct{}{}:
+ default:
+ // bucket full
+ }
+ }
+ }
+ }
+ }()
+}
+
+func (l *limiter) take() {
+ <-l.tokens
+}
+
+func (l *limiter) stop() {
+ select {
+ case <-l.stopCh:
+ default:
+ close(l.stopCh)
+ }
+}
diff --git a/pkg/resolve/privdrop_test.go b/pkg/resolve/privdrop_test.go
new file mode 100644
index 00000000..93711016
--- /dev/null
+++ b/pkg/resolve/privdrop_test.go
@@ -0,0 +1,28 @@
+package resolve
+
+import (
+ "os"
+ "runtime"
+ "testing"
+)
+
+func TestDropPrivilegesNonRootNoop(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("windows privilege model differs")
+ }
+ if os.Geteuid() == 0 {
+ t.Skip("running as root; noop path not exercised")
+ }
+ if err := DropPrivileges("", "", false); err != nil {
+ t.Fatal(err)
+ }
+ if err := DropPrivileges("nobody", "nobody", false); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDropPrivilegesKeepRoot(t *testing.T) {
+ if err := DropPrivileges("does-not-exist", "does-not-exist", true); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/pkg/resolve/privdrop_unix.go b/pkg/resolve/privdrop_unix.go
new file mode 100644
index 00000000..2a7e2afe
--- /dev/null
+++ b/pkg/resolve/privdrop_unix.go
@@ -0,0 +1,83 @@
+//go:build unix
+
+package resolve
+
+import (
+ "fmt"
+ "os/user"
+ "strconv"
+
+ "golang.org/x/sys/unix"
+)
+
+// DropPrivileges drops root after sockets are opened (massdns --drop-user /
+// --drop-group). No-op when not running as root. When keepRoot is true (massdns
+// --root), privileges are left unchanged.
+func DropPrivileges(username, groupname string, keepRoot bool) error {
+ if unix.Geteuid() != 0 {
+ return nil
+ }
+ if keepRoot {
+ return nil
+ }
+ if username == "" {
+ username = "nobody"
+ }
+ if groupname == "" {
+ groupname = "nobody"
+ }
+
+ uid, err := lookupUID(username)
+ if err != nil {
+ return err
+ }
+ gid, err := lookupGID(groupname)
+ if err != nil {
+ return err
+ }
+ // Drop root's supplementary groups before setgid/setuid; otherwise the
+ // process keeps root's group memberships after the drop (a classic
+ // incomplete-privilege-drop gap). Must happen while still privileged.
+ if err := unix.Setgroups([]int{gid}); err != nil {
+ return fmt.Errorf("setgroups(%s): %w", groupname, err)
+ }
+ if err := unix.Setgid(gid); err != nil {
+ return fmt.Errorf("setgid(%s): %w", groupname, err)
+ }
+ if err := unix.Setuid(uid); err != nil {
+ return fmt.Errorf("setuid(%s): %w", username, err)
+ }
+ return nil
+}
+
+func lookupUID(name string) (int, error) {
+ u, err := user.Lookup(name)
+ if err != nil {
+ // massdns falls back to 65534 when the default nobody user is missing,
+ // but errors when an explicit --drop-user is unknown.
+ if name == "nobody" {
+ return 65534, nil
+ }
+ return 0, fmt.Errorf("user %q does not exist", name)
+ }
+ id, err := strconv.Atoi(u.Uid)
+ if err != nil {
+ return 0, err
+ }
+ return id, nil
+}
+
+func lookupGID(name string) (int, error) {
+ g, err := user.LookupGroup(name)
+ if err != nil {
+ if name == "nobody" || name == "nogroup" {
+ return 65534, nil
+ }
+ return 0, fmt.Errorf("group %q does not exist", name)
+ }
+ id, err := strconv.Atoi(g.Gid)
+ if err != nil {
+ return 0, err
+ }
+ return id, nil
+}
diff --git a/pkg/resolve/privdrop_windows.go b/pkg/resolve/privdrop_windows.go
new file mode 100644
index 00000000..de3890ac
--- /dev/null
+++ b/pkg/resolve/privdrop_windows.go
@@ -0,0 +1,16 @@
+//go:build windows
+
+package resolve
+
+import "fmt"
+
+// DropPrivileges is not supported on Windows.
+func DropPrivileges(username, groupname string, keepRoot bool) error {
+ if keepRoot {
+ return nil
+ }
+ if username != "" || groupname != "" {
+ return fmt.Errorf("privilege drop is not supported on Windows")
+ }
+ return nil
+}
diff --git a/pkg/resolve/raw6_linux.go b/pkg/resolve/raw6_linux.go
new file mode 100644
index 00000000..41122420
--- /dev/null
+++ b/pkg/resolve/raw6_linux.go
@@ -0,0 +1,126 @@
+//go:build linux
+
+package resolve
+
+import (
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "net"
+ "syscall"
+
+ "golang.org/x/sys/unix"
+)
+
+const rawSrcPort = 666 // massdns hard-coded UDP source port for HDRINCL sends
+
+// openRawUDPv6 creates a SOCK_RAW IPPROTO_UDP socket with IPV6_HDRINCL so we can
+// forge the IPv6 source address (massdns --rand-src-ipv6). Requires CAP_NET_RAW.
+func openRawUDPv6() (int, error) {
+ fd, err := unix.Socket(unix.AF_INET6, unix.SOCK_RAW, unix.IPPROTO_UDP)
+ if err != nil {
+ return -1, fmt.Errorf("raw ipv6 udp socket: %w (need CAP_NET_RAW?)", err)
+ }
+ if err := unix.SetsockoptInt(fd, unix.IPPROTO_IPV6, unix.IPV6_HDRINCL, 1); err != nil {
+ _ = unix.Close(fd)
+ return -1, fmt.Errorf("IPV6_HDRINCL: %w", err)
+ }
+ return fd, nil
+}
+
+func closeRawFD(fd int) {
+ if fd >= 0 {
+ _ = unix.Close(fd)
+ }
+}
+
+func setRawRecvBuffer(fd, n int) error {
+ if fd < 0 || n <= 0 {
+ return nil
+ }
+ return unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_RCVBUF, n)
+}
+
+func setRawSendBuffer(fd, n int) error {
+ if fd < 0 || n <= 0 {
+ return nil
+ }
+ return unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_SNDBUF, n)
+}
+
+// writeRawUDPv6 writes an IPv6+UDP+payload datagram with an explicit source
+// address via a HDRINCL raw socket. sport is typically a fixed ephemeral-ish
+// port (massdns uses 666).
+func writeRawUDPv6(fd int, src, dst net.IP, sport, dport uint16, payload []byte) error {
+ src16 := src.To16()
+ dst16 := dst.To16()
+ if src16 == nil || dst16 == nil {
+ return errors.New("raw send requires IPv6 addresses")
+ }
+ udpLen := 8 + len(payload)
+ total := 40 + udpLen
+ buf := make([]byte, total)
+
+ // Build a buffer laid out so the IPv6 UDP pseudo-header checksum can be
+ // computed in place (same trick as massdns write_raw_header), then fix the
+ // real IPv6 header fields.
+ binary.BigEndian.PutUint16(buf[4:6], uint16(udpLen))
+ buf[6] = 0
+ buf[7] = unix.IPPROTO_UDP // temp: next-header slot holds protocol for checksum
+ copy(buf[8:24], src16)
+ copy(buf[24:40], dst16)
+ binary.BigEndian.PutUint16(buf[40:42], sport)
+ binary.BigEndian.PutUint16(buf[42:44], dport)
+ binary.BigEndian.PutUint16(buf[44:46], uint16(udpLen))
+ binary.BigEndian.PutUint16(buf[46:48], 0)
+ copy(buf[48:], payload)
+ sum := ipChecksum(buf)
+ binary.BigEndian.PutUint16(buf[46:48], sum)
+
+ buf[0] = 0x60 // version 6
+ buf[1], buf[2], buf[3] = 0, 0, 0
+ buf[6] = unix.IPPROTO_UDP
+ buf[7] = 255
+
+ sa := &unix.SockaddrInet6{}
+ copy(sa.Addr[:], dst16)
+ // Raw IPv6 sendto rejects non-zero ports on some kernels (same as massdns).
+ return unix.Sendto(fd, buf, 0, sa)
+}
+
+// readRawUDPv6 reads from a raw IPv6 UDP socket. On Linux the datagram starts
+// with the 8-byte UDP header; we strip it and recover the source port.
+func readRawUDPv6(fd int, buf []byte) (payload []byte, addr *net.UDPAddr, err error) {
+ n, from, err := unix.Recvfrom(fd, buf, 0)
+ if err != nil {
+ if errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EWOULDBLOCK) {
+ return nil, nil, err
+ }
+ return nil, nil, err
+ }
+ if n < 8 {
+ return nil, nil, errors.New("short raw udp read")
+ }
+ sport := binary.BigEndian.Uint16(buf[0:2])
+ ip := net.IPv6zero
+ if sa6, ok := from.(*unix.SockaddrInet6); ok {
+ ip = make(net.IP, 16)
+ copy(ip, sa6.Addr[:])
+ }
+ return buf[8:n], &net.UDPAddr{IP: ip, Port: int(sport)}, nil
+}
+
+// ipChecksum computes the Internet checksum over buf (as used for IPv6 UDP).
+func ipChecksum(buf []byte) uint16 {
+ var sum uint32
+ for i := 0; i+1 < len(buf); i += 2 {
+ sum += uint32(buf[i])<<8 | uint32(buf[i+1])
+ }
+ if len(buf)%2 == 1 {
+ sum += uint32(buf[len(buf)-1]) << 8
+ }
+ for sum > 0xffff {
+ sum = (sum & 0xffff) + (sum >> 16)
+ }
+ return ^uint16(sum)
+}
\ No newline at end of file
diff --git a/pkg/resolve/raw6_stub.go b/pkg/resolve/raw6_stub.go
new file mode 100644
index 00000000..b843faea
--- /dev/null
+++ b/pkg/resolve/raw6_stub.go
@@ -0,0 +1,28 @@
+//go:build !linux
+
+package resolve
+
+import (
+ "errors"
+ "net"
+)
+
+const rawSrcPort = 666
+
+func openRawUDPv6() (int, error) {
+ return -1, errors.New("--rand-src-ipv6 is only supported on Linux")
+}
+
+func closeRawFD(fd int) {}
+
+func setRawRecvBuffer(fd, n int) error { return nil }
+
+func setRawSendBuffer(fd, n int) error { return nil }
+
+func writeRawUDPv6(fd int, src, dst net.IP, sport, dport uint16, payload []byte) error {
+ return errors.New("--rand-src-ipv6 is only supported on Linux")
+}
+
+func readRawUDPv6(fd int, buf []byte) ([]byte, *net.UDPAddr, error) {
+ return nil, nil, errors.New("--rand-src-ipv6 is only supported on Linux")
+}
diff --git a/pkg/resolve/resolve.go b/pkg/resolve/resolve.go
new file mode 100644
index 00000000..399d37ed
--- /dev/null
+++ b/pkg/resolve/resolve.go
@@ -0,0 +1,1716 @@
+// Package resolve implements a high-throughput asynchronous DNS stub
+// resolver in pure Go. It is the native replacement for the massdns binary
+// previously shelled out to by shuffledns.
+//
+// Design (mirrors massdns rather than the naive goroutine-per-query model):
+// - a small fixed pool of UDP sockets, each drained by a single reader
+// goroutine, so concurrency is bounded by an in-flight map and NOT by
+// the number of goroutines;
+// - queries are tracked in a per-socket in-flight table keyed by DNS
+// transaction id; responses are correlated back by id + question name;
+// - a timeout wheel retransmits unanswered queries to a different resolver
+// up to a retry budget, and retries REFUSED/SERVFAIL like massdns does;
+// - results stream out through a callback as soon as they arrive (no temp
+// files, no text parsing), which is what gives a low time-to-first-result.
+package resolve
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "math/rand/v2"
+ "net"
+ "runtime"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/miekg/dns"
+ "golang.org/x/net/ipv4"
+ "golang.org/x/net/ipv6"
+)
+
+// Result is a single resolved answer streamed to the caller.
+type Result struct {
+ Name string // queried hostname (no trailing dot)
+ Type uint16 // dns query type (A, AAAA, ...)
+ Rcode int // dns response code
+ A []string // A records
+ AAAA []string // AAAA records
+ CNAME []string // CNAME targets
+ NS []string // NS targets
+ PTR []string // PTR targets
+ MX []string // MX exchange hosts
+ TXT []string // TXT strings
+ SOA []string // SOA primary nameservers
+ Resolver string // resolver that answered
+
+ // Msg is the full parsed response message (all sections, TTLs, classes).
+ // It is retained so callers (e.g. the output formatter) can render the
+ // complete answer faithfully; it may be nil for synthetic results.
+ Msg *dns.Msg
+ // Timestamp is when the response was accepted as final (wall clock).
+ Timestamp time.Time
+}
+
+// Options configures the resolver.
+type Options struct {
+ // Resolvers is the list of recursive resolver addresses (host or host:port).
+ Resolvers []string
+ // QueryType is the dns record type to request (default dns.TypeA).
+ QueryType uint16
+ // MaxRetries is the number of times a query is retransmitted (to a
+ // rotated resolver) before giving up. Default 3.
+ MaxRetries int
+ // Timeout is the per-attempt timeout before retransmission. Default 4s.
+ Timeout time.Duration
+ // Concurrency caps the number of in-flight queries. Default 10000.
+ Concurrency int
+ // QPS optionally rate-limits outbound queries (0 = unlimited).
+ QPS int
+ // SocketCount is the number of UDP sockets to spread load across.
+ // Default max(8, GOMAXPROCS) so the read path scales across cores.
+ SocketCount int
+ // Batch selects the datagram batching strategy (sendmmsg/recvmmsg, Linux
+ // only, IPv4-only resolver set). See BatchMode. Default BatchDisabled.
+ Batch BatchMode
+ // BatchSize is the maximum number of datagrams sent/received per syscall
+ // when batching is active. Default 64.
+ BatchSize int
+
+ // Interval is the timeout-loop scan period (how promptly a lost packet is
+ // retransmitted). 0 = derive from Timeout. Mirrors massdns -i/--interval.
+ Interval time.Duration
+
+ // NoRecurse sends non-recursive queries (RD=0), useful for cache snooping
+ // and probing authoritative servers directly. Mirrors massdns --norecurse.
+ NoRecurse bool
+ // UDPSize is the EDNS0 advertised UDP payload size. 0 = default (1232),
+ // which lets resolvers return larger answers without truncation. A value
+ // below 512 disables EDNS0 entirely.
+ UDPSize int
+ // DisableIPVerification turns off matching a reply's source address against
+ // the resolver the query was sent to. Verification is ON by default and
+ // guards against off-path answer spoofing (massdns --verify-ip is opt-in;
+ // here it is opt-out because we always send to a known address).
+ DisableIPVerification bool
+ // DisableTCPFallback disables re-querying over TCP when a UDP response has
+ // the truncation (TC) bit set. Fallback is ON by default.
+ DisableTCPFallback bool
+ // Sticky retransmits to the same resolver instead of rotating. Mirrors
+ // massdns --sticky.
+ Sticky bool
+ // RetryRcodes lists the response codes that trigger a retry. When nil the
+ // massdns default is used: retry everything except NOERROR and NXDOMAIN.
+ RetryRcodes []int
+
+ // ResolverHealth enables per-resolver health scoring: resolvers that time
+ // out or error are de-weighted in selection and recover over time.
+ ResolverHealth bool
+ // AdaptiveConcurrency lets the controller shrink/grow the in-flight cap in
+ // response to packet loss (AIMD), preventing resolver/buffer flooding.
+ // Requires Batch == BatchAdaptive (the controller drives both).
+ AdaptiveConcurrency bool
+ // CrossCheck re-queries each positive answer on a second, randomly chosen
+ // resolver and drops results the two disagree on (basic poisoning/spam
+ // detection). Doubles query volume for names that resolve.
+ CrossCheck bool
+ // ExtendedInput parses each input line as "name [resolver ...]"; the
+ // per-name resolvers are tried (in order) before falling back to the global
+ // resolver pool. Mirrors massdns --extended-input.
+ ExtendedInput bool
+ // Predictable picks resolvers sequentially instead of randomly. Mirrors
+ // massdns --predictable (useful for resolver tests).
+ Predictable bool
+ // BindAddr is an optional local UDP bind address (host, host:port, or :port).
+ // Empty means the kernel picks an ephemeral port on all interfaces.
+ // Mirrors massdns -b/--bindto.
+ BindAddr string
+ // ReadBuffer, when > 0, sets SO_RCVBUF on each UDP socket (massdns --rcvbuf).
+ // When 0 a large default (8 MiB) is used.
+ ReadBuffer int
+ // WriteBuffer, when > 0, sets SO_SNDBUF on each UDP socket (massdns --sndbuf).
+ WriteBuffer int
+ // RandSrcIPv6 is a CIDR prefix used to forge a random IPv6 source address
+ // per query (massdns --rand-src-ipv6). Linux-only; requires CAP_NET_RAW.
+ // Incompatible with BindAddr.
+ RandSrcIPv6 string
+ // RandSrcIPv6File loads discrete IPv6 source addresses (one per line) for
+ // the same purpose (massdns --rand-src-ipv6-file). Mutually exclusive with
+ // RandSrcIPv6.
+ RandSrcIPv6File string
+
+ // OnResult is invoked for every final answer (success or definitive
+ // failure such as NXDOMAIN). It must be safe for concurrent use.
+ OnResult func(Result)
+ // OnError is invoked when a query is abandoned after exhausting retries.
+ OnError func(name string, err error)
+ // OnProgress is invoked periodically (every Interval-ish tick) with a
+ // snapshot of resolver statistics, for status reporting. Optional.
+ OnProgress func(Stats)
+
+ // Hooks holds optional fine-grained lifecycle callbacks for SDK/observability
+ // use. They are independent of OnResult/OnError/OnProgress and may all be
+ // left nil. See Hooks.
+ Hooks Hooks
+}
+
+// Stats is a point-in-time snapshot of resolver activity.
+type Stats struct {
+ Queries int64 // distinct names dispatched
+ Retransmits int64 // total retransmissions
+ Answered int64 // final answers delivered (success or definitive)
+ InflightDepth int // queries currently outstanding
+ ConcurrencyCap int // current adaptive in-flight cap
+ LossRate float64 // most recent interval loss estimate
+ RTT time.Duration
+ BatchActive bool
+}
+
+// QueryInfo describes a single query attempt passed to lifecycle hooks.
+type QueryInfo struct {
+ Name string // queried hostname (no trailing dot)
+ Type uint16 // dns query type
+ Attempt int // zero-based attempt number (0 = initial send)
+ Resolver string // resolver address this attempt targets
+}
+
+// Hooks is a set of optional callbacks for observing the resolver's internals,
+// intended for embedding the resolver as an SDK (progress UIs, metrics,
+// tracing, custom retry/poisoning telemetry, etc.).
+//
+// IMPORTANT: hooks fire on hot paths (some per query/response). They must be
+// cheap, non-blocking, and safe for concurrent use; offload heavy work to a
+// channel or worker. Any hook may be nil. Hooks never alter resolver behaviour
+// — they are observation points only.
+type Hooks struct {
+ // OnQuery fires when a query is first put on the wire (initial send).
+ OnQuery func(QueryInfo)
+ // OnRetry fires before each retransmission (timeout or bad-rcode driven).
+ OnRetry func(QueryInfo)
+ // OnResponse fires for every response matched to an in-flight query, before
+ // the retry-or-finalize decision, exposing the raw message (read-only).
+ OnResponse func(QueryInfo, *dns.Msg)
+ // OnTimeout fires when an attempt's deadline expires (before retry/abandon).
+ OnTimeout func(QueryInfo)
+ // OnTruncated fires when a TC (truncated) response triggers TCP fallback.
+ OnTruncated func(QueryInfo)
+ // OnCrossCheckFailed fires when cross-resolver verification rejects a result;
+ // primary/secondary are the disagreeing A-record sets.
+ OnCrossCheckFailed func(name string, primary, secondary []string)
+ // OnResolverState fires when a resolver's health crosses the healthy
+ // threshold (requires ResolverHealth). healthy is the new state.
+ OnResolverState func(resolver string, healthy bool)
+}
+
+// fire helpers keep the hot paths branch-cheap when hooks are unset.
+func (c *Client) fireQuery(q *query) {
+ if c.hooks.OnQuery != nil {
+ c.hooks.OnQuery(c.queryInfo(q))
+ }
+}
+
+func (c *Client) fireRetry(q *query) {
+ if c.hooks.OnRetry != nil {
+ c.hooks.OnRetry(c.queryInfo(q))
+ }
+}
+
+func (c *Client) queryInfo(q *query) QueryInfo {
+ res := ""
+ if q.addr != nil {
+ res = q.addr.String()
+ }
+ return QueryInfo{Name: q.name, Type: c.opts.QueryType, Attempt: q.attempts, Resolver: res}
+}
+
+// BatchMode selects how datagram batching (Linux sendmmsg/recvmmsg) is used.
+//
+// Batching amortizes the per-datagram syscall cost and helps on high-latency /
+// bursty links where many packets cluster in time, but it is counterproductive
+// on loopback / low-RTT links (tiny batches pay the message-array setup cost
+// and add first-response latency). It also makes sends burstier, which can
+// worsen loss when a resolver or kernel buffer is already saturated.
+type BatchMode int
+
+const (
+ // BatchDisabled always uses one datagram per syscall (default). Identical
+ // to the resolver's behaviour without any batching support.
+ BatchDisabled BatchMode = iota
+ // BatchEnabled forces batching on (Linux + IPv4 resolvers only).
+ BatchEnabled
+ // BatchAdaptive turns batching on/off at runtime based on observed RTT,
+ // in-flight depth and packet loss: it engages when the pipeline is deep and
+ // latency is high enough to fill batches, and backs off on low-RTT links or
+ // when loss climbs (to avoid making sends burstier).
+ BatchAdaptive
+)
+
+const (
+ defaultMaxRetries = 3
+ defaultTimeout = 4 * time.Second
+ defaultConcurrency = 10000
+ defaultSocketCount = 8
+ defaultBatchSize = 64
+ defaultUDPSize = 1232 // conservative EDNS0 payload (avoids v4/v6 fragmentation)
+ maxTxIDAttempts = 64
+
+ // adaptive batching controller thresholds (with hysteresis to avoid flapping)
+ adaptTick = 200 * time.Millisecond
+ adaptRTTOn = 3 * time.Millisecond // engage batching above this smoothed RTT
+ adaptRTTOff = 1 * time.Millisecond // disengage below this smoothed RTT
+ adaptLossOff = 0.15 // disengage when interval loss exceeds this
+ minBatchToMMSG = 4 // only use sendmmsg for batches at least this big
+ adaptDepthRatio = 2 // need depth >= ratio*batchSize to engage
+
+ // adaptive concurrency (AIMD) thresholds
+ concLossHigh = 0.10 // multiplicative decrease above this interval loss
+ concLossLow = 0.02 // additive increase below this interval loss
+ concDecrease = 0.75 // cap *= concDecrease on high loss
+ concMinRatio = 0.05 // never shrink below this fraction of Concurrency
+)
+
+// query holds the in-flight state for a single outstanding name.
+type query struct {
+ name string
+ fqdn string
+ txid uint16
+ resolverIdx int
+ addr *net.UDPAddr // resolver this attempt was sent to (for verify + retransmit)
+ extra []*net.UDPAddr // per-name resolvers (extended-input); tried before the pool
+ sentAt time.Time
+ deadline time.Time
+ attempts int
+ sock *socket
+}
+
+// batchConn is the subset of ipv4.PacketConn / ipv6.PacketConn used for batched
+// I/O. ipv4.Message and ipv6.Message are both aliases of the same underlying
+// socket.Message type, so a single interface works for either family.
+type batchConn interface {
+ ReadBatch(ms []ipv4.Message, flags int) (int, error)
+ WriteBatch(ms []ipv4.Message, flags int) (int, error)
+}
+
+// socket is a UDP socket plus its in-flight table.
+type socket struct {
+ conn *net.UDPConn
+ rawFD int // >=0: Linux SOCK_RAW IPv6 for --rand-src-ipv6; conn is nil
+ family int // 4, 6, or 0 (dual-stack)
+ pc batchConn // batch (sendmmsg/recvmmsg) wrapper; nil if batching unavailable
+ mu sync.Mutex
+ inflight map[uint16]*query
+}
+
+// Client is an asynchronous DNS stub resolver.
+type Client struct {
+ opts Options
+ resolvers []string
+ resolverAddrs []*net.UDPAddr // pre-resolved, shared read-only across goroutines
+ sockets []*socket
+ sem chan struct{} // static in-flight cap (fast path)
+ dynSem *adaptiveSem // adjustable in-flight cap (AdaptiveConcurrency); nil otherwise
+ limiter *limiter
+ pending sync.WaitGroup
+ srcRand *srcRand
+ sockets4 []*socket // IPv4-capable sockets (subset of sockets)
+ sockets6 []*socket // IPv6-capable sockets (subset of sockets)
+
+ rd bool // recursion desired flag for outgoing queries
+ udpSize uint16
+ verify bool // verify reply source address
+ tcp bool // TCP fallback on truncation
+ sticky bool
+ predictable bool
+ predSeq atomic.Uint64 // monotonic index for Predictable resolver selection
+ extInput bool // parse per-name resolvers from input lines
+ retryRcode [16]bool // retryRcode[rcode] => retransmit on this response code
+ sockIdx atomic.Uint64
+
+ addrCache sync.Map // string -> *net.UDPAddr (extended-input resolver cache)
+ health *resolverHealth // nil unless ResolverHealth enabled
+ hooks Hooks // optional lifecycle callbacks
+
+ // precomputed hot-path gates so the common (no-hook) path costs a single
+ // bool test instead of per-query nil checks / function calls.
+ hasSendHooks bool // OnQuery or OnRetry set
+ sampleRTT bool // RTT sampling needed (adaptive batch/concurrency or OnProgress)
+
+ // batchCapable is true when the batch I/O path is usable (mode != disabled
+ // and a single-family resolver set). When true, the dispatch/read loops use
+ // the batch-aware path and consult batchActive to decide per operation.
+ batchCapable bool
+ batchSize int
+ // batchActive is flipped by the adaptive controller (or pinned on/off for
+ // the non-adaptive modes); send/read paths read it to choose mmsg vs single.
+ batchActive atomic.Bool
+
+ // adaptive metrics (cheap atomics sampled by the controller)
+ statQueries atomic.Int64 // distinct names dispatched (initial sends)
+ statRetransmit atomic.Int64 // retransmissions (timeout + servfail/refused retries)
+ statAnswered atomic.Int64 // final answers delivered
+ lastRTTNanos atomic.Int64 // most recent observed round-trip time
+ lastLossBits atomic.Uint64 // most recent interval loss (float64 bits) for stats
+}
+
+// New creates a resolver client from the given options.
+func New(opts Options) (*Client, error) {
+ if len(opts.Resolvers) == 0 {
+ return nil, errors.New("no resolvers provided")
+ }
+ if opts.QueryType == 0 {
+ opts.QueryType = dns.TypeA
+ }
+ if opts.MaxRetries <= 0 {
+ opts.MaxRetries = defaultMaxRetries
+ }
+ if opts.Timeout <= 0 {
+ opts.Timeout = defaultTimeout
+ }
+ if opts.Concurrency <= 0 {
+ opts.Concurrency = defaultConcurrency
+ }
+ if opts.SocketCount <= 0 {
+ // scale the socket pool (and thus the number of independent reader
+ // goroutines / kernel receive queues) with the available cores.
+ opts.SocketCount = defaultSocketCount
+ if n := runtime.GOMAXPROCS(0); n > opts.SocketCount {
+ opts.SocketCount = n
+ }
+ }
+ if opts.BatchSize <= 0 {
+ opts.BatchSize = defaultBatchSize
+ }
+ if opts.UDPSize == 0 {
+ opts.UDPSize = defaultUDPSize
+ }
+
+ resolvers := make([]string, 0, len(opts.Resolvers))
+ resolverAddrs := make([]*net.UDPAddr, 0, len(opts.Resolvers))
+ allIPv4, allIPv6 := true, true
+ for _, r := range opts.Resolvers {
+ r = strings.TrimSpace(r)
+ if r == "" {
+ continue
+ }
+ normalized := normalizeResolver(r)
+ // pre-resolve once at startup so the hot send() path never parses an
+ // address string or allocates a *net.UDPAddr per query/retransmit.
+ addr, err := net.ResolveUDPAddr("udp", normalized)
+ if err != nil {
+ return nil, err
+ }
+ if addr.IP.To4() == nil {
+ allIPv4 = false
+ } else {
+ allIPv6 = false
+ }
+ resolvers = append(resolvers, normalized)
+ resolverAddrs = append(resolverAddrs, addr)
+ }
+ if len(resolvers) == 0 {
+ return nil, errors.New("no valid resolvers provided")
+ }
+
+ c := &Client{
+ opts: opts,
+ resolvers: resolvers,
+ resolverAddrs: resolverAddrs,
+ batchSize: opts.BatchSize,
+ rd: !opts.NoRecurse,
+ verify: !opts.DisableIPVerification,
+ tcp: !opts.DisableTCPFallback,
+ sticky: opts.Sticky,
+ predictable: opts.Predictable,
+ extInput: opts.ExtendedInput,
+ hooks: opts.Hooks,
+ }
+ if opts.UDPSize >= 512 {
+ c.udpSize = uint16(opts.UDPSize)
+ }
+ if opts.RandSrcIPv6 != "" && opts.RandSrcIPv6File != "" {
+ return nil, errors.New("--rand-src-ipv6 cannot be used with --rand-src-ipv6-file")
+ }
+ if (opts.RandSrcIPv6 != "" || opts.RandSrcIPv6File != "") && opts.BindAddr != "" {
+ return nil, errors.New("--bindto and --rand-src-ipv6 cannot be used together")
+ }
+ switch {
+ case opts.RandSrcIPv6 != "":
+ sr, err := newSrcRandFromPrefix(opts.RandSrcIPv6)
+ if err != nil {
+ return nil, err
+ }
+ c.srcRand = sr
+ case opts.RandSrcIPv6File != "":
+ sr, err := newSrcRandFromFile(opts.RandSrcIPv6File)
+ if err != nil {
+ return nil, err
+ }
+ c.srcRand = sr
+ }
+ // Batching uses an ipv4/ipv6 PacketConn, so it requires a single-family
+ // resolver set (and a matching udp4/udp6 socket). Mixed sets always use the
+ // portable single-datagram path. Raw IPv6 source randomization also disables
+ // batching (HDRINCL path is single-datagram only).
+ c.batchCapable = opts.Batch != BatchDisabled && (allIPv4 || allIPv6) && c.srcRand == nil
+
+ // precompute hot-path hook/sampling gates (see field docs).
+ c.hasSendHooks = opts.Hooks.OnQuery != nil || opts.Hooks.OnRetry != nil
+ c.sampleRTT = (c.batchCapable && opts.Batch == BatchAdaptive) ||
+ opts.AdaptiveConcurrency || opts.OnProgress != nil
+
+ // retry policy: explicit list, or massdns default (retry all but NOERROR/NXDOMAIN).
+ if len(opts.RetryRcodes) > 0 {
+ for _, rc := range opts.RetryRcodes {
+ if rc >= 0 && rc < len(c.retryRcode) {
+ c.retryRcode[rc] = true
+ }
+ }
+ } else {
+ for rc := range c.retryRcode {
+ c.retryRcode[rc] = true
+ }
+ c.retryRcode[dns.RcodeSuccess] = false
+ c.retryRcode[dns.RcodeNameError] = false
+ }
+
+ if opts.ResolverHealth {
+ c.health = newResolverHealth(len(resolvers))
+ }
+
+ if opts.AdaptiveConcurrency {
+ c.dynSem = newAdaptiveSem(opts.Concurrency)
+ } else {
+ c.sem = make(chan struct{}, opts.Concurrency)
+ }
+
+ // BatchEnabled pins batching on; BatchAdaptive starts off and lets the
+ // controller engage it when conditions warrant.
+ c.batchActive.Store(opts.Batch == BatchEnabled && c.batchCapable)
+ if opts.QPS > 0 {
+ c.limiter = newLimiter(opts.QPS)
+ }
+
+ readBuf := 8 * 1024 * 1024
+ if opts.ReadBuffer > 0 {
+ readBuf = opts.ReadBuffer
+ }
+
+ // --rand-src-ipv6 uses Linux SOCK_RAW + IPV6_HDRINCL (massdns). Requires
+ // IPv6 resolvers and CAP_NET_RAW; batching stays off.
+ if c.srcRand != nil {
+ if !allIPv6 {
+ return nil, errors.New("--rand-src-ipv6 requires IPv6 resolvers")
+ }
+ for i := 0; i < opts.SocketCount; i++ {
+ fd, err := openRawUDPv6()
+ if err != nil {
+ c.closeSockets()
+ return nil, err
+ }
+ _ = setRawRecvBuffer(fd, readBuf)
+ if opts.WriteBuffer > 0 {
+ _ = setRawSendBuffer(fd, opts.WriteBuffer)
+ }
+ s := &socket{
+ rawFD: fd,
+ family: 6,
+ inflight: make(map[uint16]*query),
+ }
+ c.sockets = append(c.sockets, s)
+ c.sockets6 = append(c.sockets6, s)
+ }
+ return c, nil
+ }
+
+ network := "udp"
+ family := 0
+ switch {
+ case c.batchCapable && allIPv4:
+ network = "udp4"
+ family = 4
+ case c.batchCapable && allIPv6:
+ network = "udp6"
+ family = 6
+ }
+ var bindAddr *net.UDPAddr
+ if opts.BindAddr != "" {
+ var err error
+ bindAddr, err = net.ResolveUDPAddr(network, normalizeBindAddr(opts.BindAddr))
+ if err != nil {
+ return nil, fmt.Errorf("bind address: %w", err)
+ }
+ }
+ for i := 0; i < opts.SocketCount; i++ {
+ conn, err := net.ListenUDP(network, bindAddr)
+ if err != nil {
+ c.closeSockets()
+ return nil, err
+ }
+ _ = conn.SetReadBuffer(readBuf)
+ if opts.WriteBuffer > 0 {
+ _ = conn.SetWriteBuffer(opts.WriteBuffer)
+ }
+ s := &socket{
+ conn: conn,
+ rawFD: -1,
+ family: family,
+ inflight: make(map[uint16]*query),
+ }
+ if c.batchCapable {
+ if allIPv6 {
+ s.pc = ipv6.NewPacketConn(conn)
+ } else {
+ s.pc = ipv4.NewPacketConn(conn)
+ }
+ }
+ c.sockets = append(c.sockets, s)
+ switch family {
+ case 4:
+ c.sockets4 = append(c.sockets4, s)
+ case 6:
+ c.sockets6 = append(c.sockets6, s)
+ default:
+ c.sockets4 = append(c.sockets4, s)
+ c.sockets6 = append(c.sockets6, s)
+ }
+ }
+
+ return c, nil
+}
+
+// normalizeBindAddr accepts host, host:port, or :port forms used by massdns -b.
+func normalizeBindAddr(addr string) string {
+ addr = strings.TrimSpace(addr)
+ if addr == "" {
+ return ":0"
+ }
+ if _, _, err := net.SplitHostPort(addr); err == nil {
+ return addr
+ }
+ // bare IPv6 needs brackets before appending port
+ if ip := net.ParseIP(addr); ip != nil {
+ if ip.To4() == nil {
+ return "[" + ip.String() + "]:0"
+ }
+ return ip.String() + ":0"
+ }
+ // hostname without port
+ if !strings.Contains(addr, ":") {
+ return addr + ":0"
+ }
+ return addr
+}
+
+// Run consumes hostnames from input and resolves them, blocking until input
+// is closed and all in-flight queries have completed or the context is
+// cancelled. It is safe to call Run only once per Client.
+func (c *Client) Run(ctx context.Context, input <-chan string) error {
+ if c.limiter != nil {
+ c.limiter.start(ctx)
+ defer c.limiter.stop()
+ }
+
+ readerCtx, cancelReaders := context.WithCancel(ctx)
+ defer cancelReaders()
+
+ var readerWg sync.WaitGroup
+ for _, s := range c.sockets {
+ readerWg.Add(1)
+ go func(s *socket) {
+ defer readerWg.Done()
+ c.readLoop(readerCtx, s)
+ }(s)
+ }
+
+ timeoutDone := make(chan struct{})
+ go func() {
+ defer close(timeoutDone)
+ c.timeoutLoop(readerCtx)
+ }()
+
+ if c.dynSem != nil {
+ c.dynSem.watch(readerCtx)
+ }
+
+ // the controller drives adaptive batching, adaptive concurrency, and
+ // periodic progress reporting; start it if any of those is requested.
+ if (c.batchCapable && c.opts.Batch == BatchAdaptive) || c.dynSem != nil || c.opts.OnProgress != nil {
+ go c.controllerLoop(readerCtx)
+ }
+
+ if c.batchCapable {
+ c.dispatchBatched(ctx, input)
+ } else {
+ c.dispatchSingle(ctx, input)
+ }
+
+ // Wait for outstanding queries to drain (success, NXDOMAIN, or retry
+ // exhaustion all release the pending counter).
+ waitDone := make(chan struct{})
+ go func() {
+ c.pending.Wait()
+ close(waitDone)
+ }()
+
+ select {
+ case <-waitDone:
+ case <-ctx.Done():
+ }
+
+ // stop the timeout loop first so it cannot issue further retransmits, then
+ // close the sockets to immediately unblock the reader goroutines.
+ cancelReaders()
+ <-timeoutDone
+ c.closeSockets()
+ readerWg.Wait()
+ return ctx.Err()
+}
+
+// Close releases the underlying sockets. It must be called when the client is
+// no longer needed (Run does not close sockets so the client can be reused for
+// diagnostics, but typical callers Close after Run).
+func (c *Client) Close() {
+ c.closeSockets()
+}
+
+func (c *Client) closeSockets() {
+ for _, s := range c.sockets {
+ if s == nil {
+ continue
+ }
+ if s.conn != nil {
+ _ = s.conn.Close()
+ s.conn = nil
+ }
+ if s.rawFD >= 0 {
+ closeRawFD(s.rawFD)
+ s.rawFD = -1
+ }
+ }
+}
+
+// pickSocket selects a UDP/raw socket that can reach addr's address family.
+func (c *Client) pickSocket(addr *net.UDPAddr) *socket {
+ pool := c.sockets
+ if addr != nil {
+ if addr.IP.To4() == nil {
+ if len(c.sockets6) > 0 {
+ pool = c.sockets6
+ }
+ } else if len(c.sockets4) > 0 {
+ pool = c.sockets4
+ }
+ }
+ if len(pool) == 0 {
+ return nil
+ }
+ return pool[int(c.sockIdx.Add(1)-1)%len(pool)]
+}
+
+func (c *Client) acquire(ctx context.Context) error {
+ if c.dynSem != nil {
+ return c.dynSem.acquire(ctx)
+ }
+ select {
+ case c.sem <- struct{}{}:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+func (c *Client) release() {
+ if c.dynSem != nil {
+ c.dynSem.release()
+ return
+ }
+ <-c.sem
+}
+
+// pickResolver chooses the resolver index for an attempt. prevIdx is the
+// resolver used by the previous attempt (-1 for the initial send).
+func (c *Client) pickResolver(attempt, prevIdx int) int {
+ // sticky: keep hitting the same resolver on retries (massdns --sticky).
+ if c.sticky && attempt > 0 && prevIdx >= 0 {
+ return prevIdx
+ }
+ // predictable: sequential assignment across the pool (massdns --predictable).
+ if c.predictable {
+ return int(c.predSeq.Add(1)-1) % len(c.resolvers)
+ }
+ // health scoring de-weights failing resolvers via power-of-two-choices.
+ if c.health != nil {
+ return c.health.pick()
+ }
+ // default: rotate per attempt so retries hit a different server.
+ // math/rand/v2 top-level funcs are safe for concurrent use and use a
+ // per-P source, so there is no shared-lock contention here.
+ return (rand.IntN(len(c.resolvers)) + attempt) % len(c.resolvers)
+}
+
+// queryBufPool recycles the small byte buffers used to encode outgoing queries
+// so the hot send path does not allocate per query/retransmit.
+var queryBufPool = sync.Pool{New: func() any { b := make([]byte, 0, 256); return &b }}
+
+// chooseTarget selects the resolver for an attempt. Per-name (extended-input)
+// resolvers are tried first, in order, before falling back to the global pool.
+// A returned resolverIdx of -1 means a per-name resolver (not health-tracked).
+func (c *Client) chooseTarget(attempt, prevIdx int, extra []*net.UDPAddr) (int, *net.UDPAddr) {
+ if attempt < len(extra) {
+ return -1, extra[attempt]
+ }
+ idx := c.pickResolver(attempt, prevIdx)
+ return idx, c.resolverAddrs[idx]
+}
+
+// register allocates a transaction id and inserts the in-flight query into the
+// socket table. It returns the registered query, or nil if the id space is
+// saturated (in which case the name is failed).
+func (c *Client) register(s *socket, name, fqdn string, attempt, resolverIdx int, addr *net.UDPAddr, extra []*net.UDPAddr) *query {
+ s.mu.Lock()
+ txid, ok := c.allocTxID(s)
+ if !ok {
+ s.mu.Unlock()
+ c.fail(name, errors.New("transaction id space exhausted"))
+ return nil
+ }
+ now := time.Now()
+ q := &query{
+ name: name,
+ fqdn: fqdn,
+ txid: txid,
+ resolverIdx: resolverIdx,
+ addr: addr,
+ extra: extra,
+ sentAt: now,
+ deadline: now.Add(c.opts.Timeout),
+ attempts: attempt,
+ sock: s,
+ }
+ s.inflight[txid] = q
+ s.mu.Unlock()
+
+ if c.hasSendHooks {
+ if attempt == 0 {
+ c.fireQuery(q)
+ } else {
+ c.fireRetry(q)
+ }
+ }
+ return q
+}
+
+// cachedAddr resolves a resolver spec to a UDP address, caching the result so
+// repeated extended-input lines don't re-parse the same string.
+func (c *Client) cachedAddr(spec string) *net.UDPAddr {
+ if v, ok := c.addrCache.Load(spec); ok {
+ return v.(*net.UDPAddr)
+ }
+ addr, err := net.ResolveUDPAddr("udp", normalizeResolver(spec))
+ if err != nil {
+ c.addrCache.Store(spec, (*net.UDPAddr)(nil))
+ return nil
+ }
+ c.addrCache.Store(spec, addr)
+ return addr
+}
+
+// parseLine splits an input line into a name and (in extended-input mode) its
+// per-name resolver addresses.
+func (c *Client) parseLine(line string) (string, []*net.UDPAddr) {
+ if !c.extInput {
+ return strings.TrimSpace(line), nil
+ }
+ fields := strings.Fields(line)
+ if len(fields) == 0 {
+ return "", nil
+ }
+ var extra []*net.UDPAddr
+ for _, r := range fields[1:] {
+ if a := c.cachedAddr(r); a != nil {
+ extra = append(extra, a)
+ }
+ }
+ return fields[0], extra
+}
+
+// encodeQuery writes the wire-format query for q into buf, using the hand-rolled
+// fast path and falling back to miekg for names it cannot encode. Returns the
+// packed bytes, or nil on a fatal encoding error.
+func (c *Client) encodeQuery(buf []byte, q *query) []byte {
+ packed, ok := packQuery(buf[:0], q.txid, q.name, c.opts.QueryType, c.rd, c.udpSize)
+ if ok {
+ return packed
+ }
+ msg := new(dns.Msg)
+ msg.SetQuestion(q.fqdn, c.opts.QueryType)
+ msg.RecursionDesired = c.rd
+ msg.Id = q.txid
+ if c.udpSize >= 512 {
+ msg.SetEdns0(c.udpSize, false)
+ }
+ p, err := msg.Pack()
+ if err != nil {
+ return nil
+ }
+ return p
+}
+
+// send builds and transmits a single query, registering it in the socket
+// in-flight table. attempt is the zero-based retransmission count. It is used
+// for retransmissions and on the non-batched (IPv6/mixed) path.
+func (c *Client) send(s *socket, name string, attempt, prevIdx int, extra []*net.UDPAddr) {
+ if c.limiter != nil {
+ c.limiter.take()
+ }
+
+ idx, addr := c.chooseTarget(attempt, prevIdx, extra)
+ if s == nil {
+ s = c.pickSocket(addr)
+ }
+ if s == nil {
+ c.fail(name, errors.New("no socket for resolver address family"))
+ return
+ }
+ q := c.register(s, name, dns.Fqdn(name), attempt, idx, addr, extra)
+ if q == nil {
+ return
+ }
+
+ bufp := queryBufPool.Get().(*[]byte)
+ packed := c.encodeQuery(*bufp, q)
+ if packed == nil {
+ queryBufPool.Put(bufp)
+ c.remove(s, q.txid)
+ c.fail(name, errors.New("could not encode query"))
+ return
+ }
+ *bufp = packed // keep any grown backing array for reuse
+
+ var werr error
+ if s.rawFD >= 0 {
+ src := c.srcRand.pick()
+ dport := uint16(q.addr.Port)
+ if dport == 0 {
+ dport = 53
+ }
+ werr = writeRawUDPv6(s.rawFD, src, q.addr.IP, rawSrcPort, dport, packed)
+ } else {
+ _, werr = s.conn.WriteToUDP(packed, q.addr)
+ }
+ queryBufPool.Put(bufp)
+ if werr != nil {
+ // transient write error: let the timeout loop retry it
+ return
+ }
+}
+
+// dispatchSingle sends one datagram per syscall. Used when batching is
+// unavailable (IPv6/mixed resolver set).
+func (c *Client) dispatchSingle(ctx context.Context, input <-chan string) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case name, ok := <-input:
+ if !ok {
+ return
+ }
+ pname, extra := c.parseLine(name)
+ if pname == "" {
+ continue
+ }
+ if err := c.acquire(ctx); err != nil {
+ return
+ }
+ c.pending.Add(1)
+ c.statQueries.Add(1)
+ // socket is chosen inside send() once the target resolver family is known
+ c.send(nil, pname, 0, -1, extra)
+ }
+ }
+}
+
+// dispatchBatched coalesces outbound queries and flushes them with a single
+// sendmmsg (on Linux) per socket, amortizing the per-datagram syscall cost that
+// otherwise caps single-threaded send throughput. Each batch targets one socket
+// (rotated per batch); individual messages may target different resolvers.
+func (c *Client) dispatchBatched(ctx context.Context, input <-chan string) {
+ bufs := make([][]byte, c.batchSize)
+ msgs := make([]ipv4.Message, c.batchSize)
+ for i := range bufs {
+ bufs[i] = make([]byte, 0, 512)
+ msgs[i].Buffers = [][]byte{nil}
+ }
+
+ var sockIdx uint64
+ for {
+ // block for the first name of a batch
+ name, ok := c.nextName(ctx, input)
+ if !ok {
+ return
+ }
+
+ s := c.sockets[int(atomic.AddUint64(&sockIdx, 1))%len(c.sockets)]
+ n := 0
+ for {
+ if c.limiter != nil {
+ c.limiter.take()
+ }
+ if err := c.acquire(ctx); err != nil {
+ // flush what we have before bailing out
+ c.flushBatch(s, msgs[:n])
+ return
+ }
+ c.pending.Add(1)
+ c.statQueries.Add(1)
+
+ pname, extra := c.parseLine(name)
+ idx, addr := c.chooseTarget(0, -1, extra)
+ q := c.register(s, pname, dns.Fqdn(pname), 0, idx, addr, extra)
+ if q != nil {
+ packed := c.encodeQuery(bufs[n][:0], q)
+ if packed == nil {
+ c.remove(s, q.txid)
+ c.fail(pname, errors.New("could not encode query"))
+ } else {
+ bufs[n] = packed
+ msgs[n].Buffers[0] = packed
+ msgs[n].Addr = q.addr
+ n++
+ }
+ }
+
+ if n == c.batchSize {
+ break
+ }
+ // opportunistically pull more already-queued names without blocking
+ var more bool
+ name, ok, more = c.tryNextName(input)
+ if !ok {
+ // input closed: flush and finish
+ c.flushBatch(s, msgs[:n])
+ return
+ }
+ if !more {
+ break // nothing immediately available; flush the partial batch
+ }
+ }
+ c.flushBatch(s, msgs[:n])
+ }
+}
+
+// flushBatch transmits a prepared batch. It uses sendmmsg only when batching is
+// active and the batch is large enough to be worth the header-array setup;
+// otherwise it sends each datagram with a plain sendto. Unsent messages
+// (partial write / error) stay in the in-flight table and are recovered by the
+// timeout loop.
+func (c *Client) flushBatch(s *socket, msgs []ipv4.Message) {
+ if len(msgs) == 0 {
+ return
+ }
+ if !c.batchActive.Load() || len(msgs) < minBatchToMMSG {
+ for i := range msgs {
+ if addr, ok := msgs[i].Addr.(*net.UDPAddr); ok {
+ _, _ = s.conn.WriteToUDP(msgs[i].Buffers[0], addr)
+ }
+ }
+ return
+ }
+ for off := 0; off < len(msgs); {
+ n, err := s.pc.WriteBatch(msgs[off:], 0)
+ if err != nil || n <= 0 {
+ return
+ }
+ off += n
+ }
+}
+
+// nextName blocks for the next non-empty name, returning ok=false when the
+// input is closed or the context is cancelled.
+func (c *Client) nextName(ctx context.Context, input <-chan string) (string, bool) {
+ for {
+ select {
+ case <-ctx.Done():
+ return "", false
+ case name, ok := <-input:
+ if !ok {
+ return "", false
+ }
+ if name = strings.TrimSpace(name); name != "" {
+ return name, true
+ }
+ }
+ }
+}
+
+// tryNextName does a non-blocking read of the next name. more=false means no
+// name is immediately available (the caller should flush its partial batch);
+// ok=false means the input channel is closed.
+func (c *Client) tryNextName(input <-chan string) (name string, ok, more bool) {
+ for {
+ select {
+ case n, chOpen := <-input:
+ if !chOpen {
+ return "", false, false
+ }
+ if n = strings.TrimSpace(n); n != "" {
+ return n, true, true
+ }
+ // skip empty, keep trying without blocking
+ default:
+ return "", true, false
+ }
+ }
+}
+
+// packQuery encodes a minimal DNS query (single question) into buf, returning
+// the filled slice and true. rd sets the recursion-desired flag; when udpSize
+// >= 512 an EDNS0 OPT record advertising that payload size is appended. It
+// returns false for names it cannot encode (empty/oversized labels), so the
+// caller can fall back to the general encoder.
+func packQuery(buf []byte, id uint16, name string, qtype uint16, rd bool, udpSize uint16) ([]byte, bool) {
+ var flagHi byte
+ if rd {
+ flagHi = 0x01 // recursion desired
+ }
+ var arcount byte
+ if udpSize >= 512 {
+ arcount = 0x01 // one additional record (OPT)
+ }
+ buf = append(buf,
+ byte(id>>8), byte(id),
+ flagHi, 0x00, // flags
+ 0x00, 0x01, // QDCOUNT = 1
+ 0x00, 0x00, // ANCOUNT
+ 0x00, 0x00, // NSCOUNT
+ 0x00, arcount, // ARCOUNT
+ )
+
+ name = strings.TrimSuffix(name, ".")
+ start := 0
+ for i := 0; i <= len(name); i++ {
+ if i < len(name) && name[i] != '.' {
+ continue
+ }
+ l := i - start
+ if l == 0 || l > 63 {
+ // empty label (leading/double dot) or oversized label
+ return buf, false
+ }
+ buf = append(buf, byte(l))
+ buf = append(buf, name[start:i]...)
+ start = i + 1
+ }
+ buf = append(buf, 0x00) // root label terminator
+
+ buf = append(buf, byte(qtype>>8), byte(qtype), 0x00, 0x01) // qtype, qclass IN
+
+ if udpSize >= 512 {
+ // EDNS0 OPT pseudo-record: root name, type OPT(41), class=UDP payload
+ // size, extended-rcode/flags/version=0, rdlen=0.
+ buf = append(buf,
+ 0x00, // root name
+ 0x00, 0x29, // type OPT (41)
+ byte(udpSize>>8), byte(udpSize), // requestor UDP payload size
+ 0x00, 0x00, 0x00, 0x00, // ext-rcode, version, flags
+ 0x00, 0x00, // rdlen = 0
+ )
+ }
+
+ if len(buf) > 512 {
+ return buf, false
+ }
+ return buf, true
+}
+
+// allocTxID finds a free transaction id in the socket table. Caller holds s.mu.
+func (c *Client) allocTxID(s *socket) (uint16, bool) {
+ for i := 0; i < maxTxIDAttempts; i++ {
+ id := uint16(rand.Uint32())
+ if _, exists := s.inflight[id]; !exists {
+ return id, true
+ }
+ }
+ return 0, false
+}
+
+func (c *Client) remove(s *socket, txid uint16) *query {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ q, ok := s.inflight[txid]
+ if !ok {
+ return nil
+ }
+ delete(s.inflight, txid)
+ return q
+}
+
+// fail abandons a name after exhausting retries (or a fatal send error).
+func (c *Client) fail(name string, err error) {
+ if c.opts.OnError != nil {
+ c.opts.OnError(name, err)
+ }
+ c.release()
+ c.pending.Done()
+}
+
+// deliver emits a final result for a name.
+func (c *Client) deliver(res Result) {
+ c.statAnswered.Add(1)
+ if c.opts.OnResult != nil {
+ c.opts.OnResult(res)
+ }
+ c.release()
+ c.pending.Done()
+}
+
+// readLoop drains responses from a single socket. When batching is unavailable
+// it always reads one datagram per syscall; when capable it reads a batch
+// (recvmmsg on Linux) while batching is active and falls back to single reads
+// otherwise, so the read strategy tracks the adaptive controller live.
+func (c *Client) readLoop(ctx context.Context, s *socket) {
+ single := make([]byte, 4096)
+
+ var msgs []ipv4.Message
+ if c.batchCapable {
+ msgs = make([]ipv4.Message, c.batchSize)
+ for i := range msgs {
+ msgs[i].Buffers = [][]byte{make([]byte, 4096)}
+ }
+ }
+
+ for {
+ if c.batchCapable && c.batchActive.Load() {
+ n, err := s.pc.ReadBatch(msgs, 0)
+ if err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ continue
+ }
+ for i := 0; i < n; i++ {
+ m := &msgs[i]
+ if m.N == 0 {
+ continue
+ }
+ src, _ := m.Addr.(*net.UDPAddr)
+ c.handlePacket(s, m.Buffers[0][:m.N], src)
+ }
+ continue
+ }
+
+ // single-datagram read (portable path / batching disengaged / raw IPv6)
+ if s.rawFD >= 0 {
+ payload, addr, err := readRawUDPv6(s.rawFD, single)
+ if err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ continue
+ }
+ c.handlePacket(s, payload, addr)
+ continue
+ }
+ n, addr, err := s.conn.ReadFromUDP(single)
+ if err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ continue
+ }
+ c.handlePacket(s, single[:n], addr)
+ }
+}
+
+// sameUDPAddr reports whether two UDP addresses share IP and port.
+func sameUDPAddr(a, b *net.UDPAddr) bool {
+ if a == nil || b == nil {
+ return false
+ }
+ return a.Port == b.Port && a.IP.Equal(b.IP)
+}
+
+// handlePacket parses a single response datagram and dispatches it. src is the
+// datagram's source address, used for anti-spoofing verification.
+func (c *Client) handlePacket(s *socket, packet []byte, src *net.UDPAddr) {
+ resp := new(dns.Msg)
+ if err := resp.Unpack(packet); err != nil {
+ return
+ }
+ if len(resp.Question) == 0 {
+ return
+ }
+ q := c.matchAndRemove(s, resp, src)
+ if q == nil {
+ return
+ }
+ // sample round-trip time for the controller, only when something consumes
+ // it (adaptive batch/concurrency or progress reporting).
+ if c.sampleRTT {
+ c.lastRTTNanos.Store(int64(time.Since(q.sentAt)))
+ }
+
+ if c.hooks.OnResponse != nil {
+ c.hooks.OnResponse(c.queryInfo(q), resp)
+ }
+
+ from := ""
+ if src != nil {
+ from = src.String()
+ }
+ c.handleResponse(q, resp, from)
+}
+
+// matchAndRemove correlates a response to an in-flight query and removes it.
+// When source-IP verification is enabled, a reply whose source does not match
+// the resolver the query was sent to is ignored and the query is left in-flight
+// (so a genuine reply, or a retransmission, can still resolve it).
+func (c *Client) matchAndRemove(s *socket, resp *dns.Msg, src *net.UDPAddr) *query {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ q, ok := s.inflight[resp.Id]
+ if !ok {
+ return nil
+ }
+ // validate the question name to guard against id collisions
+ if !strings.EqualFold(resp.Question[0].Name, q.fqdn) {
+ return nil
+ }
+ // anti-spoofing: the reply must come from the resolver we queried.
+ if c.verify && !sameUDPAddr(src, q.addr) {
+ return nil
+ }
+ delete(s.inflight, resp.Id)
+ return q
+}
+
+func (c *Client) shouldRetry(rcode int) bool {
+ if rcode >= 0 && rcode < len(c.retryRcode) {
+ return c.retryRcode[rcode]
+ }
+ return false
+}
+
+func (c *Client) recordHealth(idx int, ok bool) {
+ if c.health == nil {
+ return
+ }
+ transitioned, healthy := c.health.record(idx, ok)
+ if transitioned && c.hooks.OnResolverState != nil && idx >= 0 && idx < len(c.resolvers) {
+ c.hooks.OnResolverState(c.resolvers[idx], healthy)
+ }
+}
+
+// handleResponse decides whether a response is final, must be retried, or needs
+// a TCP follow-up (truncation).
+func (c *Client) handleResponse(q *query, resp *dns.Msg, from string) {
+ // truncated UDP answer: the resolver answered but the payload didn't fit,
+ // so fetch the full record set over TCP (off the reader goroutine).
+ if resp.Truncated && c.tcp {
+ c.recordHealth(q.resolverIdx, true)
+ if c.hooks.OnTruncated != nil {
+ c.hooks.OnTruncated(c.queryInfo(q))
+ }
+ go c.tcpFollowup(q, from)
+ return
+ }
+
+ if c.shouldRetry(resp.Rcode) && q.attempts+1 < c.opts.MaxRetries {
+ c.recordHealth(q.resolverIdx, false)
+ c.retry(q)
+ return
+ }
+
+ c.recordHealth(q.resolverIdx, true)
+ c.finalize(q, c.buildResult(q, resp, from), from)
+}
+
+// finalize delivers a result, optionally cross-checking positive answers on a
+// second resolver first (off the reader goroutine).
+func (c *Client) finalize(q *query, res Result, from string) {
+ if c.opts.CrossCheck && res.Rcode == dns.RcodeSuccess && (len(res.A) > 0 || len(res.AAAA) > 0) {
+ go c.crossVerify(q, res)
+ return
+ }
+ c.deliver(res)
+}
+
+func (c *Client) buildResult(q *query, resp *dns.Msg, from string) Result {
+ res := Result{
+ Name: q.name,
+ Type: c.opts.QueryType,
+ Rcode: resp.Rcode,
+ Resolver: from,
+ Msg: resp,
+ Timestamp: time.Now(),
+ }
+ for _, rr := range resp.Answer {
+ switch v := rr.(type) {
+ case *dns.A:
+ res.A = append(res.A, v.A.String())
+ case *dns.AAAA:
+ res.AAAA = append(res.AAAA, v.AAAA.String())
+ case *dns.CNAME:
+ res.CNAME = append(res.CNAME, strings.TrimSuffix(v.Target, "."))
+ case *dns.NS:
+ res.NS = append(res.NS, strings.TrimSuffix(v.Ns, "."))
+ case *dns.PTR:
+ res.PTR = append(res.PTR, strings.TrimSuffix(v.Ptr, "."))
+ case *dns.MX:
+ res.MX = append(res.MX, strings.TrimSuffix(v.Mx, "."))
+ case *dns.TXT:
+ res.TXT = append(res.TXT, v.Txt...)
+ case *dns.SOA:
+ res.SOA = append(res.SOA, strings.TrimSuffix(v.Ns, "."))
+ }
+ }
+ return res
+}
+
+// tcpFollowup re-issues q over TCP to retrieve a full (untruncated) answer.
+func (c *Client) tcpFollowup(q *query, from string) {
+ client := &dns.Client{Net: "tcp", Timeout: c.opts.Timeout}
+ m := new(dns.Msg)
+ m.SetQuestion(q.fqdn, c.opts.QueryType)
+ m.RecursionDesired = c.rd
+ m.Id = q.txid
+
+ resp, _, err := client.Exchange(m, q.addr.String())
+ if err != nil || resp == nil {
+ // TCP failed: fall back to a normal UDP retry, or give up.
+ if q.attempts+1 < c.opts.MaxRetries {
+ c.recordHealth(q.resolverIdx, false)
+ c.retry(q)
+ return
+ }
+ c.fail(q.name, fmt.Errorf("tcp fallback failed: %w", err))
+ return
+ }
+ c.finalize(q, c.buildResult(q, resp, from), from)
+}
+
+// crossVerify re-resolves a positive answer on a different resolver and only
+// delivers it if the two agree on the address set, providing basic protection
+// against DNS poisoning/spam from a single bad resolver.
+func (c *Client) crossVerify(q *query, res Result) {
+ idx := c.otherResolver(q.resolverIdx)
+ client := &dns.Client{Timeout: c.opts.Timeout}
+ m := new(dns.Msg)
+ m.SetQuestion(q.fqdn, c.opts.QueryType)
+ m.RecursionDesired = c.rd
+ if c.udpSize >= 512 {
+ m.SetEdns0(c.udpSize, false)
+ }
+
+ resp, _, err := client.Exchange(m, c.resolverAddrs[idx].String())
+ if err != nil || resp == nil || resp.Rcode != dns.RcodeSuccess {
+ // can't confirm: drop conservatively
+ if c.hooks.OnCrossCheckFailed != nil {
+ c.hooks.OnCrossCheckFailed(q.name, res.A, nil)
+ }
+ c.fail(q.name, errPoisonSuspected)
+ return
+ }
+ confirm := c.buildResult(q, resp, c.resolverAddrs[idx].String())
+ if sameStringSet(res.A, confirm.A) && sameStringSet(res.AAAA, confirm.AAAA) {
+ c.deliver(res)
+ return
+ }
+ if c.hooks.OnCrossCheckFailed != nil {
+ c.hooks.OnCrossCheckFailed(q.name, res.A, confirm.A)
+ }
+ c.fail(q.name, errPoisonSuspected)
+}
+
+// otherResolver returns a resolver index different from idx (best effort).
+func (c *Client) otherResolver(idx int) int {
+ if len(c.resolvers) == 1 {
+ return idx
+ }
+ for {
+ if j := rand.IntN(len(c.resolvers)); j != idx {
+ return j
+ }
+ }
+}
+
+// retry retransmits a query. Every retransmit (timeout or bad-rcode) is counted
+// as a loss signal for the adaptive controller. The resolver is rotated unless
+// sticky mode is set.
+func (c *Client) retry(q *query) {
+ c.statRetransmit.Add(1)
+ attempt := q.attempts + 1
+ c.send(q.sock, q.name, attempt, q.resolverIdx, q.extra)
+}
+
+// timeoutLoop scans in-flight tables and retransmits or abandons expired queries.
+func (c *Client) timeoutLoop(ctx context.Context) {
+ // scan at a fraction of the per-attempt timeout so a lost packet is
+ // retransmitted close to its deadline instead of up to a full extra tick
+ // late. Bounded below to avoid burning CPU on tiny timeouts. An explicit
+ // Interval overrides the derived value (massdns -i/--interval).
+ interval := c.opts.Interval
+ if interval <= 0 {
+ interval = c.opts.Timeout / 8
+ }
+ if interval < 25*time.Millisecond {
+ interval = 25 * time.Millisecond
+ }
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ c.scanExpired()
+ }
+ }
+}
+
+func (c *Client) scanExpired() {
+ now := time.Now()
+ for _, s := range c.sockets {
+ var expired []*query
+ s.mu.Lock()
+ for id, q := range s.inflight {
+ if now.After(q.deadline) {
+ delete(s.inflight, id)
+ expired = append(expired, q)
+ }
+ }
+ s.mu.Unlock()
+
+ for _, q := range expired {
+ c.recordHealth(q.resolverIdx, false) // timeout = resolver miss
+ if c.hooks.OnTimeout != nil {
+ c.hooks.OnTimeout(c.queryInfo(q))
+ }
+ if q.attempts+1 < c.opts.MaxRetries {
+ c.retry(q)
+ } else {
+ c.fail(q.name, errExhausted)
+ }
+ }
+ }
+}
+
+// inflightDepth reports the number of currently outstanding queries, reading
+// whichever semaphore implementation is in use.
+func (c *Client) inflightDepth() int {
+ if c.dynSem != nil {
+ return c.dynSem.inflight()
+ }
+ return len(c.sem)
+}
+
+// controllerLoop is the runtime governor. Every adaptTick it samples smoothed
+// RTT, in-flight depth and interval packet loss, then drives three independent
+// (all optional) feedback mechanisms:
+//
+// - adaptive batching: engage sendmmsg/recvmmsg only when RTT is high enough
+// for packets to cluster AND the pipeline is deep enough to fill batches AND
+// loss is low; back off on low-RTT links, a shallow pipeline, or rising loss
+// (bursty sendmmsg can worsen drops at a saturated buffer);
+// - adaptive concurrency (AIMD): multiplicatively shrink the in-flight cap on
+// high loss and additively grow it back as loss subsides, so we stop
+// flooding resolvers that are dropping/refusing;
+// - progress reporting: emit a Stats snapshot via OnProgress.
+func (c *Client) controllerLoop(ctx context.Context) {
+ ticker := time.NewTicker(adaptTick)
+ defer ticker.Stop()
+
+ const alpha = 0.3 // RTT EWMA smoothing factor
+ var rttEWMA time.Duration
+ lastQ, lastR := c.statQueries.Load(), c.statRetransmit.Load()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ qNow, rNow := c.statQueries.Load(), c.statRetransmit.Load()
+ dq, dr := qNow-lastQ, rNow-lastR
+ lastQ, lastR = qNow, rNow
+
+ var loss float64
+ if total := dq + dr; total > 0 {
+ loss = float64(dr) / float64(total)
+ }
+ c.lastLossBits.Store(math.Float64bits(loss))
+
+ if sample := time.Duration(c.lastRTTNanos.Load()); sample > 0 {
+ if rttEWMA == 0 {
+ rttEWMA = sample
+ } else {
+ rttEWMA = time.Duration(alpha*float64(sample) + (1-alpha)*float64(rttEWMA))
+ }
+ }
+
+ depth := c.inflightDepth()
+
+ // --- adaptive batching ---
+ if c.batchCapable && c.opts.Batch == BatchAdaptive {
+ active := c.batchActive.Load()
+ switch {
+ case active:
+ if rttEWMA < adaptRTTOff || depth < c.batchSize || loss > adaptLossOff {
+ active = false
+ }
+ default:
+ if rttEWMA >= adaptRTTOn && depth >= adaptDepthRatio*c.batchSize && loss <= adaptLossOff {
+ active = true
+ }
+ }
+ c.batchActive.Store(active)
+ }
+
+ // --- adaptive concurrency (AIMD) ---
+ if c.dynSem != nil {
+ cap := c.dynSem.capacity()
+ minCap := int(float64(c.opts.Concurrency) * concMinRatio)
+ if minCap < 1 {
+ minCap = 1
+ }
+ switch {
+ case loss > concLossHigh:
+ nc := int(float64(cap) * concDecrease)
+ if nc < minCap {
+ nc = minCap
+ }
+ c.dynSem.setCap(nc)
+ case loss < concLossLow:
+ // additive increase, ~5% of max per tick
+ step := c.opts.Concurrency / 20
+ if step < 1 {
+ step = 1
+ }
+ c.dynSem.setCap(cap + step)
+ }
+ }
+
+ // --- progress reporting ---
+ if c.opts.OnProgress != nil {
+ capNow := c.opts.Concurrency
+ if c.dynSem != nil {
+ capNow = c.dynSem.capacity()
+ }
+ c.opts.OnProgress(Stats{
+ Queries: qNow,
+ Retransmits: rNow,
+ Answered: c.statAnswered.Load(),
+ InflightDepth: depth,
+ ConcurrencyCap: capNow,
+ LossRate: loss,
+ RTT: rttEWMA,
+ BatchActive: c.batchActive.Load(),
+ })
+ }
+ }
+ }
+}
+
+// Stats returns a current snapshot of resolver activity (safe to call anytime).
+func (c *Client) Stats() Stats {
+ capNow := c.opts.Concurrency
+ if c.dynSem != nil {
+ capNow = c.dynSem.capacity()
+ }
+ return Stats{
+ Queries: c.statQueries.Load(),
+ Retransmits: c.statRetransmit.Load(),
+ Answered: c.statAnswered.Load(),
+ InflightDepth: c.inflightDepth(),
+ ConcurrencyCap: capNow,
+ LossRate: math.Float64frombits(c.lastLossBits.Load()),
+ RTT: time.Duration(c.lastRTTNanos.Load()),
+ BatchActive: c.batchActive.Load(),
+ }
+}
+
+var (
+ errExhausted = errors.New("max retries exhausted")
+ errPoisonSuspected = errors.New("cross-resolver disagreement (possible poisoning)")
+)
+
+// Resolve is a one-shot convenience entry point for SDK use: it builds a client
+// from opts, resolves every name in names, and returns the collected results.
+// Any OnResult hook set in opts is preserved (invoked in addition to
+// collection). For streaming/long-running use, construct a Client with New and
+// call Run directly.
+func Resolve(ctx context.Context, opts Options, names []string) ([]Result, error) {
+ var mu sync.Mutex
+ out := make([]Result, 0, len(names))
+ userOnResult := opts.OnResult
+ opts.OnResult = func(r Result) {
+ mu.Lock()
+ out = append(out, r)
+ mu.Unlock()
+ if userOnResult != nil {
+ userOnResult(r)
+ }
+ }
+
+ client, err := New(opts)
+ if err != nil {
+ return nil, err
+ }
+ defer client.Close()
+
+ input := make(chan string, 1024)
+ go func() {
+ defer close(input)
+ for _, n := range names {
+ select {
+ case input <- n:
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+
+ if err := client.Run(ctx, input); err != nil {
+ return out, err
+ }
+ return out, nil
+}
+
+// sameStringSet reports whether a and b contain the same elements (set
+// equality, ignoring order and duplicates).
+func sameStringSet(a, b []string) bool {
+ if len(a) == 0 && len(b) == 0 {
+ return true
+ }
+ set := make(map[string]struct{}, len(a))
+ for _, v := range a {
+ set[v] = struct{}{}
+ }
+ for _, v := range b {
+ if _, ok := set[v]; !ok {
+ return false
+ }
+ }
+ seen := make(map[string]struct{}, len(b))
+ for _, v := range b {
+ seen[v] = struct{}{}
+ }
+ for v := range set {
+ if _, ok := seen[v]; !ok {
+ return false
+ }
+ }
+ return true
+}
+
+func normalizeResolver(r string) string {
+ r = strings.TrimSpace(r)
+ if _, _, err := net.SplitHostPort(r); err != nil {
+ return net.JoinHostPort(r, "53")
+ }
+ return r
+}
diff --git a/pkg/resolve/resolve_test.go b/pkg/resolve/resolve_test.go
new file mode 100644
index 00000000..7260684f
--- /dev/null
+++ b/pkg/resolve/resolve_test.go
@@ -0,0 +1,371 @@
+package resolve
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/require"
+)
+
+// startTestServer spins up an in-process UDP DNS server that answers A queries
+// for names present in the zone map and NXDOMAIN otherwise.
+func startTestServer(t *testing.T, zone map[string]string) (string, func()) {
+ t.Helper()
+
+ pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+ require.NoError(t, err)
+
+ handler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
+ m := new(dns.Msg)
+ m.SetReply(r)
+ if len(r.Question) > 0 {
+ q := r.Question[0]
+ if ip, ok := zone[q.Name]; ok && q.Qtype == dns.TypeA {
+ rr, _ := dns.NewRR(fmt.Sprintf("%s 60 IN A %s", q.Name, ip))
+ m.Answer = append(m.Answer, rr)
+ } else {
+ m.Rcode = dns.RcodeNameError
+ }
+ }
+ _ = w.WriteMsg(m)
+ })
+
+ srv := &dns.Server{PacketConn: pc, Handler: handler}
+ go func() { _ = srv.ActivateAndServe() }()
+
+ // give the server a moment to start
+ time.Sleep(50 * time.Millisecond)
+
+ return pc.LocalAddr().String(), func() { _ = srv.Shutdown() }
+}
+
+func TestResolveBasic(t *testing.T) {
+ zone := map[string]string{
+ "a.example.com.": "1.1.1.1",
+ "b.example.com.": "2.2.2.2",
+ "c.example.com.": "3.3.3.3",
+ }
+ addr, stop := startTestServer(t, zone)
+ defer stop()
+
+ var mu sync.Mutex
+ got := make(map[string][]string)
+ nx := make(map[string]struct{})
+
+ client, err := New(Options{
+ Resolvers: []string{addr},
+ Concurrency: 100,
+ SocketCount: 2,
+ Timeout: time.Second,
+ MaxRetries: 2,
+ OnResult: func(r Result) {
+ mu.Lock()
+ defer mu.Unlock()
+ switch r.Rcode {
+ case dns.RcodeSuccess:
+ got[r.Name] = r.A
+ case dns.RcodeNameError:
+ nx[r.Name] = struct{}{}
+ }
+ },
+ })
+ require.NoError(t, err)
+ defer client.Close()
+
+ input := make(chan string)
+ go func() {
+ defer close(input)
+ for _, n := range []string{"a.example.com", "b.example.com", "c.example.com", "missing.example.com"} {
+ input <- n
+ }
+ }()
+
+ require.NoError(t, client.Run(context.Background(), input))
+
+ mu.Lock()
+ defer mu.Unlock()
+ require.Equal(t, []string{"1.1.1.1"}, got["a.example.com"])
+ require.Equal(t, []string{"2.2.2.2"}, got["b.example.com"])
+ require.Equal(t, []string{"3.3.3.3"}, got["c.example.com"])
+ require.Contains(t, nx, "missing.example.com")
+}
+
+// TestResolveBatchModes verifies that every batching strategy resolves the same
+// set of names correctly. Batching only changes the send/recv syscall path, so
+// results must be identical regardless of mode (on platforms without
+// sendmmsg/recvmmsg the batch path transparently degrades to single I/O).
+func TestResolveBatchModes(t *testing.T) {
+ zone := make(map[string]string)
+ const total = 1500
+ for i := 0; i < total; i++ {
+ zone[fmt.Sprintf("host%d.example.com.", i)] = fmt.Sprintf("10.1.%d.%d", i/256, i%256)
+ }
+ addr, stop := startTestServer(t, zone)
+ defer stop()
+
+ for _, tc := range []struct {
+ name string
+ mode BatchMode
+ }{
+ {"disabled", BatchDisabled},
+ {"enabled", BatchEnabled},
+ {"adaptive", BatchAdaptive},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var mu sync.Mutex
+ seen := make(map[string]struct{})
+
+ client, err := New(Options{
+ Resolvers: []string{addr},
+ Concurrency: 500,
+ SocketCount: 4,
+ Timeout: time.Second,
+ MaxRetries: 3,
+ Batch: tc.mode,
+ BatchSize: 16,
+ OnResult: func(r Result) {
+ if r.Rcode == dns.RcodeSuccess && len(r.A) > 0 {
+ mu.Lock()
+ seen[r.Name] = struct{}{}
+ mu.Unlock()
+ }
+ },
+ })
+ require.NoError(t, err)
+ defer client.Close()
+
+ input := make(chan string)
+ go func() {
+ defer close(input)
+ for i := 0; i < total; i++ {
+ input <- fmt.Sprintf("host%d.example.com", i)
+ }
+ }()
+
+ require.NoError(t, client.Run(context.Background(), input))
+
+ mu.Lock()
+ defer mu.Unlock()
+ require.Equal(t, total, len(seen), "all names should resolve in %s mode", tc.name)
+ })
+ }
+}
+
+// TestRecordTypesAndFlags spins up a server that records the recursion-desired
+// bit and EDNS0 presence, answers TXT, and verifies the resolver encodes
+// queries correctly (EDNS0 on by default, RD configurable) and parses TXT.
+func TestRecordTypesAndFlags(t *testing.T) {
+ pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+ require.NoError(t, err)
+
+ var sawEDNS, sawRD atomic.Bool
+ handler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
+ if r.IsEdns0() != nil {
+ sawEDNS.Store(true)
+ }
+ if r.RecursionDesired {
+ sawRD.Store(true)
+ }
+ m := new(dns.Msg)
+ m.SetReply(r)
+ if len(r.Question) > 0 && r.Question[0].Qtype == dns.TypeTXT {
+ rr, _ := dns.NewRR(fmt.Sprintf("%s 60 IN TXT \"hello-world\"", r.Question[0].Name))
+ m.Answer = append(m.Answer, rr)
+ }
+ _ = w.WriteMsg(m)
+ })
+ srv := &dns.Server{PacketConn: pc, Handler: handler}
+ go func() { _ = srv.ActivateAndServe() }()
+ defer func() { _ = srv.Shutdown() }()
+ time.Sleep(50 * time.Millisecond)
+ addr := pc.LocalAddr().String()
+
+ var mu sync.Mutex
+ var txt []string
+ client, err := New(Options{
+ Resolvers: []string{addr},
+ QueryType: dns.TypeTXT,
+ Concurrency: 16,
+ SocketCount: 1,
+ Timeout: time.Second,
+ MaxRetries: 2,
+ NoRecurse: true, // RD must NOT be set
+ OnResult: func(r Result) {
+ mu.Lock()
+ txt = append(txt, r.TXT...)
+ mu.Unlock()
+ },
+ })
+ require.NoError(t, err)
+ defer client.Close()
+
+ input := make(chan string, 1)
+ input <- "example.com"
+ close(input)
+ require.NoError(t, client.Run(context.Background(), input))
+
+ mu.Lock()
+ defer mu.Unlock()
+ require.Contains(t, txt, "hello-world", "TXT record should be parsed")
+ require.True(t, sawEDNS.Load(), "queries should advertise EDNS0 by default")
+ require.False(t, sawRD.Load(), "NoRecurse should clear the RD bit")
+}
+
+// TestAllFeaturesEnabled verifies that turning on the full feature set (health
+// scoring, adaptive concurrency, adaptive batching, cross-check, source
+// verification, TCP fallback) still resolves every name correctly.
+func TestAllFeaturesEnabled(t *testing.T) {
+ zone := make(map[string]string)
+ const total = 1000
+ for i := 0; i < total; i++ {
+ zone[fmt.Sprintf("h%d.example.com.", i)] = fmt.Sprintf("10.2.%d.%d", i/256, i%256)
+ }
+ addr1, stop1 := startTestServer(t, zone)
+ defer stop1()
+ addr2, stop2 := startTestServer(t, zone)
+ defer stop2()
+
+ var mu sync.Mutex
+ seen := make(map[string]struct{})
+ client, err := New(Options{
+ Resolvers: []string{addr1, addr2},
+ Concurrency: 300,
+ SocketCount: 4,
+ Timeout: time.Second,
+ MaxRetries: 3,
+ Batch: BatchAdaptive,
+ ResolverHealth: true,
+ AdaptiveConcurrency: true,
+ CrossCheck: true,
+ OnResult: func(r Result) {
+ if r.Rcode == dns.RcodeSuccess && len(r.A) > 0 {
+ mu.Lock()
+ seen[r.Name] = struct{}{}
+ mu.Unlock()
+ }
+ },
+ })
+ require.NoError(t, err)
+ defer client.Close()
+
+ input := make(chan string)
+ go func() {
+ defer close(input)
+ for i := 0; i < total; i++ {
+ input <- fmt.Sprintf("h%d.example.com", i)
+ }
+ }()
+ require.NoError(t, client.Run(context.Background(), input))
+
+ mu.Lock()
+ defer mu.Unlock()
+ require.Equal(t, total, len(seen), "all names should resolve with all features enabled")
+}
+
+// TestHooks verifies the lifecycle hooks fire and that the package-level
+// Resolve convenience entry point collects results.
+func TestHooks(t *testing.T) {
+ zone := map[string]string{
+ "a.example.com.": "1.1.1.1",
+ "b.example.com.": "2.2.2.2",
+ }
+ addr, stop := startTestServer(t, zone)
+ defer stop()
+
+ var queries, responses atomic.Int64
+
+ results, err := Resolve(context.Background(), Options{
+ Resolvers: []string{addr},
+ Concurrency: 16,
+ SocketCount: 1,
+ Timeout: time.Second,
+ MaxRetries: 2,
+ Hooks: Hooks{
+ OnQuery: func(QueryInfo) { queries.Add(1) },
+ OnResponse: func(QueryInfo, *dns.Msg) { responses.Add(1) },
+ },
+ }, []string{"a.example.com", "b.example.com", "missing.example.com"})
+ require.NoError(t, err)
+
+ require.Len(t, results, 3, "Resolve should collect a result per name")
+ require.GreaterOrEqual(t, queries.Load(), int64(3), "OnQuery should fire per name")
+ require.GreaterOrEqual(t, responses.Load(), int64(3), "OnResponse should fire per answered query")
+}
+
+// TestHookTimeout verifies OnTimeout fires when a resolver never answers.
+func TestHookTimeout(t *testing.T) {
+ // a black-hole UDP socket that accepts but never replies
+ pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+ require.NoError(t, err)
+ defer func() { _ = pc.Close() }()
+
+ var timeouts atomic.Int64
+ client, err := New(Options{
+ Resolvers: []string{pc.LocalAddr().String()},
+ Concurrency: 4,
+ SocketCount: 1,
+ Timeout: 100 * time.Millisecond,
+ MaxRetries: 2,
+ Hooks: Hooks{OnTimeout: func(QueryInfo) { timeouts.Add(1) }},
+ })
+ require.NoError(t, err)
+ defer client.Close()
+
+ input := make(chan string, 1)
+ input <- "dead.example.com"
+ close(input)
+ require.NoError(t, client.Run(context.Background(), input))
+ require.GreaterOrEqual(t, timeouts.Load(), int64(1), "OnTimeout should fire for an unanswered query")
+}
+
+func TestResolveManyNames(t *testing.T) {
+ zone := make(map[string]string)
+ const total = 2000
+ for i := 0; i < total; i++ {
+ zone[fmt.Sprintf("host%d.example.com.", i)] = fmt.Sprintf("10.0.%d.%d", i/256, i%256)
+ }
+ addr, stop := startTestServer(t, zone)
+ defer stop()
+
+ var count int64
+ var mu sync.Mutex
+ seen := make(map[string]struct{})
+
+ client, err := New(Options{
+ Resolvers: []string{addr},
+ Concurrency: 500,
+ SocketCount: 4,
+ Timeout: time.Second,
+ MaxRetries: 3,
+ OnResult: func(r Result) {
+ if r.Rcode == dns.RcodeSuccess && len(r.A) > 0 {
+ mu.Lock()
+ seen[r.Name] = struct{}{}
+ count++
+ mu.Unlock()
+ }
+ },
+ })
+ require.NoError(t, err)
+ defer client.Close()
+
+ input := make(chan string)
+ go func() {
+ defer close(input)
+ for i := 0; i < total; i++ {
+ input <- fmt.Sprintf("host%d.example.com", i)
+ }
+ }()
+
+ require.NoError(t, client.Run(context.Background(), input))
+
+ mu.Lock()
+ defer mu.Unlock()
+ require.Equal(t, total, len(seen), "all names should resolve")
+}
diff --git a/pkg/resolve/sem.go b/pkg/resolve/sem.go
new file mode 100644
index 00000000..3968a5b8
--- /dev/null
+++ b/pkg/resolve/sem.go
@@ -0,0 +1,98 @@
+package resolve
+
+import (
+ "context"
+ "sync"
+)
+
+// adaptiveSem is a counting semaphore whose capacity can be changed at runtime.
+// It is used for the adaptive-concurrency in-flight cap: the controller shrinks
+// the cap under packet loss (to stop flooding a saturated resolver) and grows
+// it back when loss subsides. Acquire is cancellable via context.
+//
+// Only one goroutine acquires (the dispatcher) while many release (readers and
+// timeout/fallback paths), so the single mutex sees low contention.
+type adaptiveSem struct {
+ mu sync.Mutex
+ cond *sync.Cond
+ cur int
+ cap int
+ maxCap int
+ closed bool
+}
+
+func newAdaptiveSem(capacity int) *adaptiveSem {
+ if capacity < 1 {
+ capacity = 1
+ }
+ s := &adaptiveSem{cur: 0, cap: capacity, maxCap: capacity}
+ s.cond = sync.NewCond(&s.mu)
+ return s
+}
+
+// watch wires context cancellation to wake any blocked acquirers.
+func (s *adaptiveSem) watch(ctx context.Context) {
+ go func() {
+ <-ctx.Done()
+ s.mu.Lock()
+ s.closed = true
+ s.mu.Unlock()
+ s.cond.Broadcast()
+ }()
+}
+
+func (s *adaptiveSem) acquire(ctx context.Context) error {
+ s.mu.Lock()
+ for s.cur >= s.cap && !s.closed {
+ s.cond.Wait()
+ }
+ if s.closed {
+ s.mu.Unlock()
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return context.Canceled
+ }
+ s.cur++
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *adaptiveSem) release() {
+ s.mu.Lock()
+ if s.cur > 0 {
+ s.cur--
+ }
+ s.mu.Unlock()
+ s.cond.Signal()
+}
+
+// setCap adjusts the capacity, clamped to [1, maxCap]. Increasing it wakes
+// waiters so they can proceed immediately.
+func (s *adaptiveSem) setCap(n int) {
+ if n < 1 {
+ n = 1
+ }
+ s.mu.Lock()
+ if n > s.maxCap {
+ n = s.maxCap
+ }
+ grew := n > s.cap
+ s.cap = n
+ s.mu.Unlock()
+ if grew {
+ s.cond.Broadcast()
+ }
+}
+
+func (s *adaptiveSem) capacity() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.cap
+}
+
+func (s *adaptiveSem) inflight() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.cur
+}
diff --git a/pkg/resolve/srcrand.go b/pkg/resolve/srcrand.go
new file mode 100644
index 00000000..25710e7f
--- /dev/null
+++ b/pkg/resolve/srcrand.go
@@ -0,0 +1,102 @@
+package resolve
+
+import (
+ "bufio"
+ "fmt"
+ "math/rand/v2"
+ "net"
+ "os"
+ "strings"
+)
+
+// srcRand picks a random IPv6 source address for each query, either from a
+// prefix (massdns --rand-src-ipv6) or from a file of addresses
+// (--rand-src-ipv6-file). Used with Linux raw IPv6 UDP sockets + IPV6_HDRINCL.
+type srcRand struct {
+ fromFile bool
+ base net.IP // 16-byte network base (prefix bits set, host zeroed)
+ bits int
+ addrs []net.IP
+}
+
+func newSrcRandFromPrefix(spec string) (*srcRand, error) {
+ spec = strings.TrimSpace(spec)
+ if spec == "" {
+ return nil, fmt.Errorf("empty IPv6 prefix")
+ }
+ if !strings.Contains(spec, "/") {
+ spec += "/128"
+ }
+ ip, ipnet, err := net.ParseCIDR(spec)
+ if err != nil {
+ return nil, fmt.Errorf("invalid --rand-src-ipv6 %q: %w", spec, err)
+ }
+ ip = ip.To16()
+ if ip == nil || ip.To4() != nil {
+ return nil, fmt.Errorf("--rand-src-ipv6 requires an IPv6 prefix")
+ }
+ ones, bits := ipnet.Mask.Size()
+ if bits != 128 {
+ return nil, fmt.Errorf("--rand-src-ipv6 requires an IPv6 prefix")
+ }
+ base := make(net.IP, 16)
+ copy(base, ip.Mask(ipnet.Mask).To16())
+ return &srcRand{base: base, bits: ones}, nil
+}
+
+func newSrcRandFromFile(path string) (*srcRand, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = f.Close() }()
+ var addrs []net.IP
+ sc := bufio.NewScanner(f)
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ // allow optional /prefix; take address only
+ if i := strings.IndexByte(line, '/'); i >= 0 {
+ line = line[:i]
+ }
+ ip := net.ParseIP(line)
+ if ip == nil || ip.To4() != nil {
+ continue
+ }
+ addrs = append(addrs, ip.To16())
+ }
+ if err := sc.Err(); err != nil {
+ return nil, err
+ }
+ if len(addrs) == 0 {
+ return nil, fmt.Errorf("no valid IPv6 addresses in %s", path)
+ }
+ return &srcRand{fromFile: true, addrs: addrs}, nil
+}
+
+func (s *srcRand) pick() net.IP {
+ if s.fromFile {
+ return s.addrs[rand.IntN(len(s.addrs))]
+ }
+ out := make(net.IP, 16)
+ copy(out, s.base)
+ hostBits := 128 - s.bits
+ if hostBits <= 0 {
+ return out
+ }
+ // Randomize the host portion (same approach as massdns: full trailing
+ // bytes randomly, plus a partial byte for leftover bits).
+ fullBytes := hostBits / 8
+ remBits := hostBits % 8
+ if remBits > 0 {
+ idx := 16 - fullBytes - 1
+ mask := byte((1 << remBits) - 1)
+ out[idx] = (out[idx] & ^mask) | (byte(rand.Uint32()) & mask)
+ }
+ for i := 0; i < fullBytes; i++ {
+ out[16-fullBytes+i] = byte(rand.Uint32())
+ }
+ return out
+}
diff --git a/pkg/resolve/srcrand_test.go b/pkg/resolve/srcrand_test.go
new file mode 100644
index 00000000..d5a9948f
--- /dev/null
+++ b/pkg/resolve/srcrand_test.go
@@ -0,0 +1,92 @@
+package resolve
+
+import (
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestNewSrcRandFromPrefix(t *testing.T) {
+ sr, err := newSrcRandFromPrefix("2001:db8:abcd::/48")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 32; i++ {
+ ip := sr.pick()
+ if ip.To16() == nil || ip.To4() != nil {
+ t.Fatalf("expected IPv6, got %v", ip)
+ }
+ // first 48 bits must match 2001:db8:abcd
+ if ip[0] != 0x20 || ip[1] != 0x01 || ip[2] != 0x0d || ip[3] != 0xb8 || ip[4] != 0xab || ip[5] != 0xcd {
+ t.Fatalf("prefix mismatch: %s", ip)
+ }
+ }
+}
+
+func TestNewSrcRandFromPrefixRejectsIPv4(t *testing.T) {
+ if _, err := newSrcRandFromPrefix("192.0.2.0/24"); err == nil {
+ t.Fatal("expected error for IPv4 prefix")
+ }
+}
+
+func TestNewSrcRandFromFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "srcs.txt")
+ content := "# comment\n2001:db8::1\n2001:db8::2/128\n\n192.0.2.1\n"
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ sr, err := newSrcRandFromFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ seen := map[string]bool{}
+ for i := 0; i < 20; i++ {
+ seen[sr.pick().String()] = true
+ }
+ if !seen[net.ParseIP("2001:db8::1").String()] || !seen[net.ParseIP("2001:db8::2").String()] {
+ t.Fatalf("expected both file addresses, got %v", seen)
+ }
+ if len(seen) != 2 {
+ t.Fatalf("expected only 2 addresses, got %v", seen)
+ }
+}
+
+func TestNewSrcRandMutualExclusivityInOptions(t *testing.T) {
+ _, err := New(Options{
+ Resolvers: []string{"[2001:db8::53]:53"},
+ RandSrcIPv6: "2001:db8::/32",
+ RandSrcIPv6File: "x",
+ SocketCount: 1,
+ Concurrency: 1,
+ })
+ if err == nil {
+ t.Fatal("expected mutual exclusion error")
+ }
+}
+
+func TestNewSrcRandIncompatibleWithBind(t *testing.T) {
+ _, err := New(Options{
+ Resolvers: []string{"[2001:db8::53]:53"},
+ RandSrcIPv6: "2001:db8::/32",
+ BindAddr: "::",
+ SocketCount: 1,
+ Concurrency: 1,
+ })
+ if err == nil {
+ t.Fatal("expected bind+rand-src error")
+ }
+}
+
+func TestNewSrcRandRequiresIPv6Resolvers(t *testing.T) {
+ _, err := New(Options{
+ Resolvers: []string{"1.1.1.1:53"},
+ RandSrcIPv6: "2001:db8::/32",
+ SocketCount: 1,
+ Concurrency: 1,
+ })
+ if err == nil {
+ t.Fatal("expected IPv6 resolvers required error")
+ }
+}
diff --git a/pkg/resolve/validate.go b/pkg/resolve/validate.go
new file mode 100644
index 00000000..5218b810
--- /dev/null
+++ b/pkg/resolve/validate.go
@@ -0,0 +1,167 @@
+package resolve
+
+import (
+ "context"
+ "fmt"
+ "math/rand/v2"
+ "sync"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+// ValidateConfig configures resolver-list curation. It is the native, embeddable
+// equivalent of dnsvalidator: it weeds out resolvers that are dead, that lie by
+// hijacking NXDOMAIN (returning an address for names that do not exist), or that
+// cannot resolve a known-good name. Output quality for any mass-resolution run is
+// bounded by resolver-list quality, so this is the most important accuracy knob.
+type ValidateConfig struct {
+ // Resolvers is the candidate list (host or host:port; :53 assumed).
+ Resolvers []string
+ // GoodDomains are names expected to resolve to at least one A record. A
+ // resolver must successfully resolve one of them to pass. If empty, a small
+ // built-in set of stable, non-wildcard domains is used.
+ GoodDomains []string
+ // Timeout is the per-query timeout. Default 3s.
+ Timeout time.Duration
+ // Concurrency caps simultaneous resolver checks. Default 50.
+ Concurrency int
+ // MaxRTT optionally rejects resolvers slower than this on the positive
+ // probe. 0 disables the latency filter.
+ MaxRTT time.Duration
+ // OnResolver is an optional progress callback fired once per checked
+ // resolver (safe for concurrent use).
+ OnResolver func(ResolverCheck)
+}
+
+// ResolverCheck is the verdict for a single candidate resolver.
+type ResolverCheck struct {
+ Resolver string // normalized host:port
+ OK bool // passed all checks
+ Reason string // failure reason when !OK
+ RTT time.Duration // latency of the positive probe (when measured)
+}
+
+// defaultGoodDomains are widely-deployed names that resolve to A records and are
+// not wildcard zones, suitable as positive/negative resolver probes.
+var defaultGoodDomains = []string{"google.com", "cloudflare.com", "wikipedia.org"}
+
+// ValidateResolvers checks each candidate resolver and returns the subset that
+// passed plus the full per-resolver report. It performs a handful of low-volume
+// queries per resolver (not a mass run), so it uses a simple bounded worker pool.
+func ValidateResolvers(ctx context.Context, cfg ValidateConfig) (good []string, report []ResolverCheck, err error) {
+ if len(cfg.Resolvers) == 0 {
+ return nil, nil, fmt.Errorf("no resolvers to validate")
+ }
+ if cfg.Timeout <= 0 {
+ cfg.Timeout = 3 * time.Second
+ }
+ if cfg.Concurrency <= 0 {
+ cfg.Concurrency = 50
+ }
+ goodDomains := cfg.GoodDomains
+ if len(goodDomains) == 0 {
+ goodDomains = defaultGoodDomains
+ }
+
+ report = make([]ResolverCheck, len(cfg.Resolvers))
+ sem := make(chan struct{}, cfg.Concurrency)
+ var wg sync.WaitGroup
+
+ for i, r := range cfg.Resolvers {
+ select {
+ case <-ctx.Done():
+ return nil, nil, ctx.Err()
+ case sem <- struct{}{}:
+ }
+ wg.Add(1)
+ go func(i int, r string) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ chk := checkResolver(ctx, normalizeResolver(r), goodDomains, cfg.Timeout, cfg.MaxRTT)
+ report[i] = chk
+ if cfg.OnResolver != nil {
+ cfg.OnResolver(chk)
+ }
+ }(i, r)
+ }
+ wg.Wait()
+
+ for _, chk := range report {
+ if chk.OK {
+ good = append(good, chk.Resolver)
+ }
+ }
+ return good, report, nil
+}
+
+// checkResolver runs the liveness, NXDOMAIN-hijack and positive-resolution
+// probes against a single resolver.
+func checkResolver(ctx context.Context, server string, goodDomains []string, timeout, maxRTT time.Duration) ResolverCheck {
+ chk := ResolverCheck{Resolver: server}
+ client := &dns.Client{Timeout: timeout}
+
+ // 1) NXDOMAIN-hijack probe: a random label under a good domain must NOT
+ // resolve to an address. A resolver that answers with an A is lying.
+ probe := randomLabel() + "." + goodDomains[0]
+ if resp, _, err := exchangeA(ctx, client, server, probe); err == nil && resp != nil {
+ if hasAddress(resp) {
+ chk.Reason = "hijacks NXDOMAIN (answers for nonexistent name)"
+ return chk
+ }
+ }
+ // (a transport error here is tolerated; the positive probe is the liveness
+ // gate, since some resolvers drop obviously bogus queries.)
+
+ // 2) positive probe: at least one good domain must resolve to an A.
+ resolvedAny := false
+ for _, d := range goodDomains {
+ start := time.Now()
+ resp, _, err := exchangeA(ctx, client, server, d)
+ if err != nil || resp == nil {
+ continue
+ }
+ if resp.Rcode == dns.RcodeSuccess && hasAddress(resp) {
+ chk.RTT = time.Since(start)
+ resolvedAny = true
+ break
+ }
+ }
+ if !resolvedAny {
+ chk.Reason = "could not resolve any known-good domain"
+ return chk
+ }
+ if maxRTT > 0 && chk.RTT > maxRTT {
+ chk.Reason = fmt.Sprintf("too slow (%s > %s)", chk.RTT.Round(time.Millisecond), maxRTT)
+ return chk
+ }
+
+ chk.OK = true
+ return chk
+}
+
+func exchangeA(ctx context.Context, client *dns.Client, server, name string) (*dns.Msg, time.Duration, error) {
+ m := new(dns.Msg)
+ m.SetQuestion(dns.Fqdn(name), dns.TypeA)
+ m.RecursionDesired = true
+ return client.ExchangeContext(ctx, m, server)
+}
+
+func hasAddress(m *dns.Msg) bool {
+ for _, rr := range m.Answer {
+ switch rr.(type) {
+ case *dns.A, *dns.AAAA:
+ return true
+ }
+ }
+ return false
+}
+
+func randomLabel() string {
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
+ b := make([]byte, 12)
+ for i := range b {
+ b[i] = alphabet[rand.IntN(len(alphabet))]
+ }
+ return string(b)
+}
diff --git a/pkg/resolve/validate_test.go b/pkg/resolve/validate_test.go
new file mode 100644
index 00000000..d930d4db
--- /dev/null
+++ b/pkg/resolve/validate_test.go
@@ -0,0 +1,87 @@
+package resolve
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+// startDNSServer starts a UDP DNS server on loopback with the given handler and
+// returns its address and a stop function.
+func startDNSServer(t *testing.T, handler dns.HandlerFunc) (string, func()) {
+ t.Helper()
+ pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ srv := &dns.Server{PacketConn: pc, Handler: handler}
+ go func() { _ = srv.ActivateAndServe() }()
+ return pc.LocalAddr().String(), func() { _ = srv.Shutdown() }
+}
+
+func TestValidateResolvers(t *testing.T) {
+ // good resolver: resolves good.test, NXDOMAIN for anything else.
+ goodAddr, stopGood := startDNSServer(t, func(w dns.ResponseWriter, r *dns.Msg) {
+ m := new(dns.Msg)
+ m.SetReply(r)
+ q := r.Question[0]
+ if q.Name == "good.test." && q.Qtype == dns.TypeA {
+ rr, _ := dns.NewRR("good.test. 60 IN A 1.2.3.4")
+ m.Answer = append(m.Answer, rr)
+ } else {
+ m.Rcode = dns.RcodeNameError
+ }
+ _ = w.WriteMsg(m)
+ })
+ defer stopGood()
+
+ // hijacking resolver: answers an A for every name (lies about NXDOMAIN).
+ hijackAddr, stopHijack := startDNSServer(t, func(w dns.ResponseWriter, r *dns.Msg) {
+ m := new(dns.Msg)
+ m.SetReply(r)
+ rr, _ := dns.NewRR(r.Question[0].Name + " 60 IN A 6.6.6.6")
+ m.Answer = append(m.Answer, rr)
+ _ = w.WriteMsg(m)
+ })
+ defer stopHijack()
+
+ // dead resolver: nothing listening on this port.
+ deadAddr := "127.0.0.1:1"
+
+ good, report, err := ValidateResolvers(context.Background(), ValidateConfig{
+ Resolvers: []string{goodAddr, hijackAddr, deadAddr},
+ GoodDomains: []string{"good.test"},
+ Timeout: 2 * time.Second,
+ Concurrency: 5,
+ })
+ if err != nil {
+ t.Fatalf("ValidateResolvers: %v", err)
+ }
+ if len(report) != 3 {
+ t.Fatalf("expected 3 reports, got %d", len(report))
+ }
+ if len(good) != 1 || good[0] != goodAddr {
+ t.Fatalf("expected only the good resolver to pass, got %v", good)
+ }
+
+ // verify reasons for the rejected ones
+ byAddr := map[string]ResolverCheck{}
+ for _, c := range report {
+ byAddr[c.Resolver] = c
+ }
+ if c := byAddr[hijackAddr]; c.OK || c.Reason == "" {
+ t.Fatalf("hijacking resolver should be rejected with a reason, got %+v", c)
+ }
+ if c := byAddr[deadAddr]; c.OK {
+ t.Fatalf("dead resolver should be rejected")
+ }
+}
+
+func TestValidateResolversEmpty(t *testing.T) {
+ if _, _, err := ValidateResolvers(context.Background(), ValidateConfig{}); err == nil {
+ t.Fatal("expected error for empty resolver list")
+ }
+}
diff --git a/pkg/runner/options.go b/pkg/runner/options.go
index c2f0b1dd..6d4e8e6e 100644
--- a/pkg/runner/options.go
+++ b/pkg/runner/options.go
@@ -44,6 +44,28 @@ type Options struct {
BatchSize int // BatchSize controls the number of lines per chunk for incremental processing
FilterInternalIPs bool // FilterInternalIPs controls whether to filter out internal/private IP addresses
+ // Native resolver tuning (see pkg/resolve).
+ QueryType string // DNS record type to resolve (A, AAAA, CNAME, ...). Default A.
+ BatchMode string // sendmmsg/recvmmsg batching: off | on | adaptive
+ SocketCount int // UDP sockets per run (0 = scale to cores)
+ UDPSize int // EDNS0 advertised UDP payload size (0 = default 1232; <512 disables)
+ QPS int // outbound query rate limit (0 = unlimited)
+ NoRecurse bool // send non-recursive queries (RD=0)
+ Sticky bool // do not rotate resolver on retry
+ ResolverHealth bool // per-resolver health scoring / de-weighting
+ AdaptiveConcurrency bool // shrink/grow in-flight cap based on packet loss
+ CrossCheck bool // re-verify positive answers on a second resolver
+ ExtendedInput bool // parse "name [resolver ...]" input lines
+ NoVerifyIP bool // disable reply source-IP verification
+ NoTCPFallback bool // disable TCP fallback on truncated answers
+ // Iterative recurses from the root servers (no resolver list needed),
+ // caching delegations; removes the public-resolver dependency.
+ Iterative bool
+
+ // Distributed resolution and resume.
+ Shard string // "m/n": process only shard m of n (distributed coordination)
+ ResumeFile string // checkpoint file for crash-safe stop/resume
+
OnResult func(*retryabledns.DNSData)
}
@@ -59,7 +81,7 @@ func ParseOptions() *Options {
options := &Options{}
flagSet := goflags.NewFlagSet()
- flagSet.SetDescription(`shuffleDNS is a wrapper around massdns written in go that allows you to enumerate valid subdomains using active bruteforce as well as resolve subdomains with wildcard handling and easy input-output support.`)
+ flagSet.SetDescription(`shuffleDNS is a high-throughput DNS bruteforcer and resolver with wildcard handling. It uses a native Go stub resolver (massdns-compatible) and optional iterative resolution from the DNS roots.`)
flagSet.CreateGroup("input", "Input",
flagSet.StringSliceVarP(&options.Domains, "domain", "d", nil, "Domain to find or resolve subdomains for", goflags.FileCommaSeparatedStringSliceOptions),
@@ -68,12 +90,13 @@ func ParseOptions() *Options {
flagSet.StringVarP(&options.Wordlist, "wordlist", "w", "", "File containing words to bruteforce for domain"),
flagSet.StringVarP(&options.ResolversFile, "resolver", "r", "", "File containing list of resolvers for enumeration"),
flagSet.StringVarP(&options.TrustedResolvers, "trusted-resolver", "tr", "", "File containing list of trusted resolvers"),
- flagSet.StringVarP(&options.MassdnsRaw, "raw-input", "ri", "", "Validate raw full massdns output"),
+ flagSet.StringVarP(&options.MassdnsRaw, "raw-input", "ri", "", "Filter wildcards from an existing massdns-format output file"),
flagSet.StringVar(&options.Mode, "mode", "", "Execution mode (bruteforce, resolve, filter)"),
)
flagSet.CreateGroup("rate-limit", "Rate-Limit",
- flagSet.IntVar(&options.Threads, "t", 10000, "Number of concurrent massdns resolves"),
+ flagSet.IntVar(&options.Threads, "t", 10000, "Max concurrent in-flight DNS queries"),
+ flagSet.IntVar(&options.QPS, "qps", 0, "Max outbound DNS queries per second (0 = unlimited)"),
)
flagSet.CreateGroup("update", "Update",
@@ -88,8 +111,8 @@ func ParseOptions() *Options {
)
flagSet.CreateGroup("configs", "Configurations",
- flagSet.StringVarP(&options.MassdnsPath, "massdns", "m", "", "Path to the massdns binary"),
- flagSet.StringVarP(&options.MassDnsCmd, "massdns-cmd", "mcmd", "", "Optional massdns commands to run (example '-i 10')"),
+ flagSet.StringVarP(&options.MassdnsPath, "massdns", "m", "", "Deprecated: massdns is no longer used (native resolver), flag ignored"),
+ flagSet.StringVarP(&options.MassDnsCmd, "massdns-cmd", "mcmd", "", "Deprecated: massdns is no longer used (native resolver), flag ignored"),
flagSet.StringVar(&options.Directory, "directory", "", "Temporary directory for enumeration"),
)
@@ -97,11 +120,32 @@ func ParseOptions() *Options {
flagSet.IntVar(&options.Retries, "retries", 5, "Number of retries for dns enumeration"),
flagSet.BoolVarP(&options.StrictWildcard, "strict-wildcard", "sw", false, "Perform wildcard check on all found subdomains"),
flagSet.IntVar(&options.WildcardThreads, "wt", 250, "Number of concurrent wildcard checks"),
- flagSet.BoolVar(&options.KeepStderr, "retain-stderr", false, "Capture and store massdns stderr output (default: discard)"),
- flagSet.IntVar(&options.BatchSize, "batch-size", DefaultBatchSize, "Number of lines per chunk for incremental processing"),
+ flagSet.BoolVar(&options.KeepStderr, "retain-stderr", false, "Deprecated: massdns is no longer used (native resolver), flag ignored"),
+ flagSet.IntVar(&options.BatchSize, "batch-size", DefaultBatchSize, "Deprecated: native resolver streams without chunking, flag ignored"),
flagSet.BoolVar(&options.FilterInternalIPs, "filter-internal-ips", false, "Filter out internal/private IP addresses (0.0.0.0 is always filtered)"),
)
+ flagSet.CreateGroup("resolver", "Resolver",
+ flagSet.StringVarP(&options.QueryType, "type", "rt", "A", "DNS record type to resolve (A, AAAA, CNAME, NS, PTR, MX, TXT, SOA)"),
+ flagSet.StringVarP(&options.BatchMode, "batch-mode", "bm", "off", "sendmmsg/recvmmsg batching: off | on | adaptive (Linux)"),
+ flagSet.IntVarP(&options.SocketCount, "socket-count", "sc", 0, "UDP sockets per run (0 = scale to cores)"),
+ flagSet.IntVar(&options.UDPSize, "udp-size", 0, "EDNS0 UDP payload size (0 = 1232; <512 disables EDNS0)"),
+ flagSet.BoolVar(&options.NoRecurse, "norecurse", false, "Send non-recursive queries (RD=0)"),
+ flagSet.BoolVar(&options.Sticky, "sticky", false, "Do not rotate resolver on retry"),
+ flagSet.BoolVarP(&options.ResolverHealth, "resolver-health", "rhz", false, "De-weight failing resolvers via health scoring"),
+ flagSet.BoolVarP(&options.AdaptiveConcurrency, "adaptive-concurrency", "acy", false, "Adapt in-flight concurrency to packet loss"),
+ flagSet.BoolVarP(&options.CrossCheck, "cross-check", "cc", false, "Re-verify positive answers on a second resolver"),
+ flagSet.BoolVarP(&options.ExtendedInput, "extended-input", "ei", false, "Parse 'name [resolver ...]' input lines"),
+ flagSet.BoolVar(&options.NoVerifyIP, "no-verify-ip", false, "Disable reply source-IP verification"),
+ flagSet.BoolVar(&options.NoTCPFallback, "no-tcp-fallback", false, "Disable TCP fallback on truncated answers"),
+ flagSet.BoolVarP(&options.Iterative, "iterative", "it", false, "Recurse from root servers (no resolver list needed); caches delegations"),
+ )
+
+ flagSet.CreateGroup("distributed", "Distributed",
+ flagSet.StringVar(&options.Shard, "shard", "", "Process only shard m of n for distributed runs (e.g. 2/8)"),
+ flagSet.StringVarP(&options.ResumeFile, "resume", "rs", "", "Checkpoint file for crash-safe stop/resume"),
+ )
+
flagSet.CreateGroup("debug", "Debug",
flagSet.BoolVar(&options.Silent, "silent", false, "Show only subdomains in output"),
flagSet.BoolVar(&options.Version, "version", false, "Show version of shuffledns"),
diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go
index cd95a580..faaa9ae8 100644
--- a/pkg/runner/runner.go
+++ b/pkg/runner/runner.go
@@ -3,8 +3,8 @@ package runner
import (
"context"
"errors"
+ "fmt"
"os"
- "os/exec"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/shuffledns/pkg/massdns"
@@ -23,16 +23,6 @@ func New(options *Options) (*Runner, error) {
options: options,
}
- // Setup the massdns binary path if none was give.
- // If no valid path found, return an error
- if options.MassdnsPath == "" {
- options.MassdnsPath = runner.findBinary()
- if options.MassdnsPath == "" {
- return nil, errors.New("could not find massdns binary")
- }
- gologger.Debug().Msgf("Discovered massdns binary at %s\n", options.MassdnsPath)
- }
-
// Create a temporary directory that will be removed at the end
// of enumeration process.
dir, err := os.MkdirTemp(options.Directory, "shuffledns-*")
@@ -49,71 +39,14 @@ func (r *Runner) Close() {
_ = os.RemoveAll(r.tempDir)
}
-// findBinary searches for massdns binary in various pre-defined paths
-// only linux and macos paths are supported rn
-func (r *Runner) findBinary() string {
- otherCommonLocations := []string{
- "/usr/bin/massdns",
- "/usr/local/bin/massdns",
- "/data/data/com.termux/files/usr/bin/massdns",
- }
-
- for _, file := range otherCommonLocations {
- if fileutil.FileExists(file) {
- return file
- }
- }
-
- file, err := exec.LookPath("massdns")
- if err != nil {
- return ""
- }
-
- return file
-}
-
-// RunEnumeration sets up the input layer for giving input to massdns
-// binary and runs the actual enumeration
-func (r *Runner) RunEnumeration() {
- // Handle only wildcard filtering on existing massdns output
- if r.options.MassdnsRaw != "" {
- r.processExistingOutput()
- return
- }
-
- // Handle a domain to bruteforce with wordlist
- if r.options.Wordlist != "" {
- r.processDomain()
- return
- }
-
- // Handle a list of subdomains to resolve
- if r.options.SubdomainsList != "" || fileutil.HasStdin() {
- r.processSubdomains()
- return
- }
-}
-
-// processDomain processes the bruteforce for a domain using a wordlist
-func (r *Runner) processDomain() {
- // Read the input wordlist for bruteforce generation
- inputFile, err := os.Open(r.options.Wordlist)
- if err != nil {
- gologger.Error().Msgf("Could not read bruteforce wordlist (%s): %s\n", r.options.Wordlist, err)
- return
- }
- defer func() {
- _ = inputFile.Close()
- }()
-
- gologger.Info().Msgf("Started generating bruteforce permutation with streaming processing\n")
-
- // Create massdns instance for processing chunks
- massdns, err := massdns.New(massdns.Options{
+// massdnsOptions builds the massdns.Options from the runner options. Keeping it
+// in one place avoids drift between the bruteforce, resolve and raw-input paths
+// and makes the embeddable surface easy to reason about.
+func (r *Runner) massdnsOptions() massdns.Options {
+ return massdns.Options{
Domains: r.options.Domains,
AutoExtractRootDomains: r.options.AutoExtractRootDomains,
Retries: r.options.Retries,
- MassdnsPath: r.options.MassdnsPath,
Threads: r.options.Threads,
WildcardsThreads: r.options.WildcardThreads,
ResolversFile: r.options.ResolversFile,
@@ -124,132 +57,120 @@ func (r *Runner) processDomain() {
MassdnsRaw: r.options.MassdnsRaw,
StrictWildcard: r.options.StrictWildcard,
WildcardOutputFile: r.options.WildcardOutputFile,
- MassDnsCmd: r.options.MassDnsCmd,
- KeepStderr: r.options.KeepStderr,
- BatchSize: r.options.BatchSize,
FilterInternalIPs: r.options.FilterInternalIPs,
+ QueryType: r.options.QueryType,
+ BatchMode: r.options.BatchMode,
+ SocketCount: r.options.SocketCount,
+ UDPSize: r.options.UDPSize,
+ QPS: r.options.QPS,
+ NoRecurse: r.options.NoRecurse,
+ Sticky: r.options.Sticky,
+ ResolverHealth: r.options.ResolverHealth,
+ AdaptiveConcurrency: r.options.AdaptiveConcurrency,
+ CrossCheck: r.options.CrossCheck,
+ ExtendedInput: r.options.ExtendedInput,
+ NoVerifyIP: r.options.NoVerifyIP,
+ NoTCPFallback: r.options.NoTCPFallback,
+ Iterative: r.options.Iterative,
+ Shard: r.options.Shard,
+ ResumeFile: r.options.ResumeFile,
OnResult: r.options.OnResult,
- })
+ }
+}
+
+// RunEnumeration sets up the input layer for giving input to the native
+// resolver and runs the actual enumeration. It returns an error so the process
+// is fully embeddable (the CLI is responsible for logging/exit codes).
+func (r *Runner) RunEnumeration() error {
+ switch {
+ case r.options.MassdnsRaw != "":
+ return r.processExistingOutput()
+ case r.options.Wordlist != "":
+ return r.processDomain()
+ case r.options.SubdomainsList != "" || fileutil.HasStdin():
+ return r.processSubdomains()
+ default:
+ return errors.New("no input provided: set a wordlist, a subdomains list, stdin, or raw massdns input")
+ }
+}
+
+// dumpWildcards writes the discovered wildcard IPs when requested.
+func (r *Runner) dumpWildcards(instance *massdns.Instance) {
+ if r.options.WildcardOutputFile != "" {
+ _ = instance.DumpWildcardsToFile(r.options.WildcardOutputFile)
+ }
+}
+
+// processDomain processes the bruteforce for a domain using a wordlist
+func (r *Runner) processDomain() error {
+ inputFile, err := os.Open(r.options.Wordlist)
if err != nil {
- gologger.Error().Msgf("Could not create massdns client: %s\n", err)
- return
+ return fmt.Errorf("could not read bruteforce wordlist (%s): %w", r.options.Wordlist, err)
}
+ defer func() {
+ _ = inputFile.Close()
+ }()
+
+ gologger.Info().Msgf("Started generating bruteforce permutation with streaming processing\n")
- // Use streaming processing with batcher
- err = massdns.ProcessDomainStreaming(context.Background(), inputFile)
+ instance, err := massdns.New(r.massdnsOptions())
if err != nil {
- gologger.Error().Msgf("Could not process domain with streaming: %s\n", err)
- return
+ return fmt.Errorf("could not create massdns client: %w", err)
}
- if r.options.WildcardOutputFile != "" {
- _ = massdns.DumpWildcardsToFile(r.options.WildcardOutputFile)
+ if err := instance.ProcessDomainStreaming(context.Background(), inputFile); err != nil {
+ return fmt.Errorf("could not process domain with streaming: %w", err)
}
+ r.dumpWildcards(instance)
gologger.Info().Msgf("Finished resolving.\n")
+ return nil
}
-// processSubdomain processes the resolving for a list of subdomains
-func (r *Runner) processSubdomains() {
- // Create massdns instance for processing chunks
- massdns, err := massdns.New(massdns.Options{
- Domains: r.options.Domains,
- AutoExtractRootDomains: r.options.AutoExtractRootDomains,
- Retries: r.options.Retries,
- MassdnsPath: r.options.MassdnsPath,
- Threads: r.options.Threads,
- WildcardsThreads: r.options.WildcardThreads,
- ResolversFile: r.options.ResolversFile,
- TrustedResolvers: r.options.TrustedResolvers,
- TempDir: r.tempDir,
- OutputFile: r.options.Output,
- Json: r.options.Json,
- MassdnsRaw: r.options.MassdnsRaw,
- StrictWildcard: r.options.StrictWildcard,
- WildcardOutputFile: r.options.WildcardOutputFile,
- MassDnsCmd: r.options.MassDnsCmd,
- KeepStderr: r.options.KeepStderr,
- BatchSize: r.options.BatchSize,
- FilterInternalIPs: r.options.FilterInternalIPs,
- OnResult: r.options.OnResult,
- })
+// processSubdomains processes the resolving for a list of subdomains
+func (r *Runner) processSubdomains() error {
+ instance, err := massdns.New(r.massdnsOptions())
if err != nil {
- gologger.Error().Msgf("Could not create massdns client: %s\n", err)
- return
+ return fmt.Errorf("could not create massdns client: %w", err)
}
- // Handle stdin or file input
if fileutil.HasStdin() && r.options.SubdomainsList == "" {
- // Use streaming processing for stdin
gologger.Info().Msgf("Processing subdomains from stdin with streaming\n")
- err = massdns.ProcessSubdomainsStreaming(context.Background(), os.Stdin)
- if err != nil {
- gologger.Error().Msgf("Could not process subdomains with streaming: %s\n", err)
- return
+ if err := instance.ProcessSubdomainsStreaming(context.Background(), os.Stdin); err != nil {
+ return fmt.Errorf("could not process subdomains from stdin: %w", err)
}
} else {
- // Use streaming processing for file
subdomainFile, err := os.Open(r.options.SubdomainsList)
if err != nil {
- gologger.Error().Msgf("Could not open subdomain list (%s): %s\n", r.options.SubdomainsList, err)
- return
+ return fmt.Errorf("could not open subdomain list (%s): %w", r.options.SubdomainsList, err)
}
defer func() {
_ = subdomainFile.Close()
}()
gologger.Info().Msgf("Processing subdomains from file with streaming\n")
- err = massdns.ProcessSubdomainsStreaming(context.Background(), subdomainFile)
- if err != nil {
- gologger.Error().Msgf("Could not process subdomains with streaming from file: %s\n", err)
- return
+ if err := instance.ProcessSubdomainsStreaming(context.Background(), subdomainFile); err != nil {
+ return fmt.Errorf("could not process subdomains from file: %w", err)
}
}
- if r.options.WildcardOutputFile != "" {
- _ = massdns.DumpWildcardsToFile(r.options.WildcardOutputFile)
- }
-
+ r.dumpWildcards(instance)
gologger.Info().Msgf("Finished resolving.\n")
+ return nil
}
// processExistingOutput processes existing massdns output for wildcard filtering
-func (r *Runner) processExistingOutput() {
- massdns, err := massdns.New(massdns.Options{
- Domains: r.options.Domains,
- AutoExtractRootDomains: r.options.AutoExtractRootDomains,
- Retries: r.options.Retries,
- MassdnsPath: r.options.MassdnsPath,
- Threads: r.options.Threads,
- WildcardsThreads: r.options.WildcardThreads,
- ResolversFile: r.options.ResolversFile,
- TrustedResolvers: r.options.TrustedResolvers,
- TempDir: r.tempDir,
- OutputFile: r.options.Output,
- Json: r.options.Json,
- MassdnsRaw: r.options.MassdnsRaw,
- StrictWildcard: r.options.StrictWildcard,
- WildcardOutputFile: r.options.WildcardOutputFile,
- MassDnsCmd: r.options.MassDnsCmd,
- KeepStderr: r.options.KeepStderr,
- BatchSize: r.options.BatchSize,
- FilterInternalIPs: r.options.FilterInternalIPs,
- OnResult: r.options.OnResult,
- })
+func (r *Runner) processExistingOutput() error {
+ instance, err := massdns.New(r.massdnsOptions())
if err != nil {
- gologger.Error().Msgf("Could not create massdns client: %s\n", err)
- return
+ return fmt.Errorf("could not create massdns client: %w", err)
}
- err = massdns.Run(context.Background())
- if err != nil {
- gologger.Error().Msgf("Could not process existing massdns output: %s\n", err)
- return
- }
-
- if r.options.WildcardOutputFile != "" {
- _ = massdns.DumpWildcardsToFile(r.options.WildcardOutputFile)
+ if err := instance.Run(context.Background()); err != nil {
+ return fmt.Errorf("could not process existing massdns output: %w", err)
}
+ r.dumpWildcards(instance)
gologger.Info().Msgf("Finished processing existing output.\n")
+ return nil
}
diff --git a/pkg/runner/validate.go b/pkg/runner/validate.go
index 27813d9a..0fb603c2 100644
--- a/pkg/runner/validate.go
+++ b/pkg/runner/validate.go
@@ -17,18 +17,19 @@ func (options *Options) validateOptions() error {
return errors.New("both verbose and silent mode specified")
}
- // Check if a list of resolvers was provided and it exists
- if !fileutil.FileExists(options.ResolversFile) {
- return errors.New("resolver file doesn't exists")
- }
-
- // Check if resolvers are blank
- if blank, err := fileutil.IsEmpty(options.ResolversFile); err == nil {
- if blank {
- return errors.New("empty resolver list specified")
+ // Iterative mode recurses from the root servers and needs no resolver list.
+ // For the stub-resolver path, a non-empty resolver file is required.
+ if !options.Iterative {
+ if !fileutil.FileExists(options.ResolversFile) {
+ return errors.New("resolver file doesn't exists")
+ }
+ if blank, err := fileutil.IsEmpty(options.ResolversFile); err == nil {
+ if blank {
+ return errors.New("empty resolver list specified")
+ }
+ } else {
+ return fmt.Errorf("could not read resolvers: %w", err)
}
- } else {
- return fmt.Errorf("could not read resolvers: %w", err)
}
switch options.Mode {
diff --git a/pkg/shard/shard.go b/pkg/shard/shard.go
new file mode 100644
index 00000000..90a97182
--- /dev/null
+++ b/pkg/shard/shard.go
@@ -0,0 +1,94 @@
+// Package shard implements deterministic workload sharding for distributed,
+// coordinated resolution. Running N instances each with a distinct shard
+// (1/N .. N/N) over the same input partitions the work into disjoint subsets
+// with no central coordinator: every instance independently decides which names
+// it owns via a stable hash, so the union covers the input exactly once.
+//
+// This mirrors masscan-style sharding but for DNS names: because names cannot be
+// split arithmetically like an address range, each instance reads the full input
+// and cheaply hashes every candidate to decide ownership.
+package shard
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// Shard identifies one partition (Index) out of Total. Index is 1-based and in
+// the range [1, Total], matching the "m/n" convention.
+type Shard struct {
+ Index int
+ Total int
+}
+
+// Parse parses an "m/n" shard specification (e.g. "2/8"). An empty string or
+// "1/1" yields a disabled shard that owns everything.
+func Parse(s string) (Shard, error) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return Shard{Index: 1, Total: 1}, nil
+ }
+ parts := strings.SplitN(s, "/", 2)
+ if len(parts) != 2 {
+ return Shard{}, fmt.Errorf("invalid shard %q: expected m/n", s)
+ }
+ idx, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
+ total, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
+ if err1 != nil || err2 != nil {
+ return Shard{}, fmt.Errorf("invalid shard %q: m and n must be integers", s)
+ }
+ sh := Shard{Index: idx, Total: total}
+ if err := sh.Validate(); err != nil {
+ return Shard{}, err
+ }
+ return sh, nil
+}
+
+// Validate checks the shard is internally consistent.
+func (s Shard) Validate() error {
+ if s.Total < 1 {
+ return fmt.Errorf("shard total must be >= 1, got %d", s.Total)
+ }
+ if s.Index < 1 || s.Index > s.Total {
+ return fmt.Errorf("shard index %d out of range [1,%d]", s.Index, s.Total)
+ }
+ return nil
+}
+
+// Enabled reports whether sharding actually partitions the input (Total > 1).
+func (s Shard) Enabled() bool { return s.Total > 1 }
+
+// Owns reports whether this shard is responsible for key. When sharding is
+// disabled it always returns true. The hash is a stable FNV-1a over the
+// lower-cased key, so the partitioning is identical across machines, runs and
+// architectures.
+func (s Shard) Owns(key string) bool {
+ if !s.Enabled() {
+ return true
+ }
+ return Bucket(key, s.Total) == s.Index-1
+}
+
+// Bucket returns the stable bucket index in [0, total) for key, using FNV-1a
+// over the lower-cased key. Implemented inline (no hash.Hash allocation) so it
+// stays cheap when called once per candidate name.
+func Bucket(key string, total int) int {
+ if total <= 1 {
+ return 0
+ }
+ const (
+ offset64 = 14695981039346656037 // FNV-1a 64-bit offset basis
+ prime64 = 1099511628211 // FNV-1a 64-bit prime
+ )
+ var h uint64 = offset64
+ for i := 0; i < len(key); i++ {
+ c := key[i]
+ if c >= 'A' && c <= 'Z' {
+ c += 'a' - 'A'
+ }
+ h ^= uint64(c)
+ h *= prime64
+ }
+ return int(h % uint64(total))
+}
diff --git a/pkg/shard/shard_test.go b/pkg/shard/shard_test.go
new file mode 100644
index 00000000..db23ee8e
--- /dev/null
+++ b/pkg/shard/shard_test.go
@@ -0,0 +1,95 @@
+package shard
+
+import (
+ "fmt"
+ "testing"
+)
+
+func TestParse(t *testing.T) {
+ cases := map[string]struct {
+ idx, total int
+ err bool
+ }{
+ "": {1, 1, false},
+ "1/1": {1, 1, false},
+ "2/8": {2, 8, false},
+ "8/8": {8, 8, false},
+ "0/8": {0, 0, true},
+ "9/8": {0, 0, true},
+ "2/0": {0, 0, true},
+ "x/8": {0, 0, true},
+ "2": {0, 0, true},
+ "2/3/4": {0, 0, true},
+ }
+ for in, want := range cases {
+ got, err := Parse(in)
+ if want.err {
+ if err == nil {
+ t.Errorf("Parse(%q): expected error", in)
+ }
+ continue
+ }
+ if err != nil {
+ t.Errorf("Parse(%q): unexpected error %v", in, err)
+ continue
+ }
+ if got.Index != want.idx || got.Total != want.total {
+ t.Errorf("Parse(%q): got %d/%d want %d/%d", in, got.Index, got.Total, want.idx, want.total)
+ }
+ }
+}
+
+func TestEnabled(t *testing.T) {
+ if (Shard{1, 1}).Enabled() {
+ t.Fatal("1/1 should be disabled")
+ }
+ if !(Shard{1, 4}).Enabled() {
+ t.Fatal("1/4 should be enabled")
+ }
+}
+
+func TestDisabledOwnsEverything(t *testing.T) {
+ s := Shard{1, 1}
+ for i := 0; i < 100; i++ {
+ if !s.Owns(fmt.Sprintf("host%d.example.com", i)) {
+ t.Fatal("disabled shard must own every key")
+ }
+ }
+}
+
+// TestPartitionExactlyOnce is the core guarantee: across N shards every name is
+// owned by exactly one shard.
+func TestPartitionExactlyOnce(t *testing.T) {
+ const total = 8
+ shards := make([]Shard, total)
+ for i := range shards {
+ shards[i] = Shard{Index: i + 1, Total: total}
+ }
+ counts := make([]int, total)
+ for i := 0; i < 10000; i++ {
+ name := fmt.Sprintf("sub-%d.target.com", i)
+ owners := 0
+ for j, s := range shards {
+ if s.Owns(name) {
+ owners++
+ counts[j]++
+ }
+ }
+ if owners != 1 {
+ t.Fatalf("name %q owned by %d shards, want 1", name, owners)
+ }
+ }
+ // sanity: distribution should be roughly even (no empty shard)
+ for j, c := range counts {
+ if c == 0 {
+ t.Fatalf("shard %d got no names (bad distribution)", j+1)
+ }
+ }
+}
+
+func TestStableAcrossCase(t *testing.T) {
+ s := Shard{Index: 3, Total: 8}
+ if s.Owns("Host.EXAMPLE.com") != s.Owns("host.example.com") {
+ t.Fatal("ownership must be case-insensitive/stable")
+ }
+}
diff --git a/pkg/store/store.go b/pkg/store/store.go
index c6c1aed4..607b5999 100644
--- a/pkg/store/store.go
+++ b/pkg/store/store.go
@@ -1,129 +1,135 @@
+// Package store provides an in-memory ip -> hostnames index used for
+// deduplication and wildcard removal.
+//
+// It previously persisted to LevelDB on disk (with a JSON marshal and
+// read-modify-write on every append plus background compaction). The native
+// resolver streams structured results in-process, so an in-memory map is both
+// simpler and considerably faster; the public API is kept stable.
package store
import (
- "encoding/json"
- "os"
- "strings"
-
- mapsutil "github.com/projectdiscovery/utils/maps"
- "github.com/syndtr/goleveldb/leveldb"
- "github.com/syndtr/goleveldb/leveldb/opt"
+ "sort"
+ "sync"
)
-const Megabyte = 1 << 20
-
-// Store is a storage for ip based wildcard removal
+// Store is an in-memory storage for ip based deduplication and wildcard removal.
type Store struct {
- DB *leveldb.DB
+ mu sync.RWMutex
+ data map[string]map[string]struct{}
}
-// New creates a new storage for ip based wildcard removal
-func New(dbPath string) (*Store, error) {
- storeDb, err := os.MkdirTemp(dbPath, "shuffledns-db-")
- if err != nil {
- return nil, err
- }
- db, err := leveldb.OpenFile(storeDb, &opt.Options{
- // Optimize for disk space reduction
- CompactionTableSize: 64 * Megabyte, // Reduced from 256MB for more frequent compaction
- WriteBuffer: 2 * Megabyte, // Reduced from 4MB for more frequent flushing
- WriteL0SlowdownTrigger: 4, // Trigger slowdown earlier
- WriteL0PauseTrigger: 8, // Trigger pause earlier
- BlockSize: 2 * 1024, // Reduced from 4KB for better compression of small records
- BlockCacheCapacity: 4 * Megabyte, // Reduced from 8MB to lower memory usage
- })
- if err != nil {
- return nil, err
- }
- return &Store{DB: db}, nil
+// New creates a new in-memory store. The path argument is accepted for API
+// compatibility and ignored.
+func New(_ string) (*Store, error) {
+ return &Store{data: make(map[string]map[string]struct{})}, nil
}
-// New creates a new ip-hostname pair in the map
+// New creates a new ip-hostname pair in the map.
func (s *Store) New(ip, hostname string) error {
- hostnameMap := map[string]struct{}{hostname: {}}
- jsonData, err := json.Marshal(hostnameMap)
- if err != nil {
- return err
- }
- return s.DB.Put([]byte(ip), jsonData, nil)
+ return s.Append(ip, hostname)
}
-// Exists indicates if an IP exists in the map
+// Exists indicates if an IP exists in the map.
func (s *Store) Exists(ip string) bool {
- ok, err := s.DB.Has([]byte(ip), nil)
- return err == nil && ok
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ _, ok := s.data[ip]
+ return ok
}
-// Get gets the meta-information for an IP address from the map.
+// GetHostnames returns the comma separated hostnames stored for an IP.
func (s *Store) GetHostnames(ip string) string {
- data, err := s.DB.Get([]byte(ip), nil)
- if err != nil {
- return ""
- }
+ s.mu.RLock()
+ defer s.mu.RUnlock()
- var hostnameMap map[string]struct{}
- if err := json.Unmarshal(data, &hostnameMap); err != nil {
+ hostnameMap, ok := s.data[ip]
+ if !ok {
return ""
}
-
- return strings.Join(mapsutil.GetKeys(hostnameMap), ",")
+ hostnames := make([]string, 0, len(hostnameMap))
+ for hostname := range hostnameMap {
+ hostnames = append(hostnames, hostname)
+ }
+ sort.Strings(hostnames)
+ return joinComma(hostnames)
}
+// Append adds one or more hostnames to an IP, deduplicating automatically.
func (s *Store) Append(ip string, hostnames ...string) error {
- // Get existing hostnames
- var hostnameMap map[string]struct{}
- existingData, err := s.DB.Get([]byte(ip), nil)
- if err == nil && len(existingData) > 0 {
- if err := json.Unmarshal(existingData, &hostnameMap); err != nil {
- // If unmarshaling fails, start with empty map
- hostnameMap = make(map[string]struct{})
- }
- } else {
- hostnameMap = make(map[string]struct{})
- }
+ s.mu.Lock()
+ defer s.mu.Unlock()
- // Add new hostnames to map (automatic deduplication)
+ hostnameMap, ok := s.data[ip]
+ if !ok {
+ hostnameMap = make(map[string]struct{}, len(hostnames))
+ s.data[ip] = hostnameMap
+ }
for _, hostname := range hostnames {
hostnameMap[hostname] = struct{}{}
}
-
- // Marshal and store
- jsonData, err := json.Marshal(hostnameMap)
- if err != nil {
- return err
- }
-
- return s.DB.Put([]byte(ip), jsonData, nil)
+ return nil
}
-// Delete deletes the records for an IP from store.
+// Delete removes the records for an IP from the store.
func (s *Store) Delete(ip string) error {
- return s.DB.Delete([]byte(ip), nil)
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.data, ip)
+ return nil
}
+// Close releases all resources held by the store.
func (s *Store) Close() {
- _ = s.DB.Close()
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.data = nil
}
+// Iterate walks every ip and its hostnames. counter is the number of distinct
+// hostnames pointing at the ip (used by the wildcard heuristic).
func (s *Store) Iterate(f func(ip string, hostnames []string, counter int)) {
- iter := s.DB.NewIterator(nil, nil)
- defer iter.Release()
-
- for iter.Next() {
- ip := string(iter.Key())
-
- var hostnameMap map[string]struct{}
- if err := json.Unmarshal(iter.Value(), &hostnameMap); err != nil {
- continue
- }
-
- // Convert map keys to slice
+ // snapshot under lock to avoid holding it during the callback (which may
+ // perform network I/O during wildcard filtering)
+ s.mu.RLock()
+ ips := make([]string, 0, len(s.data))
+ snapshot := make(map[string][]string, len(s.data))
+ for ip, hostnameMap := range s.data {
hostnames := make([]string, 0, len(hostnameMap))
for hostname := range hostnameMap {
hostnames = append(hostnames, hostname)
}
+ sort.Strings(hostnames)
+ snapshot[ip] = hostnames
+ ips = append(ips, ip)
+ }
+ s.mu.RUnlock()
+
+ // Iterate in sorted order so output is deterministic across runs (the map
+ // backing replaced a LevelDB store that iterated in sorted key order).
+ sort.Strings(ips)
+ for _, ip := range ips {
+ hostnames := snapshot[ip]
+ f(ip, hostnames, len(hostnames))
+ }
+}
- counter := len(hostnames)
- f(ip, hostnames, counter)
+func joinComma(values []string) string {
+ switch len(values) {
+ case 0:
+ return ""
+ case 1:
+ return values[0]
+ }
+ n := len(values) - 1
+ for _, v := range values {
+ n += len(v)
+ }
+ out := make([]byte, 0, n)
+ for i, v := range values {
+ if i > 0 {
+ out = append(out, ',')
+ }
+ out = append(out, v...)
}
+ return string(out)
}
diff --git a/pkg/zonewalk/nsec3.go b/pkg/zonewalk/nsec3.go
new file mode 100644
index 00000000..5f855efb
--- /dev/null
+++ b/pkg/zonewalk/nsec3.go
@@ -0,0 +1,178 @@
+package zonewalk
+
+import (
+ "context"
+ "fmt"
+ "math/rand/v2"
+ "strings"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+// maxNSEC3Iterations caps the SHA-1 iteration count we are willing to process.
+// dns.HashName runs iter extra SHA-1 rounds per hash and the target controls
+// iter (up to 65535); crack does len(candidates)*len(harvested) hashes, so a
+// hostile zone advertising a huge count could burn billions of SHA-1 ops.
+// RFC 9276 treats anything above a small number as unreasonable.
+const maxNSEC3Iterations = 500
+
+// CrackConfig controls an NSEC3 harvest-and-crack.
+type CrackConfig struct {
+ // Zone is the NSEC3-signed apex to enumerate (e.g. "example.com").
+ Zone string
+ // Resolvers are DNSSEC-aware resolvers (host or host:port); the first is used.
+ Resolvers []string
+ // Timeout is the per-query timeout. Default 5s.
+ Timeout time.Duration
+ // MaxQueries bounds the harvest phase (number of probe queries). Default 2000.
+ MaxQueries int
+ // Candidates is the wordlist of labels to crack against the harvested hash
+ // ring (e.g. "www", "mail"); each is hashed and matched offline.
+ Candidates []string
+ // OnName fires for each recovered (cracked) name.
+ OnName func(string)
+}
+
+// CrackResult is the outcome of an NSEC3 harvest-and-crack.
+type CrackResult struct {
+ Zone string
+ Salt string
+ Iterations uint16
+ HashAlg uint8
+ HarvestedHashes int // distinct NSEC3 records collected (existing-name hashes)
+ Names []string // recovered existing names (no trailing dot)
+ Saturated bool // harvest reached saturation (ring likely complete)
+}
+
+// CrackNSEC3 enumerates an NSEC3-signed zone offline-style: it first harvests
+// the zone's NSEC3 records by probing for non-existent names (each NXDOMAIN
+// proof leaks NSEC3 records that cover existing-name hashes), walking until the
+// hash ring saturates, then cracks the supplied wordlist by matching each
+// candidate's hash against the harvested records. Only true positives are
+// reported. Completeness depends on harvest coverage and the wordlist; this is
+// the standard nsec3walker approach.
+func CrackNSEC3(ctx context.Context, cfg CrackConfig) (*CrackResult, error) {
+ if strings.TrimSpace(cfg.Zone) == "" {
+ return nil, fmt.Errorf("zone is required")
+ }
+ if len(cfg.Resolvers) == 0 {
+ return nil, fmt.Errorf("at least one resolver is required")
+ }
+ if cfg.Timeout <= 0 {
+ cfg.Timeout = 5 * time.Second
+ }
+ if cfg.MaxQueries <= 0 {
+ cfg.MaxQueries = 2000
+ }
+ server := normalize(cfg.Resolvers[0])
+ apex := dns.Fqdn(strings.ToLower(cfg.Zone))
+ client := &dns.Client{Timeout: cfg.Timeout}
+
+ res := &CrackResult{Zone: cfg.Zone}
+
+ // harvested NSEC3 records, deduplicated by owner name.
+ harvested := map[string]*dns.NSEC3{}
+ collect := func(resp *dns.Msg) int {
+ added := 0
+ for _, rr := range append(append([]dns.RR{}, resp.Answer...), resp.Ns...) {
+ n3, ok := rr.(*dns.NSEC3)
+ if !ok {
+ continue
+ }
+ key := strings.ToLower(n3.Hdr.Name)
+ if _, dup := harvested[key]; !dup {
+ harvested[key] = n3
+ added++
+ if res.Salt == "" && res.Iterations == 0 {
+ res.Salt, res.Iterations, res.HashAlg = n3.Salt, n3.Iterations, n3.Hash
+ }
+ }
+ }
+ return added
+ }
+
+ covered := func(name string) bool {
+ for _, n3 := range harvested {
+ if n3.Cover(name) || n3.Match(name) {
+ return true
+ }
+ }
+ return false
+ }
+
+ // harvest: probe random non-existent names; each proof extends ring coverage.
+ const saturation = 64 // consecutive already-covered probes => ring ~complete
+ miss := 0
+ for q := 0; q < cfg.MaxQueries; q++ {
+ if err := ctx.Err(); err != nil {
+ break
+ }
+ probe := randomLabel() + "." + apex
+ if len(harvested) > 0 && covered(probe) {
+ miss++
+ if miss >= saturation {
+ res.Saturated = true
+ break
+ }
+ continue
+ }
+ miss = 0
+ resp, err := queryDO(ctx, client, server, probe, dns.TypeA)
+ if err != nil {
+ continue
+ }
+ collect(resp)
+ // Bail before any expensive Cover/Match on the next iteration if the zone
+ // advertises an abusive iteration count.
+ if res.Iterations > maxNSEC3Iterations {
+ res.HarvestedHashes = len(harvested)
+ return res, fmt.Errorf("nsec3 iterations %d exceed cap %d; refusing to crack (DoS risk)", res.Iterations, maxNSEC3Iterations)
+ }
+ }
+ res.HarvestedHashes = len(harvested)
+
+ // crack: match each candidate's hash against the harvested ring.
+ seen := map[string]struct{}{}
+ for _, label := range cfg.Candidates {
+ label = strings.TrimSpace(strings.ToLower(label))
+ if label == "" {
+ continue
+ }
+ name := label + "." + apex
+ for _, n3 := range harvested {
+ if n3.Match(name) {
+ owner := strings.TrimSuffix(name, ".")
+ if _, dup := seen[owner]; !dup {
+ seen[owner] = struct{}{}
+ res.Names = append(res.Names, owner)
+ if cfg.OnName != nil {
+ cfg.OnName(owner)
+ }
+ }
+ break
+ }
+ }
+ }
+ return res, nil
+}
+
+func queryDO(ctx context.Context, client *dns.Client, server, name string, qtype uint16) (*dns.Msg, error) {
+ m := new(dns.Msg)
+ m.SetQuestion(dns.Fqdn(name), qtype)
+ m.RecursionDesired = true
+ m.SetEdns0(4096, true) // DO bit
+ resp, _, err := client.ExchangeContext(ctx, m, server)
+ return resp, err
+}
+
+const labelAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
+
+func randomLabel() string {
+ n := 10 + rand.IntN(6)
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = labelAlphabet[rand.IntN(len(labelAlphabet))]
+ }
+ return string(b)
+}
diff --git a/pkg/zonewalk/nsec3_test.go b/pkg/zonewalk/nsec3_test.go
new file mode 100644
index 00000000..e8de70a8
--- /dev/null
+++ b/pkg/zonewalk/nsec3_test.go
@@ -0,0 +1,94 @@
+package zonewalk
+
+import (
+ "context"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/miekg/dns"
+)
+
+// buildNSEC3Ring builds a synthetic NSEC3 ring for the given existing names
+// under apex, using the supplied salt/iterations. Returns the ring records.
+func buildNSEC3Ring(t *testing.T, apex, salt string, iter uint16, names []string) []*dns.NSEC3 {
+ t.Helper()
+ type he struct {
+ name, hash string
+ }
+ var hs []he
+ for _, n := range names {
+ fq := dns.Fqdn(strings.ToLower(n))
+ hs = append(hs, he{fq, dns.HashName(fq, dns.SHA1, iter, salt)})
+ }
+ sort.Slice(hs, func(i, j int) bool { return hs[i].hash < hs[j].hash })
+
+ var ring []*dns.NSEC3
+ for i, h := range hs {
+ next := hs[(i+1)%len(hs)].hash
+ ring = append(ring, &dns.NSEC3{
+ Hdr: dns.RR_Header{Name: h.hash + "." + apex, Rrtype: dns.TypeNSEC3, Class: dns.ClassINET, Ttl: 60},
+ Hash: dns.SHA1,
+ Flags: 0,
+ Iterations: iter,
+ SaltLength: uint8(len(salt) / 2),
+ Salt: salt,
+ HashLength: 20,
+ NextDomain: next,
+ TypeBitMap: []uint16{dns.TypeA, dns.TypeRRSIG},
+ })
+ }
+ return ring
+}
+
+func TestCrackNSEC3(t *testing.T) {
+ apex := "example.com."
+ salt := "deadbeef"
+ var iter uint16 = 5
+ // existing names in the zone (the apex is part of the ring too).
+ existing := []string{"example.com.", "www.example.com.", "mail.example.com.", "ftp.example.com."}
+ ring := buildNSEC3Ring(t, apex, salt, iter, existing)
+
+ addr, stop := startServer(t, func(w dns.ResponseWriter, r *dns.Msg) {
+ m := new(dns.Msg)
+ m.SetReply(r)
+ m.Rcode = dns.RcodeNameError
+ // leak the full NSEC3 ring on every NXDOMAIN proof (a permissive but
+ // valid model: real servers leak the covering subset per query).
+ for _, n3 := range ring {
+ m.Ns = append(m.Ns, n3)
+ }
+ _ = w.WriteMsg(m)
+ })
+ defer stop()
+
+ var recovered []string
+ res, err := CrackNSEC3(context.Background(), CrackConfig{
+ Zone: "example.com",
+ Resolvers: []string{addr},
+ MaxQueries: 50,
+ Candidates: []string{"www", "mail", "ftp", "doesnotexist", "admin"},
+ OnName: func(n string) { recovered = append(recovered, n) },
+ })
+ if err != nil {
+ t.Fatalf("CrackNSEC3: %v", err)
+ }
+ if res.HarvestedHashes != len(ring) {
+ t.Fatalf("expected %d harvested NSEC3 records, got %d", len(ring), res.HarvestedHashes)
+ }
+ got := map[string]bool{}
+ for _, n := range res.Names {
+ got[n] = true
+ }
+ for _, want := range []string{"www.example.com", "mail.example.com", "ftp.example.com"} {
+ if !got[want] {
+ t.Errorf("expected to crack %q, recovered %v", want, res.Names)
+ }
+ }
+ if got["doesnotexist.example.com"] || got["admin.example.com"] {
+ t.Errorf("cracked a non-existent name (false positive): %v", res.Names)
+ }
+ if len(recovered) != len(res.Names) {
+ t.Errorf("OnName fired %d times, result has %d", len(recovered), len(res.Names))
+ }
+}
diff --git a/pkg/zonewalk/zonewalk.go b/pkg/zonewalk/zonewalk.go
new file mode 100644
index 00000000..a4a73350
--- /dev/null
+++ b/pkg/zonewalk/zonewalk.go
@@ -0,0 +1,171 @@
+// Package zonewalk enumerates the contents of a DNS zone by following its NSEC
+// chain (RFC 4034 §4). Zones signed with NSEC leak their full list of names: each
+// NSEC record points to the next name in canonical order, so walking the chain
+// from the apex recovers every owner name for free, without a wordlist. This is
+// directly useful for subdomain enumeration.
+//
+// Zones signed with NSEC3 hash their names and cannot be walked online; Walk
+// detects this and reports it (along with the NSEC3 parameters) so callers can
+// decide whether to attempt offline hash cracking.
+package zonewalk
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+// Config controls a zone walk.
+type Config struct {
+ // Zone is the apex to enumerate (e.g. "example.com").
+ Zone string
+ // Resolvers are DNSSEC-aware resolvers to query (host or host:port). The
+ // first reachable one is used.
+ Resolvers []string
+ // Timeout is the per-query timeout. Default 5s.
+ Timeout time.Duration
+ // MaxNames caps the number of discovered names. A safety valve against
+ // pathological chains that emit endless distinct names. Default 1,000,000
+ // when unset (<= 0); the visited-set and apex-wrap checks already stop
+ // well-formed chains, this bounds hostile ones.
+ MaxNames int
+ // OnName is an optional callback fired for each newly discovered name.
+ OnName func(string)
+}
+
+// NSEC3Info holds the parameters of an NSEC3-signed zone.
+type NSEC3Info struct {
+ Salt string
+ Iterations uint16
+ HashAlg uint8
+}
+
+// Result is the outcome of a zone walk.
+type Result struct {
+ Zone string
+ Names []string // discovered owner names (no trailing dot), excluding the apex
+ NSEC3 bool // zone uses NSEC3 (not walkable online)
+ NSEC3Param *NSEC3Info // populated when NSEC3 is true and parameters were seen
+}
+
+// Walk follows the NSEC chain of cfg.Zone and returns the discovered names.
+func Walk(ctx context.Context, cfg Config) (*Result, error) {
+ if strings.TrimSpace(cfg.Zone) == "" {
+ return nil, fmt.Errorf("zone is required")
+ }
+ if len(cfg.Resolvers) == 0 {
+ return nil, fmt.Errorf("at least one resolver is required")
+ }
+ if cfg.Timeout <= 0 {
+ cfg.Timeout = 5 * time.Second
+ }
+ if cfg.MaxNames <= 0 {
+ cfg.MaxNames = 1_000_000
+ }
+ server := normalize(cfg.Resolvers[0])
+ apex := dns.Fqdn(strings.ToLower(cfg.Zone))
+ client := &dns.Client{Timeout: cfg.Timeout}
+
+ res := &Result{Zone: cfg.Zone}
+ visited := map[string]struct{}{}
+ current := apex
+
+ for {
+ resp, err := queryNSEC(ctx, client, server, current)
+ if err != nil {
+ if len(res.Names) > 0 {
+ // partial walk: return what we have rather than failing hard
+ return res, nil
+ }
+ return nil, fmt.Errorf("nsec query for %s failed: %w", current, err)
+ }
+
+ if info, ok := nsec3Param(resp); ok {
+ res.NSEC3 = true
+ res.NSEC3Param = info
+ return res, nil
+ }
+
+ next, ok := nextName(resp, current)
+ if !ok {
+ // no NSEC for this owner: chain ended or zone is not NSEC-signed
+ return res, nil
+ }
+
+ // termination: the last NSEC wraps back to the apex (or we loop).
+ if next == apex || equalName(next, apex) {
+ return res, nil
+ }
+ if _, seen := visited[next]; seen {
+ return res, nil
+ }
+ visited[next] = struct{}{}
+
+ name := strings.TrimSuffix(next, ".")
+ res.Names = append(res.Names, name)
+ if cfg.OnName != nil {
+ cfg.OnName(name)
+ }
+ if cfg.MaxNames > 0 && len(res.Names) >= cfg.MaxNames {
+ return res, nil
+ }
+ current = next
+ }
+}
+
+func queryNSEC(ctx context.Context, client *dns.Client, server, name string) (*dns.Msg, error) {
+ m := new(dns.Msg)
+ m.SetQuestion(dns.Fqdn(name), dns.TypeNSEC)
+ m.RecursionDesired = true
+ m.SetEdns0(4096, true) // DO bit: request DNSSEC records
+ resp, _, err := client.ExchangeContext(ctx, m, server)
+ return resp, err
+}
+
+// nextName finds the NSEC record covering `owner` and returns its NextDomain.
+func nextName(resp *dns.Msg, owner string) (string, bool) {
+ for _, rr := range append(append([]dns.RR{}, resp.Answer...), resp.Ns...) {
+ if nsec, ok := rr.(*dns.NSEC); ok {
+ // prefer the record whose owner matches, but accept any NSEC if the
+ // owner-specific one is absent (covering record on NXDOMAIN proofs).
+ if equalName(nsec.Hdr.Name, owner) {
+ return dns.Fqdn(strings.ToLower(nsec.NextDomain)), true
+ }
+ }
+ }
+ // fall back to the first NSEC seen
+ for _, rr := range append(append([]dns.RR{}, resp.Answer...), resp.Ns...) {
+ if nsec, ok := rr.(*dns.NSEC); ok {
+ return dns.Fqdn(strings.ToLower(nsec.NextDomain)), true
+ }
+ }
+ return "", false
+}
+
+func nsec3Param(resp *dns.Msg) (*NSEC3Info, bool) {
+ for _, rr := range append(append([]dns.RR{}, resp.Answer...), resp.Ns...) {
+ switch v := rr.(type) {
+ case *dns.NSEC3:
+ return &NSEC3Info{Salt: v.Salt, Iterations: v.Iterations, HashAlg: v.Hash}, true
+ case *dns.NSEC3PARAM:
+ return &NSEC3Info{Salt: v.Salt, Iterations: v.Iterations, HashAlg: v.Hash}, true
+ }
+ }
+ return nil, false
+}
+
+func equalName(a, b string) bool {
+ return strings.EqualFold(dns.Fqdn(a), dns.Fqdn(b))
+}
+
+func normalize(r string) string {
+ r = strings.TrimSpace(r)
+ if _, _, err := net.SplitHostPort(r); err != nil {
+ return net.JoinHostPort(r, "53")
+ }
+ return r
+}
diff --git a/pkg/zonewalk/zonewalk_test.go b/pkg/zonewalk/zonewalk_test.go
new file mode 100644
index 00000000..f5d3bd80
--- /dev/null
+++ b/pkg/zonewalk/zonewalk_test.go
@@ -0,0 +1,114 @@
+package zonewalk
+
+import (
+ "context"
+ "net"
+ "strings"
+ "testing"
+
+ "github.com/miekg/dns"
+)
+
+func startServer(t *testing.T, handler dns.HandlerFunc) (string, func()) {
+ t.Helper()
+ pc, err := net.ListenPacket("udp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ srv := &dns.Server{PacketConn: pc, Handler: handler}
+ go func() { _ = srv.ActivateAndServe() }()
+ return pc.LocalAddr().String(), func() { _ = srv.Shutdown() }
+}
+
+func TestWalkNSEC(t *testing.T) {
+ // a tiny NSEC-signed zone: apex -> a -> b -> c -> apex (wrap).
+ chain := map[string]string{
+ "example.com.": "a.example.com.",
+ "a.example.com.": "b.example.com.",
+ "b.example.com.": "c.example.com.",
+ "c.example.com.": "example.com.",
+ }
+ addr, stop := startServer(t, func(w dns.ResponseWriter, r *dns.Msg) {
+ m := new(dns.Msg)
+ m.SetReply(r)
+ q := r.Question[0]
+ if q.Qtype == dns.TypeNSEC {
+ if next, ok := chain[strings.ToLower(q.Name)]; ok {
+ nsec := &dns.NSEC{
+ Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeNSEC, Class: dns.ClassINET, Ttl: 60},
+ NextDomain: next,
+ TypeBitMap: []uint16{dns.TypeA, dns.TypeNSEC},
+ }
+ m.Answer = append(m.Answer, nsec)
+ } else {
+ m.Rcode = dns.RcodeNameError
+ }
+ }
+ _ = w.WriteMsg(m)
+ })
+ defer stop()
+
+ res, err := Walk(context.Background(), Config{
+ Zone: "example.com",
+ Resolvers: []string{addr},
+ })
+ if err != nil {
+ t.Fatalf("Walk: %v", err)
+ }
+ if res.NSEC3 {
+ t.Fatal("did not expect NSEC3")
+ }
+ want := []string{"a.example.com", "b.example.com", "c.example.com"}
+ if len(res.Names) != len(want) {
+ t.Fatalf("got %v want %v", res.Names, want)
+ }
+ for i := range want {
+ if res.Names[i] != want[i] {
+ t.Fatalf("idx %d: got %q want %q", i, res.Names[i], want[i])
+ }
+ }
+}
+
+func TestNSEC3Detection(t *testing.T) {
+ // NSEC3PARAM in the authority section must flag the zone as NSEC3-signed.
+ m := new(dns.Msg)
+ m.Ns = append(m.Ns, &dns.NSEC3PARAM{
+ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeNSEC3PARAM, Class: dns.ClassINET, Ttl: 60},
+ Hash: 1,
+ Iterations: 10,
+ Salt: "abcd",
+ })
+ info, ok := nsec3Param(m)
+ if !ok || info == nil {
+ t.Fatalf("expected NSEC3 detection")
+ }
+ if info.Iterations != 10 || info.Salt != "abcd" {
+ t.Fatalf("bad NSEC3 params: %+v", info)
+ }
+}
+
+func TestWalkMaxNames(t *testing.T) {
+ chain := map[string]string{
+ "z.com.": "a.z.com.", "a.z.com.": "b.z.com.", "b.z.com.": "c.z.com.", "c.z.com.": "z.com.",
+ }
+ addr, stop := startServer(t, func(w dns.ResponseWriter, r *dns.Msg) {
+ m := new(dns.Msg)
+ m.SetReply(r)
+ if next, ok := chain[strings.ToLower(r.Question[0].Name)]; ok {
+ m.Answer = append(m.Answer, &dns.NSEC{
+ Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeNSEC, Class: dns.ClassINET, Ttl: 60},
+ NextDomain: next, TypeBitMap: []uint16{dns.TypeA},
+ })
+ }
+ _ = w.WriteMsg(m)
+ })
+ defer stop()
+
+ res, err := Walk(context.Background(), Config{Zone: "z.com", Resolvers: []string{addr}, MaxNames: 2})
+ if err != nil {
+ t.Fatalf("Walk: %v", err)
+ }
+ if len(res.Names) != 2 {
+ t.Fatalf("expected MaxNames cap of 2, got %d (%v)", len(res.Names), res.Names)
+ }
+}