feat: rework engine and CLI for dnstraverse parity
Port the traversal engine to the Ruby dnstraverse model so behaviour and output match dns.squish.net: - dns: single RD=0 query path (RD=1 only for upstream root discovery), per-run packet cache, EDNS0 512-fallback with warnings, UDP->TCP on truncation; fix --retries 0 and --root-server IP-literal handling; drop all hardcoded 127.0.0.1:53 resolvers - traverse: hierarchical per-branch InfoCache, 7-step response classification with the full 10-status vocabulary, bailiwick partitioning, strictly-deeper lame-referral rule, refid grammar with .0 resolve subtrees and childset digits, per-IP branching at 1/n weight, cache-based glue resolution with noglue/loop dead ends, CNAME restarts from the deepest cached zone, fast-mode memoization, probability aggregation with Ruby-identical stats keys (sums to 1.0) - output: byte-for-byte reference text format pinned by a golden test, reference CLI defaults, working --quiet/--show-X=false, TTY-aware colour, deduplicated deterministic JSON - web: adapt API/SPA to the new engine, SSE events carry refid/status, fix subscribe/snapshot duplicate-event race and a statusCls TDZ bug, align SPA type list with the backend - delete the old engine and dead code (net -4,350 lines) Verified against live runs of the reference Ruby engine across five domains (answers, NXDOMAIN, null MX, CNAME restart, glueless resolve) with no divergences beyond the documented typo fixes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
af15c9c2d4
commit
d71c7fbef2
+560
-219
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.hansenits.com.au/hits/ExploreDNS/internal/dns"
|
||||
@@ -11,44 +12,87 @@ import (
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
type ResolutionState int
|
||||
// DefaultMaxDepth is the default maximum referral depth (non-zero refid
|
||||
// components) before a "Maxdepth N exceeded" exception is injected.
|
||||
const DefaultMaxDepth = 20
|
||||
|
||||
// ReferralStatus is the resolve-phase status of a Referral node itself
|
||||
// (referral.rb @status), distinct from the per-IP response statuses.
|
||||
type ReferralStatus string
|
||||
|
||||
const (
|
||||
StateUnresolved ResolutionState = iota
|
||||
StateResolving
|
||||
StateResolved
|
||||
RefStatusNormal ReferralStatus = "normal"
|
||||
RefStatusNoGlue ReferralStatus = "noglue"
|
||||
RefStatusLoop ReferralStatus = "loop"
|
||||
)
|
||||
|
||||
func (s ResolutionState) String() string {
|
||||
switch s {
|
||||
case StateUnresolved:
|
||||
return "unresolved"
|
||||
case StateResolving:
|
||||
return "resolving"
|
||||
case StateResolved:
|
||||
return "resolved"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
// StatsEntry is one aggregated leaf statistic: the probability mass that
|
||||
// ended in Response's outcome at Referral (referral.rb @stats values).
|
||||
type StatsEntry struct {
|
||||
Key string
|
||||
Prob float64
|
||||
Response *ServerResponse
|
||||
Referral *Referral
|
||||
}
|
||||
|
||||
// Referral represents one referral to a specific server for qname/qclass/
|
||||
// qtype (referral.rb). The synthetic top node ("rootroot") has Server == ""
|
||||
// and is never displayed; its children are the root servers.
|
||||
type Referral struct {
|
||||
Name string
|
||||
Qtype uint16
|
||||
Qclass uint16
|
||||
Bailiwick string
|
||||
|
||||
Addresses []net.IP
|
||||
State ResolutionState
|
||||
|
||||
NSName string
|
||||
RefID string
|
||||
Parent *Referral
|
||||
Depth int
|
||||
Prob float64
|
||||
|
||||
Qname string
|
||||
Qclass uint16
|
||||
Qtype uint16
|
||||
// NSAType is the record type used to resolve nameserver addresses
|
||||
// (always A; the reference is IPv4-only for transport).
|
||||
NSAType uint16
|
||||
|
||||
// Server is the NS hostname this referral queries ("" for rootroot).
|
||||
Server string
|
||||
// ServerIPs is nil when the server still needs resolving. After a
|
||||
// resolve it may also contain "key:..." pseudo entries carrying the
|
||||
// probability of failed resolutions.
|
||||
ServerIPs []string
|
||||
Bailiwick string
|
||||
ParentIP string
|
||||
|
||||
InfoCache *InfoCache
|
||||
Status ReferralStatus
|
||||
|
||||
// Responses holds the classified response for each real IP queried.
|
||||
Responses map[string]*ServerResponse
|
||||
// Children holds child referrals keyed by the parent IP that produced
|
||||
// them ("rootroot" for the synthetic top node).
|
||||
Children map[string][]*Referral
|
||||
// Resolves is the glue-resolution subtree (refid ".0." components).
|
||||
Resolves []*Referral
|
||||
|
||||
ServerWeights map[string]float64
|
||||
Warnings []string
|
||||
|
||||
// Stats is the post-order aggregation of leaf outcomes below this node.
|
||||
Stats map[string]*StatsEntry
|
||||
// StatsResolve aggregates the outcomes of the resolve subtree.
|
||||
StatsResolve map[string]*StatsEntry
|
||||
|
||||
// ReplacedBy points at the earlier completed referral that fast mode
|
||||
// substituted for this node.
|
||||
ReplacedBy *Referral
|
||||
|
||||
// summaryStats memoises SummaryStats() (referral.rb summary_stats).
|
||||
summaryStats *SummaryStats
|
||||
|
||||
client *dns.Client
|
||||
maxdepth int
|
||||
referralResolution bool
|
||||
processed bool
|
||||
calculated bool
|
||||
}
|
||||
|
||||
// idnaLookup is the IDN lookup profile used to convert internationalised domain
|
||||
// names (unicode labels) to their ACE/punycode equivalents before querying.
|
||||
// idnaLookup is the IDN lookup profile used to convert internationalised
|
||||
// domain names (unicode labels) to their ACE/punycode equivalents.
|
||||
var idnaLookup = idna.New(
|
||||
idna.MapForLookup(),
|
||||
idna.BidiRule(),
|
||||
@@ -56,9 +100,8 @@ var idnaLookup = idna.New(
|
||||
)
|
||||
|
||||
// toASCII converts a domain name that may contain unicode labels to its
|
||||
// punycode (ACE) representation. Pure-ASCII names are returned unchanged.
|
||||
// On conversion errors the original name is returned so the caller can still
|
||||
// attempt a query (the server will reject it if truly invalid).
|
||||
// punycode (ACE) representation. On conversion errors the original name is
|
||||
// returned so the caller can still attempt a query.
|
||||
func toASCII(name string) string {
|
||||
if name == "" || name == "." {
|
||||
return name
|
||||
@@ -70,210 +113,508 @@ func toASCII(name string) string {
|
||||
return ascii
|
||||
}
|
||||
|
||||
func NewReferral(name string, qtype uint16, bailiwick string, depth int, prob float64, parent *Referral) *Referral {
|
||||
return &Referral{
|
||||
Name: miekgdns.Fqdn(strings.ToLower(toASCII(name))),
|
||||
Qtype: qtype,
|
||||
Qclass: miekgdns.ClassINET,
|
||||
Bailiwick: miekgdns.Fqdn(strings.ToLower(toASCII(bailiwick))),
|
||||
Depth: depth,
|
||||
Prob: prob,
|
||||
Parent: parent,
|
||||
State: StateUnresolved,
|
||||
// referralArgs are the per-child overrides for makeReferral; zero values
|
||||
// inherit from the parent (referral.rb make_referral merge semantics).
|
||||
type referralArgs struct {
|
||||
qname string
|
||||
qtype uint16
|
||||
server string
|
||||
serverIPs []string
|
||||
bailiwick string
|
||||
infoCache *InfoCache
|
||||
refid string
|
||||
parentIP string
|
||||
referralResolution bool
|
||||
}
|
||||
|
||||
func (r *Referral) makeReferral(a referralArgs) *Referral {
|
||||
child := &Referral{
|
||||
RefID: a.refid,
|
||||
Parent: r,
|
||||
Qname: r.Qname,
|
||||
Qclass: r.Qclass,
|
||||
Qtype: r.Qtype,
|
||||
NSAType: r.NSAType,
|
||||
Server: canonicalName(a.server),
|
||||
ServerIPs: a.serverIPs,
|
||||
Bailiwick: canonicalName(a.bailiwick),
|
||||
ParentIP: a.parentIP,
|
||||
InfoCache: r.InfoCache,
|
||||
Status: RefStatusNormal,
|
||||
Responses: make(map[string]*ServerResponse),
|
||||
Children: make(map[string][]*Referral),
|
||||
ServerWeights: make(map[string]float64),
|
||||
client: r.client,
|
||||
maxdepth: r.maxdepth,
|
||||
referralResolution: a.referralResolution || r.referralResolution,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Referral) InBailiwick(name string) bool {
|
||||
if r.Bailiwick == "" || r.Bailiwick == "." {
|
||||
return true
|
||||
if a.qname != "" {
|
||||
child.Qname = canonicalName(a.qname)
|
||||
}
|
||||
fqdn := miekgdns.Fqdn(strings.ToLower(name))
|
||||
return miekgdns.IsSubDomain(r.Bailiwick, fqdn)
|
||||
}
|
||||
|
||||
func (r *Referral) HasAddresses() bool {
|
||||
return len(r.Addresses) > 0
|
||||
}
|
||||
|
||||
func (r *Referral) SetAddresses(addrs []net.IP) {
|
||||
r.Addresses = addrs
|
||||
if len(addrs) > 0 {
|
||||
r.State = StateResolved
|
||||
} else {
|
||||
r.State = StateUnresolved
|
||||
if a.qtype != 0 {
|
||||
child.Qtype = a.qtype
|
||||
}
|
||||
}
|
||||
|
||||
type CircularReferralError struct {
|
||||
Name string
|
||||
Chain []string
|
||||
}
|
||||
|
||||
func (e *CircularReferralError) Error() string {
|
||||
return fmt.Sprintf("circular referral detected for %s: %v", e.Name, e.Chain)
|
||||
}
|
||||
|
||||
type UnresolvableNameserverError struct {
|
||||
Name string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *UnresolvableNameserverError) Error() string {
|
||||
return fmt.Sprintf("unresolvable nameserver %s: %s", e.Name, e.Reason)
|
||||
}
|
||||
|
||||
func (r *Referral) Resolve(ctx context.Context, traverser *Traverser, cache *InfoCache, visited map[string]bool, depth int) error {
|
||||
if r.HasAddresses() {
|
||||
r.State = StateResolved
|
||||
return nil
|
||||
if a.infoCache != nil {
|
||||
child.InfoCache = a.infoCache
|
||||
}
|
||||
|
||||
if cache != nil {
|
||||
if addrs := cache.LookupGlue(r.Name); len(addrs) > 0 {
|
||||
r.Addresses = addrs
|
||||
r.State = StateResolved
|
||||
return nil
|
||||
// serverweight = 1/len(ips) per IP when the addresses are known.
|
||||
if child.ServerIPs != nil {
|
||||
for _, ip := range child.ServerIPs {
|
||||
child.ServerWeights[ip] = 1.0 / float64(len(child.ServerIPs))
|
||||
}
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
if visited != nil {
|
||||
if visited[r.Name] {
|
||||
return &CircularReferralError{
|
||||
Name: r.Name,
|
||||
Chain: getVisitedNames(visited),
|
||||
// IsRootRoot reports whether this is the synthetic top node representing an
|
||||
// automatic referral to the root servers.
|
||||
func (r *Referral) IsRootRoot() bool {
|
||||
return r.Server == ""
|
||||
}
|
||||
|
||||
// IsResolve reports whether this node is part of a glue-resolution subtree.
|
||||
func (r *Referral) IsResolve() bool {
|
||||
return r.referralResolution
|
||||
}
|
||||
|
||||
// Resolved reports whether the server addresses are known (rootroot is
|
||||
// always resolved).
|
||||
func (r *Referral) Resolved() bool {
|
||||
return r.IsRootRoot() || r.ServerIPs != nil
|
||||
}
|
||||
|
||||
// Depth counts the non-zero refid components; resolve subtrees (".0.") do
|
||||
// not count against the depth limit.
|
||||
func (r *Referral) Depth() int {
|
||||
return refidDepth(r.RefID)
|
||||
}
|
||||
|
||||
func refidDepth(refid string) int {
|
||||
if refid == "" {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, part := range strings.Split(refid, ".") {
|
||||
if part != "0" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// IPsAsArray returns the real IP addresses known for this referral,
|
||||
// excluding "key:" pseudo entries.
|
||||
func (r *Referral) IPsAsArray() []string {
|
||||
var out []string
|
||||
for _, ip := range r.ServerIPs {
|
||||
if !strings.HasPrefix(ip, "key:") {
|
||||
out = append(out, ip)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TxtIPsVerbose renders the per-IP weights, sorted, e.g.
|
||||
// "50.0%=1.2.3.4,50.0%=noglue:1.2.3.4" (referral.rb txt_ips_verbose). It is
|
||||
// part of the fast-mode memo key.
|
||||
func (r *Referral) TxtIPsVerbose() string {
|
||||
if r.ServerIPs == nil {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(r.ServerIPs))
|
||||
for _, ip := range r.ServerIPs {
|
||||
label := ip
|
||||
if rest, ok := strings.CutPrefix(ip, "key:"); ok {
|
||||
// keep the first two colon-separated fields, like Ruby's
|
||||
// /^key:([^:]+(:[^:]*)?)/ capture.
|
||||
fields := strings.SplitN(rest, ":", 3)
|
||||
if len(fields) > 2 {
|
||||
fields = fields[:2]
|
||||
}
|
||||
label = strings.Join(fields, ":")
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%.1f%%=%s", 100*r.ServerWeights[ip], label))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// TxtIPs renders the addresses for progress display; failed-resolve pseudo
|
||||
// entries render as their response description (referral.rb txt_ips).
|
||||
func (r *Referral) TxtIPs() string {
|
||||
if r.ServerIPs == nil {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(r.ServerIPs))
|
||||
for _, ip := range r.ServerIPs {
|
||||
if strings.HasPrefix(ip, "key:") {
|
||||
if e, ok := r.StatsResolve[ip]; ok && e.Response != nil {
|
||||
parts = append(parts, e.Response.String())
|
||||
continue
|
||||
}
|
||||
}
|
||||
visited[r.Name] = true
|
||||
}
|
||||
|
||||
if depth > DefaultMaxDepth {
|
||||
return &UnresolvableNameserverError{
|
||||
Name: r.Name,
|
||||
Reason: "max depth exceeded",
|
||||
}
|
||||
}
|
||||
|
||||
roots, err := traverser.discoverRoots(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("root discovery: %w", err)
|
||||
}
|
||||
|
||||
initial := NewReferral(r.Name, dns.TypeA, ".", 0, 1.0, nil)
|
||||
initial.Addresses = roots
|
||||
initial.State = StateResolved
|
||||
|
||||
stack := NewStack(DefaultMaxDepth)
|
||||
stack.Push(initial)
|
||||
|
||||
traversalCache := NewInfoCache(nil)
|
||||
if visited != nil {
|
||||
for name := range visited {
|
||||
traversalCache.StoreGlue(name, []net.IP{})
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("resolution cancelled: %w", ctx.Err())
|
||||
default:
|
||||
}
|
||||
|
||||
ref := stack.Pop()
|
||||
if ref == nil {
|
||||
break
|
||||
}
|
||||
|
||||
cacheForStep := traversalCache
|
||||
if ref.Parent != nil {
|
||||
cacheForStep = traversalCache.Child()
|
||||
}
|
||||
|
||||
resp := traverser.processReferral(ctx, ref, cacheForStep)
|
||||
|
||||
if resp.Type == RespAnswer {
|
||||
if len(resp.Decoded.Answers) > 0 {
|
||||
var addrs []net.IP
|
||||
for _, rr := range resp.Decoded.Answers {
|
||||
if a, ok := rr.(*miekgdns.A); ok {
|
||||
addrs = append(addrs, a.A)
|
||||
}
|
||||
if aaaa, ok := rr.(*miekgdns.AAAA); ok {
|
||||
addrs = append(addrs, aaaa.AAAA)
|
||||
}
|
||||
}
|
||||
if len(addrs) > 0 {
|
||||
r.Addresses = addrs
|
||||
r.State = StateResolved
|
||||
if cache != nil {
|
||||
cache.StoreGlue(r.Name, addrs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Type == RespNXDOMAIN {
|
||||
lastErr = &UnresolvableNameserverError{
|
||||
Name: r.Name,
|
||||
Reason: "NXDOMAIN",
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if resp.Type == RespSERVFAIL || resp.Type == RespError {
|
||||
lastErr = fmt.Errorf("server error resolving %s: %s", r.Name, resp.Type)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.Type == RespReferral {
|
||||
children := resp.ChildReferrals()
|
||||
for _, child := range children {
|
||||
// Only skip visited names when they have no addresses; if glue
|
||||
// was included in the referral response we still need to query
|
||||
// that child to get the authoritative answer.
|
||||
if visited != nil && visited[child.Name] && !child.HasAddresses() {
|
||||
continue
|
||||
}
|
||||
if !stack.Push(child) {
|
||||
lastErr = &UnresolvableNameserverError{
|
||||
Name: r.Name,
|
||||
Reason: "max depth exceeded during resolution",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
|
||||
return &UnresolvableNameserverError{
|
||||
Name: r.Name,
|
||||
Reason: "resolution exhausted without answer",
|
||||
parts = append(parts, ip)
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func getVisitedNames(visited map[string]bool) []string {
|
||||
var names []string
|
||||
for name := range visited {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
func (r *Referral) String() string {
|
||||
return fmt.Sprintf("%s [%s/%s/%s] server=%s server_ips=%s bailiwick=%s",
|
||||
r.RefID, r.Qname, ClassToString(r.Qclass), TypeToString(r.Qtype),
|
||||
r.Server, r.TxtIPs(), r.Bailiwick)
|
||||
}
|
||||
|
||||
// IsNameInChain reports whether name appears anywhere in this referral's ancestor
|
||||
// chain, including this referral itself. Used for CNAME loop detection.
|
||||
func (r *Referral) IsNameInChain(name string) bool {
|
||||
n := miekgdns.Fqdn(strings.ToLower(name))
|
||||
curr := r
|
||||
for curr != nil {
|
||||
if curr.Name == n {
|
||||
// OverallStatus summarises the node's outcome for event consumers: a resolve
|
||||
// dead end (noglue/loop), the shared status of every per-IP response, or
|
||||
// "mixed" when the responses disagree ("" before anything was queried).
|
||||
func (r *Referral) OverallStatus() Status {
|
||||
switch r.Status {
|
||||
case RefStatusNoGlue:
|
||||
return StatusNoGlue
|
||||
case RefStatusLoop:
|
||||
return StatusLoop
|
||||
}
|
||||
var s Status
|
||||
for _, resp := range r.Responses {
|
||||
if s == "" {
|
||||
s = resp.Status
|
||||
} else if s != resp.Status {
|
||||
return "mixed"
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// StatsList returns the aggregated leaf statistics sorted by stats key.
|
||||
func (r *Referral) StatsList() []*StatsEntry {
|
||||
out := make([]*StatsEntry, 0, len(r.Stats))
|
||||
for _, e := range r.Stats {
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
// isNoGlue reports a dead end: the server is inside the current bailiwick,
|
||||
// so its address should have come as glue, but none was provided and no
|
||||
// deeper zone can name it (referral.rb noglue?).
|
||||
func (r *Referral) isNoGlue() bool {
|
||||
return r.ServerIPs == nil && insideBailiwick(r.Server, r.Bailiwick)
|
||||
}
|
||||
|
||||
// isLoop reports a resolve loop: an ancestor referral asks the same
|
||||
// qname/qclass/qtype of the same still-unresolved server (referral.rb loop?),
|
||||
// e.g. b NS c.d while d NS a.b.
|
||||
func (r *Referral) isLoop() bool {
|
||||
if r.ServerIPs != nil {
|
||||
return false
|
||||
}
|
||||
for p := r.Parent; p != nil; p = p.Parent {
|
||||
if p.Qname == r.Qname && p.Qclass == r.Qclass && p.Qtype == r.Qtype &&
|
||||
p.Server == r.Server && p.ServerIPs == nil {
|
||||
return true
|
||||
}
|
||||
curr = curr.Parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// chainHasQuery reports whether this referral or any ancestor already asks
|
||||
// qname with the same qclass/qtype. Used to stop cross-response CNAME chains
|
||||
// (restart loops) that Ruby only catches via the depth limit.
|
||||
func (r *Referral) chainHasQuery(qname string) bool {
|
||||
name := canonicalName(qname)
|
||||
for p := r; p != nil; p = p.Parent {
|
||||
if p.Qname == name && p.Qclass == r.Qclass && p.Qtype == r.Qtype {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolve turns an address-less referral into either a dead end (noglue/
|
||||
// loop) or a resolve subtree querying A <server> from this branch's cache
|
||||
// (referral.rb resolve). It returns the referrals to process.
|
||||
func (r *Referral) resolve() ([]*Referral, error) {
|
||||
if r.isNoGlue() {
|
||||
r.Status = RefStatusNoGlue
|
||||
return nil, nil
|
||||
}
|
||||
if r.isLoop() {
|
||||
r.Status = RefStatusLoop
|
||||
return nil, nil
|
||||
}
|
||||
starters, newbailiwick, err := r.InfoCache.GetStartServers(r.Server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, st := range starters {
|
||||
child := r.makeReferral(referralArgs{
|
||||
qname: r.Server,
|
||||
qtype: r.NSAType,
|
||||
server: st.Name,
|
||||
serverIPs: st.IPs,
|
||||
bailiwick: newbailiwick,
|
||||
refid: fmt.Sprintf("%s.0.%d", r.RefID, i+1),
|
||||
referralResolution: true,
|
||||
})
|
||||
r.Resolves = append(r.Resolves, child)
|
||||
}
|
||||
return r.Resolves, nil
|
||||
}
|
||||
|
||||
// resolveCalculate folds the resolve subtree's statistics into per-IP server
|
||||
// weights (referral.rb resolve_calculate): each answered leaf distributes
|
||||
// its probability evenly across the A records it returned; every other leaf
|
||||
// keeps its probability under its "key:" stats key so failures surface in
|
||||
// the results.
|
||||
func (r *Referral) resolveCalculate() {
|
||||
r.StatsResolve = make(map[string]*StatsEntry)
|
||||
switch r.Status {
|
||||
case RefStatusNoGlue:
|
||||
resp := NewNoGlueResponse(r.Qname, r.Qclass, r.Qtype, r.ParentIP, r.Server, r.Bailiwick)
|
||||
key := resp.StatsKey()
|
||||
r.StatsResolve[key] = &StatsEntry{Key: key, Prob: 1.0, Response: resp, Referral: r}
|
||||
case RefStatusLoop:
|
||||
resp := NewLoopResponse(r.Qname, r.Qclass, r.Qtype, r.ParentIP, r.Server, r.Bailiwick)
|
||||
key := resp.StatsKey()
|
||||
r.StatsResolve[key] = &StatsEntry{Key: key, Prob: 1.0, Response: resp, Referral: r}
|
||||
default:
|
||||
statsCalculateChildren(r.StatsResolve, r.Resolves, 1.0)
|
||||
}
|
||||
|
||||
r.ServerWeights = make(map[string]float64)
|
||||
r.ServerIPs = []string{}
|
||||
keys := make([]string, 0, len(r.StatsResolve))
|
||||
for key := range r.StatsResolve {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
addWeight := func(ip string, prob float64) {
|
||||
if _, ok := r.ServerWeights[ip]; !ok {
|
||||
r.ServerIPs = append(r.ServerIPs, ip)
|
||||
}
|
||||
r.ServerWeights[ip] += prob
|
||||
}
|
||||
for _, key := range keys {
|
||||
data := r.StatsResolve[key]
|
||||
if data.Response.Status == StatusAnswered {
|
||||
var addrs []string
|
||||
for _, rr := range data.Response.DQ.Answers {
|
||||
if a, ok := rr.(*miekgdns.A); ok {
|
||||
addrs = append(addrs, a.A.String())
|
||||
}
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
addWeight(addr, data.Prob/float64(len(addrs)))
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
// answered but no A records (e.g. AAAA-only): carry the
|
||||
// probability as a failure key so mass is not lost.
|
||||
addWeight(key, data.Prob)
|
||||
}
|
||||
} else {
|
||||
addWeight(key, data.Prob)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// statsCalculateChildren merges the children's statistics into stats with an
|
||||
// equal split of weight among them (referral.rb stats_calculate_children).
|
||||
func statsCalculateChildren(stats map[string]*StatsEntry, children []*Referral, weight float64) {
|
||||
if len(children) == 0 {
|
||||
return
|
||||
}
|
||||
percent := (1.0 / float64(len(children))) * weight
|
||||
for _, child := range children {
|
||||
for key, data := range child.Stats {
|
||||
if e, ok := stats[key]; ok {
|
||||
e.Prob += data.Prob * percent
|
||||
} else {
|
||||
stats[key] = &StatsEntry{
|
||||
Key: key,
|
||||
Prob: data.Prob * percent,
|
||||
Response: data.Response,
|
||||
Referral: data.Referral,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// answerCalculate computes this node's aggregated statistics from its
|
||||
// children, responses and resolve failures (referral.rb answer_calculate).
|
||||
// Unlike the Ruby source, duplicate stats keys across a referral's IPs merge
|
||||
// by summing probability (Ruby computes the sum then discards it — a source
|
||||
// bug that breaks the probabilities-sum-to-1 invariant).
|
||||
func (r *Referral) answerCalculate() {
|
||||
r.Stats = make(map[string]*StatsEntry)
|
||||
if r.IsRootRoot() {
|
||||
statsCalculateChildren(r.Stats, r.Children["rootroot"], 1.0)
|
||||
r.calculated = true
|
||||
return
|
||||
}
|
||||
for _, ip := range r.ServerIPs {
|
||||
serverweight := r.ServerWeights[ip]
|
||||
if strings.HasPrefix(ip, "key:") {
|
||||
// resolve failed for some reason - copy the resolve statistics
|
||||
src := r.StatsResolve[ip]
|
||||
if e, ok := r.Stats[ip]; ok {
|
||||
e.Prob += src.Prob
|
||||
} else {
|
||||
r.Stats[ip] = &StatsEntry{Key: ip, Prob: src.Prob, Response: src.Response, Referral: src.Referral}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if children := r.Children[ip]; len(children) > 0 {
|
||||
statsCalculateChildren(r.Stats, children, serverweight)
|
||||
continue
|
||||
}
|
||||
resp := r.Responses[ip]
|
||||
if resp == nil {
|
||||
continue
|
||||
}
|
||||
key := resp.StatsKey()
|
||||
if e, ok := r.Stats[key]; ok {
|
||||
e.Prob += serverweight
|
||||
} else {
|
||||
r.Stats[key] = &StatsEntry{Key: key, Prob: serverweight, Response: resp, Referral: r}
|
||||
}
|
||||
}
|
||||
r.calculated = true
|
||||
}
|
||||
|
||||
// process queries every real IP of this referral through the packet cache,
|
||||
// classifies each response, and creates one child per NS name (including
|
||||
// glueless ones) for referral/restart statuses (referral.rb process/
|
||||
// process_normal). It returns one set of children per IP that produced any.
|
||||
func (r *Referral) process(ctx context.Context) ([][]*Referral, error) {
|
||||
r.processed = true
|
||||
if r.IsRootRoot() {
|
||||
children, err := r.processAddRoots()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return [][]*Referral{children}, nil
|
||||
}
|
||||
|
||||
// Phase one: query and classify, counting childsets so refids can grow
|
||||
// an extra childset digit when more than one IP produces children.
|
||||
childsets := 0
|
||||
var order []string
|
||||
for _, ip := range r.ServerIPs {
|
||||
if strings.HasPrefix(ip, "key:") {
|
||||
continue
|
||||
}
|
||||
var dq *DecodedQuery
|
||||
if r.Depth() >= r.maxdepth {
|
||||
err := fmt.Errorf("Maxdepth %d exceeded", r.maxdepth)
|
||||
dq = NewDecodedQuery(nil, err, r.Qname, r.Qclass, r.Qtype, ip, r.Bailiwick)
|
||||
} else {
|
||||
msg, warnings, err := r.client.Query(ctx, net.ParseIP(ip), r.Qname, r.Qtype)
|
||||
dq = NewDecodedQuery(msg, err, r.Qname, r.Qclass, r.Qtype, ip, r.Bailiwick)
|
||||
dq.WarningsAdd(warnings...)
|
||||
}
|
||||
resp, err := NewServerResponse(dq, r.Server, r.ParentIP, r.InfoCache)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Status == StatusRestart {
|
||||
// Cross-response CNAME loop: any target in the chain that we
|
||||
// (or an ancestor) are already querying is a dead end.
|
||||
for _, target := range dq.ChainTargets {
|
||||
if r.chainHasQuery(target) {
|
||||
resp.Status = StatusCNAMELoop
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
r.Warnings = append(r.Warnings, dq.Warnings...)
|
||||
r.Responses[ip] = resp
|
||||
order = append(order, ip)
|
||||
if resp.Status == StatusRestart || resp.Status == StatusReferral {
|
||||
childsets++
|
||||
}
|
||||
}
|
||||
|
||||
// Phase two: create the children.
|
||||
childset := 0
|
||||
var sets [][]*Referral
|
||||
for _, ip := range order {
|
||||
resp := r.Responses[ip]
|
||||
if resp.Status != StatusRestart && resp.Status != StatusReferral {
|
||||
continue
|
||||
}
|
||||
childset++
|
||||
refid := r.RefID
|
||||
if childsets > 1 {
|
||||
refid = fmt.Sprintf("%s.%d", r.RefID, childset)
|
||||
}
|
||||
children := r.makeReferrals(resp, refid, ip)
|
||||
r.Children[ip] = children
|
||||
sets = append(sets, children)
|
||||
}
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// processAddRoots creates one child per root server with equal weight
|
||||
// (referral.rb process_add_roots); the roots come from the info cache hints.
|
||||
func (r *Referral) processAddRoots() ([]*Referral, error) {
|
||||
starters, _, err := r.InfoCache.GetStartServers("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dot := ""
|
||||
if r.RefID != "" {
|
||||
dot = "."
|
||||
}
|
||||
var children []*Referral
|
||||
for i, root := range starters {
|
||||
child := r.makeReferral(referralArgs{
|
||||
server: root.Name,
|
||||
serverIPs: root.IPs,
|
||||
refid: fmt.Sprintf("%s%s%d", r.RefID, dot, i+1),
|
||||
})
|
||||
children = append(children, child)
|
||||
}
|
||||
r.Children["rootroot"] = children
|
||||
return children, nil
|
||||
}
|
||||
|
||||
// makeReferrals creates one child per start server for a referral/restart
|
||||
// response (referral.rb make_referrals): qname moves to the response's
|
||||
// endname (the CNAME target on restart), the bailiwick and cache come from
|
||||
// the response.
|
||||
func (r *Referral) makeReferrals(resp *ServerResponse, refid, parentIP string) []*Referral {
|
||||
var children []*Referral
|
||||
for i, st := range resp.Starters {
|
||||
children = append(children, r.makeReferral(referralArgs{
|
||||
qname: resp.DQ.Endname,
|
||||
server: st.Name,
|
||||
serverIPs: st.IPs,
|
||||
bailiwick: resp.StartersBailiwick,
|
||||
infoCache: resp.Cache,
|
||||
refid: fmt.Sprintf("%s.%d", refid, i+1),
|
||||
parentIP: parentIP,
|
||||
}))
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
// replaceChild swaps before for after in the children/resolves lists (fast
|
||||
// mode substitution); before keeps a pointer to its replacement.
|
||||
func (r *Referral) replaceChild(before, after *Referral) {
|
||||
before.ReplacedBy = after
|
||||
for ip := range r.Children {
|
||||
for i, c := range r.Children[ip] {
|
||||
if c == before {
|
||||
r.Children[ip][i] = after
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, c := range r.Resolves {
|
||||
if c == before {
|
||||
r.Resolves[i] = after
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user