Files
ExploreDNS/internal/dns/decode.go
T
9aa85d8e5d
CI / test (pull_request) Failing after 2m13s
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 <github@multica.ai>
2026-06-08 04:01:51 +10:00

270 lines
6.8 KiB
Go

package dns
import (
"fmt"
"strings"
"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 // 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 {
switch rc {
case ResponseAnswer:
return "answer"
case ResponseReferral:
return "referral"
case ResponseNODATA:
return "nodata"
case ResponseNXDOMAIN:
return "nxdomain"
case ResponseSERVFAIL:
return "servfail"
case ResponseREFUSED:
return "refused"
case ResponseNOTIMPL:
return "notimp"
default:
return "other"
}
}
// DecodedResponse holds the extracted, structured fields from a raw *dns.Msg.
type DecodedResponse struct {
Rcode int
RcodeName string
Truncated bool
RecursionAvailable bool
Authoritative bool
Classification ResponseClassification
Answers []dns.RR
Authority []dns.RR
Additional []dns.RR
// 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.
type DNAMEMapping struct {
Owner string // e.g., "example.com."
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
}
d := &DecodedResponse{
Rcode: msg.Rcode,
RcodeName: dns.RcodeToString[msg.Rcode],
Truncated: msg.Truncated,
RecursionAvailable: msg.RecursionAvailable,
Authoritative: msg.Authoritative,
Answers: msg.Answer,
Authority: msg.Ns,
Additional: msg.Extra,
CNAMEChain: extractCNAMEChain(msg),
DNAMEMappings: extractDNAMEMappings(msg),
}
d.Classification = classify(msg)
return d
}
func classify(msg *dns.Msg) ResponseClassification {
switch msg.Rcode {
case dns.RcodeNameError:
return ResponseNXDOMAIN
case dns.RcodeServerFailure:
return ResponseSERVFAIL
case dns.RcodeRefused:
return ResponseREFUSED
case dns.RcodeNotImplemented:
return ResponseNOTIMPL
case dns.RcodeSuccess:
return classifySuccess(msg)
default:
return ResponseOther
}
}
func classifySuccess(msg *dns.Msg) ResponseClassification {
hasAnswers := len(msg.Answer) > 0
if hasAnswers {
return ResponseAnswer
}
hasNS := hasNSRecords(msg.Ns)
if hasNS && !msg.Authoritative {
return ResponseReferral
}
if hasNS {
return ResponseNODATA
}
return ResponseNODATA
}
func hasNSRecords(rrs []dns.RR) bool {
for _, rr := range rrs {
if _, ok := rr.(*dns.NS); ok {
return true
}
}
return false
}
func extractCNAMEChain(msg *dns.Msg) []string {
var chain []string
seen := make(map[string]bool)
for _, rr := range msg.Answer {
if cname, ok := rr.(*dns.CNAME); ok {
target := cname.Target
if !seen[target] {
seen[target] = true
chain = append(chain, target)
}
}
}
return chain
}
func extractDNAMEMappings(msg *dns.Msg) []DNAMEMapping {
var mappings []DNAMEMapping
for _, rr := range msg.Answer {
if dname, ok := rr.(*dns.DNAME); ok {
mappings = append(mappings, DNAMEMapping{
Owner: dns.Fqdn(dname.Hdr.Name),
Target: dns.Fqdn(dname.Target),
})
}
}
return mappings
}
// SynthesizeCNAMEFromDNAME computes the CNAME target for queryName given a DNAME mapping.
// Returns empty string if queryName is not a strict subdomain of dnameOwner.
func SynthesizeCNAMEFromDNAME(queryName, dnameOwner, dnameTarget string) string {
q := strings.ToLower(dns.Fqdn(queryName))
owner := strings.ToLower(dns.Fqdn(dnameOwner))
target := strings.ToLower(dns.Fqdn(dnameTarget))
if !dns.IsSubDomain(owner, q) || q == owner {
return ""
}
prefix := strings.TrimSuffix(q, owner)
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"
}
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
}
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
}
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
}
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
}
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
}
if len(msg.Answer) > 0 {
return false
}
if IsReferral(msg) {
return false
}
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
}
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 ""
}
header := rr.Header()
return fmt.Sprintf("%s %d %s %s %s",
header.Name,
header.Ttl,
dns.ClassToString[header.Class],
QNameType(header.Rrtype),
rr.String(),
)
}