docs: comprehensive documentation for ExploreDNS
CI / test (pull_request) Failing after 2m13s

- 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 <github@multica.ai>
This commit is contained in:
Gary Hansen
2026-06-08 04:01:51 +10:00
co-authored by Copilot multica-agent
parent fe1afe2a97
commit 9aa85d8e5d
21 changed files with 675 additions and 91 deletions
+54 -13
View File
@@ -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",
+11
View File
@@ -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
+34 -10
View File
@@ -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 ""
+10
View File
@@ -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
+23 -4
View File
@@ -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()
+17
View File
@@ -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
+19 -2
View File
@@ -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()
+8
View File
@@ -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
}
+9
View File
@@ -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
+14
View File
@@ -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
+45 -15
View File
@@ -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
+9
View File
@@ -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")
+8 -1
View File
@@ -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),
+21
View File
@@ -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()
+16 -4
View File
@@ -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
}
+38 -9
View File
@@ -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
+38 -15
View File
@@ -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:
+13
View File
@@ -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
}
+16
View File
@@ -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
+32 -5
View File
@@ -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 {