From 9aa85d8e5d12af27f06c229a98e8789762bc72b6 Mon Sep 17 00:00:00 2001 From: Gary Hansen Date: Mon, 8 Jun 2026 04:01:51 +1000 Subject: [PATCH] docs: comprehensive documentation for ExploreDNS - Rewrite README.md with overview, features, installation (go install + build from source), quick start, full CLI flag reference table, output section descriptions, project structure, and development guide - Add package-level doc comments to all five internal packages: config, dns, fingerprint, output, traverse (via doc.go or existing package-declaration files) - Add GoDoc comments on every exported type, constant, function, and method across all packages: - internal/config: Config struct fields, all Parse*/Default/Validate - internal/dns: QueryConfig, Resolver, BasicResolver, CachingResolver, ExchangeFunc, RootServer, RootDiscoveryConfig, DecodedResponse, ResponseClassification, all exported helpers - internal/fingerprint: Fingerprinter, New, NewWithTimeout, Query, FingerprintAll - internal/traverse: Traverser, TraverserConfig, TraversalResult, Referral, ResolutionState, Response, ResponseType, InfoCache, Stack, TraverserHooks, EventStage, TraversalEvent, EventHandler, CircularReferralError, UnresolvableNameserverError - internal/output: Format, Config, Formatter, SummaryStats, NewFormatter, AttachHooks, RunTraversal, DefaultConfig, ComputeSummary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: multica-agent --- README.md | 253 +++++++++++++++++++++++++++++++-- internal/config/config.go | 67 +++++++-- internal/config/doc.go | 11 ++ internal/dns/decode.go | 44 ++++-- internal/dns/dns.go | 10 ++ internal/dns/query.go | 27 +++- internal/dns/resolver.go | 17 +++ internal/dns/roots.go | 21 ++- internal/dns/types.go | 8 ++ internal/fingerprint/doc.go | 9 ++ internal/output/doc.go | 14 ++ internal/output/formatter.go | 60 ++++++-- internal/output/runner.go | 9 ++ internal/output/stats.go | 9 +- internal/traverse/cache.go | 21 +++ internal/traverse/hooks.go | 20 ++- internal/traverse/referral.go | 47 ++++-- internal/traverse/response.go | 53 +++++-- internal/traverse/stack.go | 13 ++ internal/traverse/traverse.go | 16 +++ internal/traverse/traverser.go | 37 ++++- 21 files changed, 675 insertions(+), 91 deletions(-) create mode 100644 internal/config/doc.go create mode 100644 internal/fingerprint/doc.go create mode 100644 internal/output/doc.go diff --git a/README.md b/README.md index 107c448..01b3426 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,255 @@ # ExploreDNS -DNS reconnaissance and exploration tool. +ExploreDNS is a command-line DNS reconnaissance and exploration tool that performs iterative DNS traversal from the root servers down to the target domain — the same way resolvers do, but with full visibility into every step. -## Build +Unlike a standard resolver query, ExploreDNS shows you the entire delegation path: which root server was queried, which TLD server was referred to, which authoritative server finally answered, and the probability that each path was followed. It also fingerprints nameservers via `version.bind` and detects CNAME chains, CNAME loops, and circular referrals. + +Inspired by [dnstraverse](https://github.com/jeremyevans/dnstraverse). + +--- + +## Features + +- **Full delegation path visibility** — every referral hop from root to authoritative +- **CNAME chain following** and **loop detection** +- **DNAME synthesis** — generates implicit CNAME targets from DNAME records +- **Server fingerprinting** — queries `version.bind` CHAOS TXT on all encountered servers +- **JSON output** — machine-readable structured output for scripting and pipelines +- **Fast mode** — shared glue cache across branches (on by default) +- **Configurable transport** — UDP with TCP fallback, or TCP-only +- **All 13 root servers** — optionally query every root for maximum coverage +- **IPv6 support** — follow AAAA glue records when available +- **Probability weighting** — each result carries a probability so you know how authoritative it is + +--- + +## Installation + +### go install (recommended) ```sh +go install github.com/hits/ExploreDNS/cmd/exploredns@latest +``` + +### Build from source + +```sh +git clone https://github.com/hits/ExploreDNS.git +cd ExploreDNS go build -o bin/exploredns ./cmd/exploredns ``` -Or use the Makefile: +Or via the Makefile: ```sh make build ``` -## Usage +The binary is placed at `bin/exploredns`. + +--- + +## Quick Start ```sh -./bin/exploredns +# Basic A record traversal +exploredns www.example.com + +# Query MX records +exploredns --type MX example.com + +# JSON output for scripting +exploredns --json www.example.com | jq .summary + +# Use all 13 root servers +exploredns --all-root-servers www.example.com + +# TCP only (no UDP) +exploredns --allow-tcp --always-tcp www.example.com + +# Quiet mode — results only, no headers +exploredns --quiet --no-show-progress --no-show-resolves --no-show-servers www.example.com + +# Debug mode (shows config and traversal steps) +exploredns --debug www.example.com + +# Library-level debug +exploredns -dd www.example.com ``` +--- + +## Examples + +### Basic traversal + +``` +$ exploredns www.example.com +ExploreDNS - exploring: www.example.com (type: A) +1 198.41.0.4 (A) + 2 192.5.6.30 (A) + 3 199.43.135.53 (A) + 4 93.184.216.34 (A) + 4 100% answered with 93.184.216.34 + +The following servers were encountered: + a.root-servers.net: 198.41.0.4 + a.iana-servers.net: 199.43.135.53 + a.gtld-servers.net: 192.5.6.30 + +Results: + 4 100% answered with 93.184.216.34 + +Summary: + 100% answered with 93.184.216.34 +``` + +### MX records + +```sh +exploredns --type MX gmail.com +``` + +### JSON output + +```sh +exploredns --json www.example.com +``` + +```json +{ + "domain": "www.example.com", + "query_type": "A", + "results": [ + { + "depth": 3, + "probability": 1, + "response_type": "answer", + "server": "93.184.216.34", + "answers": ["www.example.com. 3600 IN A 93.184.216.34"] + } + ], + "servers": [ + {"name": "a.root-servers.net", "ips": ["198.41.0.4"]}, + {"name": "a.gtld-servers.net", "ips": ["192.5.6.30"]}, + {"name": "a.iana-servers.net", "ips": ["199.43.135.53"]} + ], + "summary": { + "answers": [{"rdata": "93.184.216.34", "probability": 1}] + } +} +``` + +### All root servers + +```sh +exploredns --all-root-servers www.example.com +``` + +Uses all 13 root servers. Each branch carries a probability of approximately 1/13. + +### Verbose mode + +```sh +exploredns -v www.example.com +``` + +Adds bailiwick information to each progress line. + +--- + +## CLI Reference + +``` +Usage: + exploredns [flags] +``` + +### Query Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--type` | `A` | Record type: `A`, `AAAA`, `NS`, `CNAME`, `MX`, `TXT`, `SOA`, `PTR`, `ANY` | +| `--root-server` | _(auto)_ | Override the root server IP address | +| `--all-root-servers` | `false` | Use all 13 root servers | +| `--root-aaaa` | `false` | Include IPv6 addresses of root servers | +| `--follow-aaaa` | `false` | Follow only AAAA glue records for referrals | + +### Transport Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--udp-size` | `2048` | EDNS0 UDP payload size (512–4096) | +| `--allow-tcp` | `true` | TCP fallback when UDP response is truncated | +| `--always-tcp` | `false` | Always use TCP (requires `--allow-tcp`) | +| `--retries` | `2` | Number of retry attempts per query (0–10) | + +### Traversal Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--max-depth` | `20` | Maximum referral depth (1–100) | +| `--fast` | `true` | Fast mode: sibling branches share a glue cache | + +### Output Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--verbose`, `-v` | `false` | Verbose output (shows bailiwick on each line) | +| `--debug`, `-d` | `false` | Application debug output to stderr | +| `-dd` | `false` | Library-level debug (equivalent to `-d -d`) | +| `--quiet`, `-q` | `false` | Suppress introductory banner | +| `--json` | `false` | Output results as a single JSON document | +| `--show-progress` / `--no-show-progress` | on | Per-referral traversal progress lines | +| `--show-resolves` / `--no-show-resolves` | on | Nameserver address resolution detail | +| `--show-servers` / `--no-show-servers` | on | Encountered servers list | +| `--show-versions` / `--no-show-versions` | on | Server version.bind fingerprints | +| `--show-all-stats` / `--no-show-all-stats` | on | Per-result stats as they arrive | +| `--show-results` / `--no-show-results` | on | Terminal results section | +| `--show-summary-results` / `--no-show-summary-results` | on | Probability summary section | + +> **Tip:** Set `NO_COLOR=1` in your environment to disable ANSI colour codes. + +--- + +## Output Sections + +### Traversal progress + +``` +1 198.41.0.4 (A) + 2 192.5.6.30 (A) +``` + +Each line shows the hop number, the server IP, and the query type. Indentation reflects delegation depth. + +### Servers list + +Lists every nameserver encountered, mapped to its IP address, with optional `version.bind` fingerprints. + +### Results + +Terminal outcomes (answers, NXDOMAIN, SERVFAIL, etc.) for each leaf of the traversal tree. + +### Summary + +Aggregates terminal results by response type, weighted by probability. Answer results are grouped by RDATA value. + +--- + +## Project Structure + +``` +cmd/exploredns/ CLI entry point and flag parsing +internal/config/ Config struct, validation, and usage text +internal/dns/ DNS query primitives, response decoding, root discovery +internal/traverse/ Iterative DNS tree traversal engine +internal/fingerprint/ DNS server version fingerprinting +internal/output/ Text and JSON output formatters +``` + +--- + ## Development ```sh @@ -28,16 +258,13 @@ make lint # run go vet make clean # remove build artifacts ``` -## Project Structure +To run tests with the race detector: +```sh +go test -race ./... ``` -cmd/exploredns/ CLI entry point -internal/dns/ DNS query operations -internal/traverse/ DNS tree traversal -internal/fingerprint/ DNS server fingerprinting -internal/output/ Result formatting and output -internal/config/ Configuration management -``` + +--- ## License diff --git a/internal/config/config.go b/internal/config/config.go index 5f7dd1d..af20991 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( "github.com/miekg/dns" ) +// Sentinel errors returned by validation and parsing functions. var ( ErrInvalidQueryType = errors.New("invalid query type") ErrInvalidUDPSize = errors.New("UDP size must be between 512 and 4096") @@ -20,22 +21,41 @@ var ( ErrInvalidRootServer = errors.New("invalid root server IP address") ) +// Config holds all runtime settings for ExploreDNS. +// Populate it from CLI flags, then call Validate before use. type Config struct { - QueryType string - RootServer string + // QueryType is the DNS record type to query (e.g. "A", "MX", "TXT"). + QueryType string + // RootServer overrides the root server IP used to begin traversal. + // Empty string means auto-discover via the system resolver. + RootServer string + // AllRootServers queries all 13 DNS root servers instead of one. AllRootServers bool - RootAAAA bool - FollowAAAA bool - UDPSize int - AllowTCP bool - AlwaysTCP bool - MaxDepth int - Retries int - Fast bool - Verbose bool - Debug int - Quiet bool + // RootAAAA includes IPv6 addresses of root servers when true. + RootAAAA bool + // FollowAAAA restricts referral following to AAAA glue records only. + FollowAAAA bool + // UDPSize is the EDNS0 advertised UDP payload size (512–4096 bytes). + UDPSize int + // AllowTCP enables TCP fallback when a UDP response is truncated. + AllowTCP bool + // AlwaysTCP forces all queries over TCP (requires AllowTCP = true). + AlwaysTCP bool + // MaxDepth limits the traversal depth (1–100). + MaxDepth int + // Retries is the number of times a failed query is retried (0–10). + Retries int + // Fast enables the shared-cache fast mode. When true, sibling branches + // inherit glue discovered by earlier branches, trading accuracy for speed. + Fast bool + // Verbose enables verbose output lines. + Verbose bool + // Debug controls debug verbosity: 0 = off, 1 = app debug, 2 = library debug. + Debug int + // Quiet suppresses supplementary informational output. + Quiet bool + // Output visibility flags — each controls a section of the output. ShowProgress bool ShowResolves bool ShowServers bool @@ -45,6 +65,8 @@ type Config struct { ShowSummaryResults bool } +// ParseQueryType converts a case-insensitive query type string (e.g. "A", "MX") +// to its numeric DNS type constant. Returns ErrInvalidQueryType for unknown types. func ParseQueryType(s string) (uint16, error) { s = strings.ToUpper(s) switch s { @@ -71,6 +93,8 @@ func ParseQueryType(s string) (uint16, error) { } } +// ParseUDPSize parses a string as a UDP buffer size. +// Returns ErrInvalidUDPSize if the string is not an integer or is outside 512–4096. func ParseUDPSize(s string) (int, error) { var size int if _, err := fmt.Sscanf(s, "%d", &size); err != nil { @@ -82,6 +106,8 @@ func ParseUDPSize(s string) (int, error) { return size, nil } +// ParseMaxDepth parses a string as a traversal depth. +// Returns ErrInvalidMaxDepth if the string is not an integer or is outside 1–100. func ParseMaxDepth(s string) (int, error) { var depth int if _, err := fmt.Sscanf(s, "%d", &depth); err != nil { @@ -93,6 +119,8 @@ func ParseMaxDepth(s string) (int, error) { return depth, nil } +// ParseRetries parses a string as a retry count. +// Returns ErrInvalidRetries if the string is not an integer or is outside 0–10. func ParseRetries(s string) (int, error) { var retries int if _, err := fmt.Sscanf(s, "%d", &retries); err != nil { @@ -104,6 +132,9 @@ func ParseRetries(s string) (int, error) { return retries, nil } +// Validate checks that all Config fields are within their accepted ranges and +// that flag combinations are valid (e.g. AlwaysTCP requires AllowTCP). +// Returns the first validation error encountered, or nil. func (c *Config) Validate() error { if _, err := ParseQueryType(c.QueryType); err != nil { return err @@ -128,6 +159,8 @@ func (c *Config) Validate() error { return nil } +// GetDomain returns the domain name from the positional CLI arguments. +// Returns ErrMissingDomain when args is empty. func (c *Config) GetDomain(args []string) (string, error) { if len(args) == 0 { return "", ErrMissingDomain @@ -135,6 +168,9 @@ func (c *Config) GetDomain(args []string) (string, error) { return args[0], nil } +// ParseRootServer parses the RootServer field as an IP address. +// Returns nil, nil when RootServer is empty (auto-discover mode). +// Returns ErrInvalidRootServer when the string is non-empty but not a valid IP. func (c *Config) ParseRootServer() (net.IP, error) { if c.RootServer == "" { return nil, nil @@ -158,6 +194,8 @@ func ParseDebugLevel(d, dd bool) int { return 0 } +// PrintUsage writes a grouped help message to stderr, listing every flag with +// its description. It is registered as flag.Usage by main. func PrintUsage() { fmt.Fprintf(os.Stderr, "ExploreDNS - DNS reconnaissance and exploration tool\n\n") fmt.Fprintf(os.Stderr, "Usage:\n") @@ -211,6 +249,9 @@ func PrintUsage() { } } +// DefaultConfig returns a Config populated with sensible defaults: +// query type A, UDP size 2048, max depth 20, 2 retries, fast mode on, +// TCP fallback allowed, and all output sections visible. func DefaultConfig() *Config { return &Config{ QueryType: "A", diff --git a/internal/config/doc.go b/internal/config/doc.go new file mode 100644 index 0000000..be79e28 --- /dev/null +++ b/internal/config/doc.go @@ -0,0 +1,11 @@ +// Package config defines the ExploreDNS runtime configuration, flag parsing +// helpers, input validation, and usage text. +// +// Config holds all settings that control traversal behaviour and output. +// DefaultConfig returns a ready-to-use Config with sensible defaults. +// After populating Config from CLI flags, call Validate to ensure +// all field values are within their accepted ranges. +// +// Helper functions such as ParseQueryType, ParseDebugLevel, and +// ParseRootServer are pure converters; they never read flag state. +package config diff --git a/internal/dns/decode.go b/internal/dns/decode.go index 3e868f1..80a7ab7 100644 --- a/internal/dns/decode.go +++ b/internal/dns/decode.go @@ -7,17 +7,20 @@ import ( "github.com/miekg/dns" ) +// ResponseClassification categorises a DNS response at a high level, +// independent of the raw RCODE. type ResponseClassification int +// Classification constants, in order from most to least specific. const ( - ResponseAnswer ResponseClassification = iota - ResponseReferral - ResponseNODATA - ResponseNXDOMAIN - ResponseSERVFAIL - ResponseREFUSED - ResponseNOTIMPL - ResponseOther + ResponseAnswer ResponseClassification = iota // answer section contains records + ResponseReferral // non-authoritative NS referral + ResponseNODATA // NOERROR with empty answer section + ResponseNXDOMAIN // name does not exist (RCODE 3) + ResponseSERVFAIL // server failure (RCODE 2) + ResponseREFUSED // query refused (RCODE 5) + ResponseNOTIMPL // not implemented (RCODE 4) + ResponseOther // any other RCODE ) func (rc ResponseClassification) String() string { @@ -41,6 +44,7 @@ func (rc ResponseClassification) String() string { } } +// DecodedResponse holds the extracted, structured fields from a raw *dns.Msg. type DecodedResponse struct { Rcode int RcodeName string @@ -51,8 +55,10 @@ type DecodedResponse struct { Answers []dns.RR Authority []dns.RR Additional []dns.RR - CNAMEChain []string - DNAMEMappings []DNAMEMapping + // CNAMEChain contains the ordered CNAME targets from the answer section. + CNAMEChain []string + // DNAMEMappings contains any DNAME records for redirect synthesis. + DNAMEMappings []DNAMEMapping } // DNAMEMapping holds a DNAME record's owner and target for redirect synthesis. @@ -61,6 +67,8 @@ type DNAMEMapping struct { Target string // e.g., "example.net." } +// DecodeResponse converts a raw *dns.Msg into a DecodedResponse. +// Returns nil when msg is nil. func DecodeResponse(msg *dns.Msg) *DecodedResponse { if msg == nil { return nil @@ -171,10 +179,13 @@ func SynthesizeCNAMEFromDNAME(queryName, dnameOwner, dnameTarget string) string return prefix + target } +// IsTruncated reports whether msg has the TC (truncated) bit set. func IsTruncated(msg *dns.Msg) bool { return msg != nil && msg.Truncated } +// RcodeName returns the string representation of the RCODE in msg (e.g. "NOERROR"). +// Returns "UNKNOWN" when msg is nil. func RcodeName(msg *dns.Msg) string { if msg == nil { return "UNKNOWN" @@ -182,6 +193,7 @@ func RcodeName(msg *dns.Msg) string { return dns.RcodeToString[msg.Rcode] } +// ExtractAnswers returns the answer section of msg, or nil when msg is nil. func ExtractAnswers(msg *dns.Msg) []dns.RR { if msg == nil { return nil @@ -189,6 +201,7 @@ func ExtractAnswers(msg *dns.Msg) []dns.RR { return msg.Answer } +// ExtractAuthority returns the authority section of msg, or nil when msg is nil. func ExtractAuthority(msg *dns.Msg) []dns.RR { if msg == nil { return nil @@ -196,6 +209,8 @@ func ExtractAuthority(msg *dns.Msg) []dns.RR { return msg.Ns } +// ExtractCNAMEChain returns the ordered list of CNAME targets from the answer section. +// Returns nil when msg is nil. func ExtractCNAMEChain(msg *dns.Msg) []string { if msg == nil { return nil @@ -203,6 +218,8 @@ func ExtractCNAMEChain(msg *dns.Msg) []string { return extractCNAMEChain(msg) } +// IsReferral reports whether msg is a non-authoritative NS referral +// (NOERROR, empty answer section, NS records in authority, AA=false). func IsReferral(msg *dns.Msg) bool { if msg == nil || msg.Rcode != dns.RcodeSuccess || len(msg.Answer) > 0 { return false @@ -210,6 +227,8 @@ func IsReferral(msg *dns.Msg) bool { return hasNSRecords(msg.Ns) && !msg.Authoritative } +// IsNODATA reports whether msg is a NODATA response +// (NOERROR with an empty answer section and no referral). func IsNODATA(msg *dns.Msg) bool { if msg == nil || msg.Rcode != dns.RcodeSuccess { return false @@ -223,6 +242,8 @@ func IsNODATA(msg *dns.Msg) bool { return true } +// HasCNAMEChain reports whether msg contains at least one CNAME record in its +// answer section. func HasCNAMEChain(msg *dns.Msg) bool { if msg == nil { return false @@ -230,6 +251,9 @@ func HasCNAMEChain(msg *dns.Msg) bool { return len(extractCNAMEChain(msg)) > 0 } +// FormatRecord returns a human-readable representation of a DNS resource record +// in the form: NAME TTL CLASS TYPE RDATA. +// Returns an empty string when rr is nil. func FormatRecord(rr dns.RR) string { if rr == nil { return "" diff --git a/internal/dns/dns.go b/internal/dns/dns.go index 1ffe03d..4934cbd 100644 --- a/internal/dns/dns.go +++ b/internal/dns/dns.go @@ -1 +1,11 @@ +// Package dns provides low-level DNS query primitives, response decoding, root +// server discovery, and type constants used throughout ExploreDNS. +// +// The package is organised around four concerns: +// +// - types.go — DNS record-type constants and helpers (QNameType, etc.) +// - query.go — Query / IterativeQuery and retry/back-off logic +// - resolver.go — Resolver interface plus BasicResolver and CachingResolver +// - decode.go — DecodeResponse, ResponseClassification, and related helpers +// - roots.go — DiscoverRoots for bootstrapping iterative traversal package dns diff --git a/internal/dns/query.go b/internal/dns/query.go index 61b1e82..3187c74 100644 --- a/internal/dns/query.go +++ b/internal/dns/query.go @@ -9,14 +9,22 @@ import ( "github.com/miekg/dns" ) +// QueryConfig controls the transport-level behaviour of DNS queries. type QueryConfig struct { - UDPSize int - Timeout time.Duration - Retries int - UseTCP bool + // UDPSize is the EDNS0 advertised UDP payload size in bytes. + UDPSize int + // Timeout is the per-attempt network timeout. + Timeout time.Duration + // Retries is the total number of query attempts (first try + retries - 1 extra attempts). + Retries int + // UseTCP forces all queries over TCP when true. + UseTCP bool + // AllowTCP enables automatic TCP retry when a UDP response is truncated. AllowTCP bool } +// DefaultQueryConfig returns a QueryConfig with sensible defaults: +// 2048-byte UDP buffer, 5-second timeout, 3 retries, UDP with TCP fallback. func DefaultQueryConfig() *QueryConfig { return &QueryConfig{ UDPSize: DefaultEDNS0UDPSize(), @@ -27,8 +35,13 @@ func DefaultQueryConfig() *QueryConfig { } } +// ExchangeFunc is a function that sends a DNS message to server and returns +// the response. Injecting an ExchangeFunc in tests avoids real network calls. type ExchangeFunc func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) +// Query sends a standard (recursion-desired) DNS query to server for name/qtype +// using the provided cfg. cfg may be nil; DefaultQueryConfig is used in that case. +// Retries with exponential back-off are performed on transient errors. func Query(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig) (*dns.Msg, error) { if cfg == nil { cfg = DefaultQueryConfig() @@ -75,6 +88,8 @@ func realExchange(ctx context.Context, server string, msg *dns.Msg, useTCP bool) return r, nil } +// QueryWithExchange is the testable core of Query. It uses exchangeFn instead +// of the real network, enabling deterministic unit tests. func QueryWithExchange(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig, exchangeFn ExchangeFunc) (*dns.Msg, error) { if cfg == nil { cfg = DefaultQueryConfig() @@ -127,6 +142,9 @@ func QueryWithExchange(ctx context.Context, server net.IP, name string, qtype ui return nil, fmt.Errorf("query %s %s failed after %d retries: %w", name, QNameType(qtype), cfg.Retries, lastErr) } +// IterativeQuery sends a non-recursive (RD=false) DNS query intended for +// authoritative nameservers. Use this during iterative traversal to prevent +// resolvers from answering on behalf of the authoritative server. func IterativeQuery(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig) (*dns.Msg, error) { if cfg == nil { cfg = DefaultQueryConfig() @@ -137,6 +155,7 @@ func IterativeQuery(ctx context.Context, server net.IP, name string, qtype uint1 return IterativeQueryWithExchange(ctx, server, name, qtype, cfg, realExchange) } +// IterativeQueryWithExchange is the testable core of IterativeQuery. func IterativeQueryWithExchange(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig, exchangeFn ExchangeFunc) (*dns.Msg, error) { if cfg == nil { cfg = DefaultQueryConfig() diff --git a/internal/dns/resolver.go b/internal/dns/resolver.go index ee9ed5f..86a9937 100644 --- a/internal/dns/resolver.go +++ b/internal/dns/resolver.go @@ -10,12 +10,16 @@ import ( "github.com/miekg/dns" ) +// Resolver is the interface for sending DNS queries. +// Implementations may cache, mock, or delegate to the real network. type Resolver interface { Query(ctx context.Context, server net.IP, name string, qtype uint16, cfg *QueryConfig) (*dns.Msg, error) } +// BasicResolver implements Resolver by issuing real UDP/TCP DNS queries. type BasicResolver struct{} +// NewBasicResolver returns a BasicResolver ready for use. func NewBasicResolver() *BasicResolver { return &BasicResolver{} } @@ -40,6 +44,10 @@ func (e *cacheEntry) expired() bool { return time.Now().After(e.expireAt) } +// CachingResolver wraps another Resolver and caches successful responses. +// Responses are evicted based on the minimum DNS TTL in the message, with a +// configurable fallback default TTL for zero-TTL responses. +// All operations are safe for concurrent use. type CachingResolver struct { inner Resolver mu sync.RWMutex @@ -47,6 +55,9 @@ type CachingResolver struct { defaultTTL time.Duration } +// NewCachingResolver creates a CachingResolver wrapping inner. +// When inner is nil, a BasicResolver is used. +// Optional opts may be supplied to customise the default TTL (see WithDefaultTTL). func NewCachingResolver(inner Resolver, opts ...CachingResolverOption) *CachingResolver { if inner == nil { inner = NewBasicResolver() @@ -119,6 +130,7 @@ func (cr *CachingResolver) store(key cacheKey, msg *dns.Msg) { cr.mu.Unlock() } +// Len returns the current number of entries in the cache, including expired ones. func (cr *CachingResolver) Len() int { cr.mu.RLock() n := len(cr.cache) @@ -126,12 +138,14 @@ func (cr *CachingResolver) Len() int { return n } +// Clear removes all entries from the cache. func (cr *CachingResolver) Clear() { cr.mu.Lock() cr.cache = make(map[cacheKey]*cacheEntry) cr.mu.Unlock() } +// PurgeExpired removes all expired entries from the cache and returns the count removed. func (cr *CachingResolver) PurgeExpired() int { cr.mu.Lock() count := 0 @@ -145,12 +159,15 @@ func (cr *CachingResolver) PurgeExpired() int { return count } +// CachingResolverOption is a functional option for NewCachingResolver. type CachingResolverOption func(*cachingResolverConfig) type cachingResolverConfig struct { defaultTTL time.Duration } +// WithDefaultTTL sets the TTL used for cache entries when the DNS response +// contains no TTL information (or when TTL is zero). func WithDefaultTTL(d time.Duration) CachingResolverOption { return func(c *cachingResolverConfig) { c.defaultTTL = d diff --git a/internal/dns/roots.go b/internal/dns/roots.go index 811eafe..f914869 100644 --- a/internal/dns/roots.go +++ b/internal/dns/roots.go @@ -10,12 +10,18 @@ import ( "github.com/miekg/dns" ) +// RootServer holds the name and IP addresses of a DNS root nameserver. type RootServer struct { + // Name is the FQDN of the root nameserver (e.g. "a.root-servers.net."). Name string + // IPv4 holds the IPv4 addresses for the server. IPv4 []net.IP + // IPv6 holds the IPv6 addresses for the server. IPv6 []net.IP } +// AllIPs returns all IP addresses for the server. +// When includeAAAA is false, only IPv4 addresses are returned. func (rs *RootServer) AllIPs(includeAAAA bool) []net.IP { var ips []net.IP ips = append(ips, rs.IPv4...) @@ -25,12 +31,19 @@ func (rs *RootServer) AllIPs(includeAAAA bool) []net.IP { return ips } +// RootDiscoveryConfig controls how DiscoverRoots selects root servers. type RootDiscoveryConfig struct { - Server string - AllRoots bool + // Server overrides which root server is used. An empty string means + // auto-select the first root server returned by the system resolver. + Server string + // AllRoots queries all 13 root servers instead of just one. + AllRoots bool + // IncludeAAAA includes IPv6 addresses of root servers when true. IncludeAAAA bool } +// DefaultRootDiscoveryConfig returns a RootDiscoveryConfig that auto-selects a +// single IPv4-only root server. func DefaultRootDiscoveryConfig() *RootDiscoveryConfig { return &RootDiscoveryConfig{ AllRoots: false, @@ -38,6 +51,10 @@ func DefaultRootDiscoveryConfig() *RootDiscoveryConfig { } } +// DiscoverRoots discovers DNS root servers to use as traversal starting points. +// When cfg.Server is set, that specific root server is used. +// When cfg.AllRoots is true, all 13 root servers are returned. +// Otherwise, a single root server is selected from the system resolver's NS response. func DiscoverRoots(ctx context.Context, cfg *RootDiscoveryConfig) ([]RootServer, error) { if cfg == nil { cfg = DefaultRootDiscoveryConfig() diff --git a/internal/dns/types.go b/internal/dns/types.go index 3a2353d..f114d6f 100644 --- a/internal/dns/types.go +++ b/internal/dns/types.go @@ -4,6 +4,9 @@ import ( "github.com/miekg/dns" ) +// DNS record-type constants mirroring github.com/miekg/dns values. +// Using local aliases keeps the rest of ExploreDNS independent of the +// upstream library's type system. const ( TypeA uint16 = dns.TypeA TypeAAAA uint16 = dns.TypeAAAA @@ -17,6 +20,7 @@ const ( TypeANY uint16 = dns.TypeANY ) +// QNameTypes maps numeric DNS type constants to their canonical uppercase names. var QNameTypes = map[uint16]string{ TypeA: "A", TypeAAAA: "AAAA", @@ -30,6 +34,8 @@ var QNameTypes = map[uint16]string{ TypeANY: "ANY", } +// QNameType returns the string name for a numeric DNS query type. +// Falls back to the upstream dns.TypeToString map for types not in QNameTypes. func QNameType(qtype uint16) string { if name, ok := QNameTypes[qtype]; ok { return name @@ -37,10 +43,12 @@ func QNameType(qtype uint16) string { return dns.TypeToString[qtype] } +// DefaultEDNS0UDPSize returns the default EDNS0 advertised UDP payload size (2048 bytes). func DefaultEDNS0UDPSize() int { return 2048 } +// MinEDNS0UDPSize returns the minimum accepted EDNS0 UDP payload size (512 bytes). func MinEDNS0UDPSize() int { return 512 } diff --git a/internal/fingerprint/doc.go b/internal/fingerprint/doc.go new file mode 100644 index 0000000..5da0c2a --- /dev/null +++ b/internal/fingerprint/doc.go @@ -0,0 +1,9 @@ +// Package fingerprint identifies DNS server software by sending a +// version.bind CHAOS TXT query to each server IP address. +// +// The Fingerprinter type caches results so repeated queries for the same IP +// are answered from memory without a network round-trip. +// FingerprintAll queries a list of IPs concurrently and returns a map of +// IP string → version string. Servers that do not support version.bind, or +// that time out, map to the empty string. +package fingerprint diff --git a/internal/output/doc.go b/internal/output/doc.go new file mode 100644 index 0000000..0e8a1c0 --- /dev/null +++ b/internal/output/doc.go @@ -0,0 +1,14 @@ +// Package output formats ExploreDNS traversal results for human-readable text +// and machine-readable JSON output. +// +// The Formatter interface is the single point of contact for the traversal +// engine. Two implementations are provided: +// +// - text formatter (default) — coloured, human-readable output. +// - JSON formatter (--json flag) — structured JSON suitable for piping. +// +// NewFormatter selects the right implementation based on Config.Format. +// RunTraversal is the high-level entry point: it attaches event hooks, +// executes the traversal, fingerprints encountered servers, and writes the +// final summary and flush. +package output diff --git a/internal/output/formatter.go b/internal/output/formatter.go index 5cd44b7..ed9307d 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -8,34 +8,53 @@ import ( "github.com/hits/ExploreDNS/internal/traverse" ) +// Format selects the output format. type Format int +// Output format constants. const ( - FormatText Format = iota - FormatJSON + FormatText Format = iota // human-readable coloured text (default) + FormatJSON // machine-readable JSON ) +// Config carries settings that control what the formatter emits. type Config struct { - Format Format - Domain string - QueryType string - ShowProgress bool - ShowResolves bool - ShowServers bool - ShowVersions bool - ShowAllStats bool - ShowResults bool + // Format selects text or JSON output. + Format Format + // Domain is the queried domain name, included in JSON output. + Domain string + // QueryType is the record type queried, included in JSON output. + QueryType string + // ShowProgress enables per-referral progress lines. + ShowProgress bool + // ShowResolves enables nameserver resolution detail lines. + ShowResolves bool + // ShowServers enables the server list section. + ShowServers bool + // ShowVersions enables version.bind fingerprint display alongside servers. + ShowVersions bool + // ShowAllStats enables per-result stats as they arrive (in text mode). + ShowAllStats bool + // ShowResults enables the terminal-results section. + ShowResults bool + // ShowSummaryResults enables the probability summary section. ShowSummaryResults bool - Verbose bool - Quiet bool - Color bool - Debug int + // Verbose enables additional detail in progress lines. + Verbose bool + // Quiet suppresses the introductory banner line. + Quiet bool + // Color enables ANSI terminal colour codes in text output. + Color bool + // Debug controls debug verbosity for the formatter itself. + Debug int // Fingerprints maps server IP strings to their version.bind version strings. // Populated by RunTraversal when ShowVersions and ShowServers are both true. Fingerprints map[string]string } +// DefaultConfig returns a Config with all output sections enabled, text format, +// and color determined by the NO_COLOR environment variable. func DefaultConfig() *Config { return &Config{ Format: FormatText, @@ -50,14 +69,23 @@ func DefaultConfig() *Config { } } +// Formatter is the interface that both the text and JSON output backends implement. +// Each method is called by the traversal hooks or by RunTraversal. type Formatter interface { + // WriteProgress is called at EventStart for each Referral. WriteProgress(event traverse.TraversalEvent) error + // WriteResolve is called at EventStart for nameserver resolution sub-steps. WriteResolve(event traverse.TraversalEvent) error + // WriteResult is called at EventComplete for each TraversalResult. WriteResult(result traverse.TraversalResult) error + // WriteSummary is called once after all results are collected. WriteSummary(results []traverse.TraversalResult) error + // Flush finalises output (e.g. writes buffered JSON to the writer). Flush() error } +// NewFormatter returns the appropriate Formatter (text or JSON) based on cfg.Format. +// A nil cfg uses DefaultConfig. A nil w uses os.Stdout. func NewFormatter(cfg *Config, w io.Writer) Formatter { if cfg == nil { cfg = DefaultConfig() @@ -71,6 +99,8 @@ func NewFormatter(cfg *Config, w io.Writer) Formatter { return newTextFormatter(cfg, w) } +// AttachHooks creates a TraverserHooks that routes traversal events to formatter +// according to cfg visibility settings. Returns nil when cfg or formatter is nil. func AttachHooks(cfg *Config, formatter Formatter) *traverse.TraverserHooks { if cfg == nil || formatter == nil { return nil diff --git a/internal/output/runner.go b/internal/output/runner.go index 67c6b6b..df8e151 100644 --- a/internal/output/runner.go +++ b/internal/output/runner.go @@ -9,6 +9,15 @@ import ( "github.com/hits/ExploreDNS/internal/traverse" ) +// RunTraversal is the high-level entry point that wires together a Traverser, +// a Config, and a Formatter. It: +// +// 1. Attaches output hooks to traverser so events are formatted in real time. +// 2. Calls traverser.Traverse(ctx, domain) to perform the traversal. +// 3. Optionally fingerprints encountered servers (when ShowVersions && ShowServers). +// 4. Writes the summary and flushes the formatter. +// +// Returns all TraversalResults and any error from the traversal or output. func RunTraversal(ctx context.Context, traverser *traverse.Traverser, cfg *Config, formatter Formatter, domain string) ([]traverse.TraversalResult, error) { if traverser == nil { return nil, fmt.Errorf("traverser is required") diff --git a/internal/output/stats.go b/internal/output/stats.go index a6b0456..c57b669 100644 --- a/internal/output/stats.go +++ b/internal/output/stats.go @@ -21,11 +21,18 @@ type answerEntry struct { RRs []string } +// SummaryStats holds the aggregated probability statistics computed from a set +// of traversal results. type SummaryStats struct { - ByType map[string]float64 + // ByType maps ResponseType strings (e.g. "nxdomain", "servfail") to their + // cumulative probability weight. + ByType map[string]float64 + // Answers contains per-RDATA probability statistics for answer results. Answers []answerEntry } +// ComputeSummary aggregates terminal TraversalResults into a SummaryStats. +// Returns nil when there are no terminal results to summarise. func ComputeSummary(results []traverse.TraversalResult) *SummaryStats { stats := &SummaryStats{ ByType: make(map[string]float64), diff --git a/internal/traverse/cache.go b/internal/traverse/cache.go index 9894324..7c123c1 100644 --- a/internal/traverse/cache.go +++ b/internal/traverse/cache.go @@ -8,6 +8,13 @@ import ( miekgdns "github.com/miekg/dns" ) +// InfoCache is a two-level (parent/child) concurrent cache for NS records and +// glue addresses discovered during traversal. +// +// Lookups walk the parent chain: a child cache falls back to its parent when +// no local entry is found. Writes always go to the local cache, never to the +// parent. This makes it safe to give sibling branches separate child caches +// that share the root cache read-only in fast mode. type InfoCache struct { parent *InfoCache mu sync.RWMutex @@ -15,6 +22,8 @@ type InfoCache struct { glue map[string][]net.IP } +// NewInfoCache creates an InfoCache with an optional parent. +// Pass nil for a root-level cache with no parent. func NewInfoCache(parent *InfoCache) *InfoCache { return &InfoCache{ parent: parent, @@ -23,6 +32,8 @@ func NewInfoCache(parent *InfoCache) *InfoCache { } } +// StoreNS records the nameserver names for zone in the local cache. +// Duplicate names within a zone are deduplicated. func (c *InfoCache) StoreNS(zone string, nameservers []string) { if len(nameservers) == 0 { return @@ -40,6 +51,8 @@ func (c *InfoCache) StoreNS(zone string, nameservers []string) { c.mu.Unlock() } +// LookupNS returns the cached nameserver names for zone, +// walking the parent chain when no local entry is found. func (c *InfoCache) LookupNS(zone string) []string { zone = normalize(zone) if names := c.localNS(zone); len(names) > 0 { @@ -63,6 +76,8 @@ func (c *InfoCache) localNS(zone string) []string { return result } +// StoreGlue records the IP addresses for a nameserver hostname in the local cache. +// Duplicate addresses are deduplicated. func (c *InfoCache) StoreGlue(name string, addrs []net.IP) { if len(addrs) == 0 { return @@ -80,6 +95,8 @@ func (c *InfoCache) StoreGlue(name string, addrs []net.IP) { c.mu.Unlock() } +// LookupGlue returns the cached IP addresses for a nameserver hostname, +// walking the parent chain when no local entry is found. func (c *InfoCache) LookupGlue(name string) []net.IP { name = normalize(name) if addrs := c.localGlue(name); len(addrs) > 0 { @@ -103,16 +120,20 @@ func (c *InfoCache) localGlue(name string) []net.IP { return result } +// Child creates a new InfoCache that inherits from c. +// The child reads from c when a local lookup misses, but never writes to c. func (c *InfoCache) Child() *InfoCache { return NewInfoCache(c) } +// NSCount returns the number of zone→nameservers entries in the local cache. func (c *InfoCache) NSCount() int { c.mu.RLock() defer c.mu.RUnlock() return len(c.ns) } +// GlueCount returns the number of nameserver→addresses entries in the local cache. func (c *InfoCache) GlueCount() int { c.mu.RLock() defer c.mu.RUnlock() diff --git a/internal/traverse/hooks.go b/internal/traverse/hooks.go index fdf0d88..07b76dd 100644 --- a/internal/traverse/hooks.go +++ b/internal/traverse/hooks.go @@ -1,20 +1,32 @@ package traverse +// EventStage indicates whether a TraversalEvent is fired at the start or +// completion of processing a Referral. type EventStage int +// Event stage constants. const ( - EventStart EventStage = iota - EventComplete + EventStart EventStage = iota // fired when a Referral is about to be queried + EventComplete // fired after the Response has been produced ) +// TraversalEvent carries context for a single traversal event delivered to +// the OnEvent hook. type TraversalEvent struct { - Stage EventStage - Result TraversalResult + // Stage is EventStart or EventComplete. + Stage EventStage + // Result carries the Referral and (for EventComplete) the Response. + Result TraversalResult + // IsResolve is true when the event relates to a nameserver address + // resolution sub-traversal rather than the main traversal. IsResolve bool } +// EventHandler is the function signature for traversal event callbacks. type EventHandler func(TraversalEvent) +// TraverserHooks holds the optional event callback for a Traverser. +// Assigning OnEvent enables progress and result notifications. type TraverserHooks struct { OnEvent EventHandler } diff --git a/internal/traverse/referral.go b/internal/traverse/referral.go index ca78481..0dfe526 100644 --- a/internal/traverse/referral.go +++ b/internal/traverse/referral.go @@ -11,12 +11,14 @@ import ( "golang.org/x/net/idna" ) +// ResolutionState tracks whether a Referral's nameserver addresses have been resolved. type ResolutionState int +// Resolution state constants. const ( - StateUnresolved ResolutionState = iota - StateResolving - StateResolved + StateUnresolved ResolutionState = iota // nameserver addresses are not yet known + StateResolving // address resolution is in progress + StateResolved // addresses are available in Addresses ) func (s ResolutionState) String() string { @@ -32,19 +34,32 @@ func (s ResolutionState) String() string { } } +// Referral represents a pending DNS query: a (name, qtype) pair delegated to a +// set of nameserver addresses. Referrals form a linked-list chain through +// Parent, enabling loop and depth detection. type Referral struct { - Name string - Qtype uint16 - Qclass uint16 + // Name is the fully-qualified domain name being queried. + Name string + // Qtype is the DNS record type being queried. + Qtype uint16 + // Qclass is the DNS class (always ClassINET in practice). + Qclass uint16 + // Bailiwick is the zone that delegated this referral. Bailiwick string + // Addresses holds the resolved IP addresses for this nameserver referral. Addresses []net.IP - State ResolutionState + // State tracks whether Addresses have been resolved. + State ResolutionState + // NSName is the nameserver hostname (before IP resolution). NSName string + // Parent is the Referral that triggered this one, or nil for the root. Parent *Referral - Depth int - Prob float64 + // Depth is the number of referral hops from the root. + Depth int + // Prob is the probability weight for this branch (product of 1/fanout at each step). + Prob float64 } // idnaLookup is the IDN lookup profile used to convert internationalised domain @@ -70,6 +85,9 @@ func toASCII(name string) string { return ascii } +// NewReferral creates a Referral for (name, qtype) within bailiwick, at the +// given depth and probability. The name and bailiwick are normalised to +// lowercase FQDN, and internationalised labels are converted to punycode. func NewReferral(name string, qtype uint16, bailiwick string, depth int, prob float64, parent *Referral) *Referral { return &Referral{ Name: miekgdns.Fqdn(strings.ToLower(toASCII(name))), @@ -83,6 +101,8 @@ func NewReferral(name string, qtype uint16, bailiwick string, depth int, prob fl } } +// InBailiwick reports whether name is within this referral's bailiwick zone. +// A root bailiwick ("." or "") is treated as matching everything. func (r *Referral) InBailiwick(name string) bool { if r.Bailiwick == "" || r.Bailiwick == "." { return true @@ -91,10 +111,12 @@ func (r *Referral) InBailiwick(name string) bool { return miekgdns.IsSubDomain(r.Bailiwick, fqdn) } +// HasAddresses reports whether at least one nameserver IP address is known. func (r *Referral) HasAddresses() bool { return len(r.Addresses) > 0 } +// SetAddresses stores addrs and updates State accordingly. func (r *Referral) SetAddresses(addrs []net.IP) { r.Addresses = addrs if len(addrs) > 0 { @@ -104,6 +126,8 @@ func (r *Referral) SetAddresses(addrs []net.IP) { } } +// CircularReferralError is returned when a referral chain revisits a nameserver, +// indicating a circular delegation. type CircularReferralError struct { Name string Chain []string @@ -113,6 +137,8 @@ func (e *CircularReferralError) Error() string { return fmt.Sprintf("circular referral detected for %s: %v", e.Name, e.Chain) } +// UnresolvableNameserverError is returned when a nameserver hostname cannot be +// resolved to any IP address. type UnresolvableNameserverError struct { Name string Reason string @@ -122,6 +148,9 @@ func (e *UnresolvableNameserverError) Error() string { return fmt.Sprintf("unresolvable nameserver %s: %s", e.Name, e.Reason) } +// Resolve attempts to resolve the nameserver addresses for this Referral by +// performing a fresh iterative traversal. It stores the found addresses and +// updates State. Returns an error when resolution fails. func (r *Referral) Resolve(ctx context.Context, traverser *Traverser, cache *InfoCache, visited map[string]bool, depth int) error { if r.HasAddresses() { r.State = StateResolved diff --git a/internal/traverse/response.go b/internal/traverse/response.go index 81620f4..4c92936 100644 --- a/internal/traverse/response.go +++ b/internal/traverse/response.go @@ -7,19 +7,21 @@ import ( miekgdns "github.com/miekg/dns" ) +// ResponseType classifies the outcome of querying a single Referral. type ResponseType int +// Response type constants used to drive traversal logic. const ( - RespReferral ResponseType = iota - RespAnswer - RespCNAMEFollow - RespNODATA - RespNXDOMAIN - RespSERVFAIL - RespREFUSED - RespNOTIMPL - RespCNAMELoop - RespError + RespReferral ResponseType = iota // server returned an NS referral + RespAnswer // server returned a final answer + RespCNAMEFollow // answer contains a CNAME requiring further traversal + RespNODATA // NOERROR with no matching records + RespNXDOMAIN // name does not exist + RespSERVFAIL // server failure + RespREFUSED // query refused + RespNOTIMPL // query type not implemented + RespCNAMELoop // CNAME chain revisits a name already in the chain + RespError // transport or decoding error ) func (rt ResponseType) String() string { @@ -49,15 +51,25 @@ func (rt ResponseType) String() string { } } +// Response is the result of querying a single Referral against a specific +// nameserver. It contains the decoded DNS message, the classified ResponseType, +// and any error message for display. type Response struct { - Referral *Referral - Server net.IP - Cache *InfoCache - Decoded *dns.DecodedResponse - Type ResponseType + // Referral is the query this response corresponds to. + Referral *Referral + // Server is the nameserver IP that was queried. + Server net.IP + // Cache is the InfoCache used during processing (for glue resolution). + Cache *InfoCache + // Decoded holds the structured DNS response fields. + Decoded *dns.DecodedResponse + // Type is the high-level classification of this response. + Type ResponseType + // ErrorMessage is a human-readable description when Type is RespError or RespCNAMELoop. ErrorMessage string } +// NewResponse creates a Response for the given Referral and server. func NewResponse(ref *Referral, server net.IP, cache *InfoCache) *Response { return &Response{ Referral: ref, @@ -66,6 +78,9 @@ func NewResponse(ref *Referral, server net.IP, cache *InfoCache) *Response { } } +// Process decodes msg, classifies it, and populates r.Type and r.Decoded. +// Synthesises CNAME records from DNAME mappings when no explicit CNAME is present. +// Returns r for chaining. func (r *Response) Process(msg *miekgdns.Msg) *Response { if msg == nil { r.Type = RespError @@ -133,6 +148,10 @@ func (r *Response) hasFinalAnswer() bool { return false } +// ChildReferrals returns the set of child Referrals implied by a referral +// response. It extracts nameservers from the authority section, resolves +// any glue from the additional section, and sets Prob proportionally. +// Returns nil when Type != RespReferral. func (r *Response) ChildReferrals() []*Referral { if r.Type != RespReferral { return nil @@ -177,6 +196,8 @@ func (r *Response) ChildReferrals() []*Referral { return children } +// CNAMEFollowReferral constructs a follow-up Referral targeting the last CNAME +// in the chain. Returns nil when Type != RespCNAMEFollow. func (r *Response) CNAMEFollowReferral() *Referral { if r.Type != RespCNAMEFollow || len(r.Decoded.CNAMEChain) == 0 { return nil @@ -229,6 +250,8 @@ func (r *Response) resolveGlue(child *Referral) { r.Cache.StoreGlue(nsName, child.Addresses) } +// IsTerminal reports whether this response ends a traversal branch (no further +// referrals or CNAME follows are expected). func (r *Response) IsTerminal() bool { switch r.Type { case RespAnswer, RespNODATA, RespNXDOMAIN, RespSERVFAIL, RespREFUSED, RespNOTIMPL, RespCNAMELoop, RespError: diff --git a/internal/traverse/stack.go b/internal/traverse/stack.go index 7e09cd3..c961eb0 100644 --- a/internal/traverse/stack.go +++ b/internal/traverse/stack.go @@ -1,12 +1,18 @@ package traverse +// DefaultMaxDepth is the maximum traversal depth used when no explicit depth is configured. const DefaultMaxDepth = 20 +// Stack is a depth-limited LIFO queue of Referrals used to drive iterative +// DNS traversal. Pushing a Referral whose Depth exceeds maxDepth fails +// silently and returns false, preventing unbounded traversal. type Stack struct { items []*Referral maxDepth int } +// NewStack creates a Stack with the given maximum depth. +// When maxDepth is ≤ 0, DefaultMaxDepth is used. func NewStack(maxDepth int) *Stack { if maxDepth <= 0 { maxDepth = DefaultMaxDepth @@ -17,6 +23,8 @@ func NewStack(maxDepth int) *Stack { } } +// Push adds r to the stack. Returns false (and does not add) when r is nil +// or r.Depth >= maxDepth. func (s *Stack) Push(r *Referral) bool { if r == nil { return false @@ -28,6 +36,7 @@ func (s *Stack) Push(r *Referral) bool { return true } +// Pop removes and returns the top Referral, or nil when the stack is empty. func (s *Stack) Pop() *Referral { if len(s.items) == 0 { return nil @@ -38,6 +47,7 @@ func (s *Stack) Pop() *Referral { return item } +// Peek returns the top Referral without removing it, or nil when empty. func (s *Stack) Peek() *Referral { if len(s.items) == 0 { return nil @@ -45,14 +55,17 @@ func (s *Stack) Peek() *Referral { return s.items[len(s.items)-1] } +// Len returns the current number of items in the stack. func (s *Stack) Len() int { return len(s.items) } +// MaxDepth returns the configured maximum depth for this stack. func (s *Stack) MaxDepth() int { return s.maxDepth } +// IsEmpty reports whether the stack has no items. func (s *Stack) IsEmpty() bool { return len(s.items) == 0 } diff --git a/internal/traverse/traverse.go b/internal/traverse/traverse.go index 5550fe0..e173217 100644 --- a/internal/traverse/traverse.go +++ b/internal/traverse/traverse.go @@ -1 +1,17 @@ +// Package traverse implements iterative DNS tree traversal. +// +// Starting from the DNS root, the traverser follows referrals depth-first until +// it reaches a terminal response (answer, NXDOMAIN, SERVFAIL, etc.) for the +// queried name and type. CNAME chains are followed automatically; CNAME loops +// are detected and reported as errors. +// +// Key types: +// +// - Traverser — entry point; call NewTraverser then Traverse. +// - TraverserConfig — controls max depth, query type, root discovery and fast mode. +// - Referral — a pending query to a set of nameserver addresses. +// - Response — the classified, decoded result of querying a Referral. +// - InfoCache — a two-level (parent/child) cache for NS records and glue. +// - Stack — depth-limited LIFO work queue used by Traverser. +// - TraverserHooks — callback interface for progress and result events. package traverse diff --git a/internal/traverse/traverser.go b/internal/traverse/traverser.go index c09f835..a985be5 100644 --- a/internal/traverse/traverser.go +++ b/internal/traverse/traverser.go @@ -11,13 +11,21 @@ import ( miekgdns "github.com/miekg/dns" ) +// TraverserConfig controls the behaviour of a Traverser. type TraverserConfig struct { - MaxDepth int - QueryType uint16 - RootConfig *dns.RootDiscoveryConfig + // MaxDepth caps the traversal depth. Referrals at or beyond this depth + // are rejected and reported as errors. + MaxDepth int + // QueryType is the DNS record type requested at each step (e.g. dns.TypeA). + QueryType uint16 + // RootConfig controls how root servers are discovered at startup. + RootConfig *dns.RootDiscoveryConfig + // QueryConfig controls UDP/TCP transport settings for each DNS query. QueryConfig *dns.QueryConfig - RootAddrs []net.IP - Hooks *TraverserHooks + // RootAddrs may be supplied directly to skip root discovery. + RootAddrs []net.IP + // Hooks receives events during traversal (progress, resolve, result). + Hooks *TraverserHooks // Fast controls cache sharing across branches. When true (default), child // branches inherit glue discovered by earlier branches via the shared root // cache, trading accuracy for speed. When false, each branch gets a @@ -26,6 +34,8 @@ type TraverserConfig struct { Fast bool } +// DefaultTraverserConfig returns a TraverserConfig with sensible defaults: +// max depth 20, query type A, fast mode on. func DefaultTraverserConfig() *TraverserConfig { return &TraverserConfig{ MaxDepth: DefaultMaxDepth, @@ -37,11 +47,17 @@ func DefaultTraverserConfig() *TraverserConfig { } } +// TraversalResult pairs a Referral (the query that was attempted) with the +// Response (the outcome of that query). Response may be nil for referrals +// that were never processed (e.g. depth-limit rejections). type TraversalResult struct { Referral *Referral Response *Response } +// Traverser performs iterative DNS traversal from the root down to the target +// domain, following referrals and CNAME chains. +// Create one with NewTraverser; call Traverse to run a traversal. type Traverser struct { config *TraverserConfig exchange dns.ExchangeFunc @@ -50,6 +66,8 @@ type Traverser struct { mu sync.Mutex } +// NewTraverser creates a Traverser using cfg. +// When cfg is nil, DefaultTraverserConfig is used. func NewTraverser(cfg *TraverserConfig) *Traverser { if cfg == nil { cfg = DefaultTraverserConfig() @@ -62,10 +80,12 @@ func NewTraverser(cfg *TraverserConfig) *Traverser { } } +// SetExchange injects a custom exchange function, primarily for testing. func (t *Traverser) SetExchange(fn dns.ExchangeFunc) { t.exchange = fn } +// SetHooks attaches traversal event hooks to the Traverser. func (t *Traverser) SetHooks(hooks *TraverserHooks) { if t.config == nil { t.config = DefaultTraverserConfig() @@ -73,6 +93,10 @@ func (t *Traverser) SetHooks(hooks *TraverserHooks) { t.config.Hooks = hooks } +// Traverse performs an iterative DNS traversal for name, starting from the root +// servers. It returns all TraversalResults, including intermediate referrals +// and terminal outcomes. Hooks are called for each event during the traversal. +// The context can be used to cancel a long-running traversal. func (t *Traverser) Traverse(ctx context.Context, name string) ([]TraversalResult, error) { name = miekgdns.Fqdn(name) @@ -251,6 +275,9 @@ func (t *Traverser) processReferral(ctx context.Context, ref *Referral, cache *I } } +// ResolveNS resolves a nameserver hostname to its IP addresses by performing +// a fresh iterative traversal for that name, using cache to avoid repeated +// queries and visited to detect circular referrals. func (t *Traverser) ResolveNS(ctx context.Context, nsName string, cache *InfoCache, visited map[string]bool, depth int) ([]net.IP, error) { if cache != nil { if addrs := cache.LookupGlue(nsName); len(addrs) > 0 { -- 2.54.0