package resolver_test import ( "context" "errors" "fmt" "slices" "sort" "strings" "testing" "sneak.berlin/go/dnswatcher/internal/livedns" "sneak.berlin/go/dnswatcher/internal/resolver" ) // ---------------------------------------------------------------- // Live DNS test support // ---------------------------------------------------------------- // // Every test in this package resolves against the real, live DNS — // see TESTING.md. Each live operation below goes through // livedns.Retry, which bounds how many resolutions are in flight at // once and retries an operation that got no answer (see package // livedns). // // Where an assertion spans several independent nameservers, a quorum // is enough: a strict majority answering as expected. A server that // fails to answer is tolerated, while a server that answers *wrongly* // still fails the test. // // That tolerance is expressed as an ALLOWLIST of sanctioned statuses, // never as a blocklist of known-bad ones. A blocklist bans the one // wrong answer its author thought of and silently admits every other // status, including any added to the resolver later; an allowlist // fails on anything nobody explicitly sanctioned. Silence (timeout, // error) is the only thing quorum exists to tolerate. A *wrong // answer* — nxdomain for a name that exists, ok for one that does // not, nodata for either — is never tolerated at any count. // minNameservers is the smallest nameserver count a well-run zone is // expected to publish. const minNameservers = 2 // errLiveNoQuorum reports that too few of a domain's nameservers // answered for a quorum assertion to be made. var errLiveNoQuorum = errors.New("no nameserver quorum") // liveQuorum is how many of total nameservers must agree for a // multi-nameserver assertion to hold: a strict majority. func liveQuorum(total int) int { if total < 1 { return 1 } return total/2 + 1 } // countStatus counts the responses carrying the given status. func countStatus( results map[string]*resolver.NameserverResponse, status string, ) int { n := 0 for _, resp := range results { if resp.Status == status { n++ } } return n } // liveAnswerStatuses is the closed set of statuses that count as a // nameserver having ANSWERED at all, whether or not the test agrees // with the answer. It is deliberately an allowlist: a status added // to the resolver later is treated as silence, so it can only ever // cause a retry and then a loud failure, never a quiet pass. func liveAnswerStatuses() []string { return []string{ resolver.StatusOK, resolver.StatusNXDomain, resolver.StatusNoData, } } // answeredCount counts the nameservers that produced an answer of // any kind, as opposed to failing or timing out. func answeredCount( results map[string]*resolver.NameserverResponse, ) int { answers := liveAnswerStatuses() n := 0 for _, resp := range results { if slices.Contains(answers, resp.Status) { n++ } } return n } // unsanctionedStatuses returns "nameserver=status" for every result // whose status the caller did not explicitly sanction, sorted for a // stable failure message. Callers pass the full closed set they will // accept — the expected answer plus whichever non-answers (timeout, // error) quorum is allowed to tolerate — so that any status outside // it fails the test by name. func unsanctionedStatuses( results map[string]*resolver.NameserverResponse, allowed ...string, ) []string { offenders := make([]string, 0, len(results)) for ns, resp := range results { if slices.Contains(allowed, resp.Status) { continue } offenders = append( offenders, fmt.Sprintf("%s=%s", ns, resp.Status), ) } sort.Strings(offenders) return offenders } // describeStatuses renders per-nameserver statuses for use in // assertion failure messages. func describeStatuses( results map[string]*resolver.NameserverResponse, ) string { parts := make([]string, 0, len(results)) for ns, resp := range results { parts = append( parts, fmt.Sprintf("%s=%s", ns, resp.Status), ) } sort.Strings(parts) return strings.Join(parts, " ") } // ---------------------------------------------------------------- // Live operation wrappers // ---------------------------------------------------------------- // liveFindAuthoritative resolves a domain's authoritative // nameservers, retrying until the delegation chain can be walked. func liveFindAuthoritative( t *testing.T, r *resolver.Resolver, domain string, ) []string { t.Helper() var out []string livedns.Retry( t, "FindAuthoritativeNameservers("+domain+")", func(ctx context.Context) error { ns, err := r.FindAuthoritativeNameservers(ctx, domain) if err != nil { return err } if len(ns) == 0 { return fmt.Errorf( "%w: %s has no nameservers", livedns.ErrNoAnswer, domain, ) } out = ns return nil }, ) return out } // liveLookupNS is liveFindAuthoritative through the LookupNS entry // point, so that both entry points stay independently exercised. func liveLookupNS( t *testing.T, r *resolver.Resolver, domain string, ) []string { t.Helper() var out []string livedns.Retry( t, "LookupNS("+domain+")", func(ctx context.Context) error { ns, err := r.LookupNS(ctx, domain) if err != nil { return err } if len(ns) == 0 { return fmt.Errorf( "%w: %s has no nameservers", livedns.ErrNoAnswer, domain, ) } out = ns return nil }, ) return out } // liveQueryNameserver queries one nameserver, retrying while that // nameserver fails to answer. NXDOMAIN and NODATA are answers and // are returned to the caller to assert on. func liveQueryNameserver( t *testing.T, r *resolver.Resolver, nameserver string, hostname string, ) *resolver.NameserverResponse { t.Helper() what := fmt.Sprintf( "QueryNameserver(%s, %s)", nameserver, hostname, ) var out *resolver.NameserverResponse livedns.Retry( t, what, func(ctx context.Context) error { resp, err := r.QueryNameserver( ctx, nameserver, hostname, ) if err != nil { return err } if resp.Status == resolver.StatusTimeout || resp.Status == resolver.StatusError { return fmt.Errorf( "%w: %s returned %s: %s", livedns.ErrNoAnswer, nameserver, resp.Status, resp.Error, ) } out = resp return nil }, ) return out } // liveQueryAllNameservers queries every authoritative nameserver // for a hostname, retrying until a quorum of them has answered. // Individual nameservers that stay silent are left in the result // for the caller to account for. func liveQueryAllNameservers( t *testing.T, r *resolver.Resolver, hostname string, ) map[string]*resolver.NameserverResponse { t.Helper() var out map[string]*resolver.NameserverResponse livedns.Retry( t, "QueryAllNameservers("+hostname+")", func(ctx context.Context) error { results, err := r.QueryAllNameservers(ctx, hostname) if err != nil { return err } if len(results) == 0 { return fmt.Errorf( "%w: no nameservers queried for %s", livedns.ErrNoAnswer, hostname, ) } answered := answeredCount(results) if answered < liveQuorum(len(results)) { return fmt.Errorf( "%w: %d of %d answered: %s", errLiveNoQuorum, answered, len(results), describeStatuses(results), ) } out = results return nil }, ) return out } // liveResolveIPs resolves a hostname that is expected to have // addresses, retrying until at least one is returned. func liveResolveIPs( t *testing.T, r *resolver.Resolver, hostname string, ) []string { t.Helper() var out []string livedns.Retry( t, "ResolveIPAddresses("+hostname+")", func(ctx context.Context) error { ips, err := r.ResolveIPAddresses(ctx, hostname) if err != nil { return err } if len(ips) == 0 { return fmt.Errorf( "%w: no addresses for %s", livedns.ErrNoAnswer, hostname, ) } out = ips return nil }, ) return out } // liveResolveIPsAllowingEmpty resolves a hostname that may legitimately // have no addresses, so the empty result is returned rather than // retried. Used for names that must not exist; the corresponding // QueryAllNameservers test is what proves the nameservers actively // said NXDOMAIN rather than merely staying silent. func liveResolveIPsAllowingEmpty( t *testing.T, r *resolver.Resolver, hostname string, ) []string { t.Helper() var out []string livedns.Retry( t, "ResolveIPAddresses("+hostname+")", func(ctx context.Context) error { ips, err := r.ResolveIPAddresses(ctx, hostname) if err != nil { return err } out = ips return nil }, ) return out }