diff --git a/README.md b/README.md index 8a1d875..9210a9c 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ make help # list all targets | `-force` | `false` | Continue scanning even if wildcard DNS is detected | | `-no-abort` | `false` | Keep scanning after the 20% resolver failure-rate abort (warning is still emitted) | | `-auto-rate` | `false` | When more than 20% of the last 200 lookups failed, halve the query rate (from the rate reached so far when `-rate` is 0) and keep going, with a `rate` notice; speed back up by a quarter at a time, to the rate it started from, once a window of lookups is healthy. Only after 6 back-offs, or at 5 queries/s, does the reliability guard abort. The report counts `rate_backoffs` and the `final_rate`. Pairs well with `-retry-inconclusive`, which retries the names that failed while the resolver was overloaded | -| `-max-queries ` | `0` | Max candidate names to test (0 = unlimited). Each name sends one query per record type per attempt (`CNAME` costs two) | +| `-max-queries ` | `0` | Max candidate names to test (0 = unlimited). Each record type costs one query per attempt, except CNAME (three: A, AAAA and CNAME) | | `-max-duration ` | `0` | Time budget for the whole run, e.g. `20m` (0 = unlimited). Then no new names are tested, lookups already under way finish, and the run ends normally with its results, report (`stopped_by: "duration"`) and a `degraded` verdict. Under `-dL` it covers all targets | | `-retry-inconclusive` | `false` | After each scan, look up once more (against `-dns-server`, never the `-r` pool) every name that timed out, was refused or failed. An answer replaces the first outcome in the counts; a name that resolves is reported, but not expanded by `-recursive`. Skipped after an abort, an interrupt or once `-max-duration` runs out. The report counts `retried` and `recovered` | | `-inconclusive ` | n/a | Write the names still without an answer (timeout, refused, other) to this file, one per line, sorted, so a later run can retry them with `-w ` | diff --git a/internal/dns/resolver.go b/internal/dns/resolver.go index 9753309..72fd310 100644 --- a/internal/dns/resolver.go +++ b/internal/dns/resolver.go @@ -274,6 +274,19 @@ func fqdn(domain string) string { // dialed (see NewResolver). var queriesPerType = map[string]int32{"A": 1, "AAAA": 1, "CNAME": 3, "NS": 1, "MX": 1, "TXT": 1} +// QueriesPerName returns the DNS queries reserved for one name across types. +// An empty type list uses DefaultTypes, as Lookup.ResolveTypes does. +func QueriesPerName(types []string) int64 { + if len(types) == 0 { + types = DefaultTypes + } + var total int64 + for _, typ := range types { + total += int64(queriesPerType[typ]) + } + return total +} + // Lookup is how one name is looked up: the resolver, the per-type timeout, // how many attempts a retried lookup makes, the record types, and the verbose // logger. Naming the fields keeps a swapped attempts/types argument from diff --git a/internal/dns/resolver_test.go b/internal/dns/resolver_test.go index b6df450..91b3e67 100644 --- a/internal/dns/resolver_test.go +++ b/internal/dns/resolver_test.go @@ -10,6 +10,25 @@ import ( "time" ) +func TestQueriesPerName(t *testing.T) { + for _, tc := range []struct { + name string + types []string + want int64 + }{ + {"default types", nil, 2}, + {"CNAME", []string{"CNAME"}, 3}, + {"all types", []string{"A", "AAAA", "CNAME", "NS", "MX", "TXT"}, 8}, + {"same types as the resolver receives", []string{"A", "AAAA", "CNAME", "CNAME"}, 8}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := QueriesPerName(tc.types); got != tc.want { + t.Errorf("QueriesPerName(%v) = %d, want %d", tc.types, got, tc.want) + } + }) + } +} + // resolveDomain reports whether one lookup of domain (DefaultTypes, no // retry) returns records. func resolveDomain(ctx context.Context, r *net.Resolver, domain string, timeout time.Duration) bool { diff --git a/main_dryrun.go b/main_dryrun.go index 7c04c0f..728bffa 100644 --- a/main_dryrun.go +++ b/main_dryrun.go @@ -19,8 +19,9 @@ type dryRunTarget struct { Duplicates int `json:"duplicates"` // wordlist duplicates removed Skipped int `json:"skipped"` // invalid wordlist entries Excluded int `json:"excluded"` // candidates out of scope under -exclude - // Queries is one query per candidate and record type; QueriesMax also - // counts every retry -attempts allows. Both respect -max-queries. + // Queries counts the resolver's expected wire queries for each candidate + // and record type; QueriesMax also counts every retry -attempts allows. + // Both respect -max-queries. Queries int64 `json:"queries"` QueriesMax int64 `json:"queries_max"` // Seconds and SecondsMax are the time those queries take at -rate; 0 @@ -57,7 +58,7 @@ func (s *session) dryRun() int { if f.maxQueries > 0 { names = min(names, int64(f.maxQueries)) } - p.Queries = names * int64(len(s.in.recordTypes)) + p.Queries = names * dns.QueriesPerName(s.in.recordTypes) p.QueriesMax = p.Queries * int64(max(s.in.maxAttempts, 1)) if f.rate > 0 { p.Seconds = float64(p.Queries) / float64(f.rate) diff --git a/main_dryrun_test.go b/main_dryrun_test.go index 654ef54..7aad928 100644 --- a/main_dryrun_test.go +++ b/main_dryrun_test.go @@ -56,6 +56,22 @@ func TestDryRunMaxQueriesCapsEstimate(t *testing.T) { } } +func TestDryRunCountsCNAMEQueriesAndRetries(t *testing.T) { + wl := writeFile(t, "wl.txt", "www\napi\n") + code, plans := dryRunPlans(t, "-simulate", "-w", wl, "-type", "A,AAAA,CNAME", + "-attempts", "2", "-rate", "10", "example.com") + if code != exitOK { + t.Fatalf("exit %d, want %d", code, exitOK) + } + if len(plans) != 1 { + t.Fatalf("got %d plans, want 1", len(plans)) + } + want := dryRunTarget{Domain: "example.com", Candidates: 2, Queries: 10, QueriesMax: 20, Seconds: 1, SecondsMax: 2, PreflightOK: true} + if plans[0] != want { + t.Errorf("plan = %+v\nwant = %+v", plans[0], want) + } +} + // TestDryRunPreflight: the preflight is a dry run's only DNS traffic, and a // resolver that fails it fails the run. func TestDryRunPreflight(t *testing.T) {