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>
270 lines
6.8 KiB
Go
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(),
|
|
)
|
|
}
|