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
+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
}