package checker import ( "encoding/json" "github.com/miekg/dns" ) // ObservationKeyResolverPropagation is the observation key used to store data // produced by this checker. const ObservationKeyResolverPropagation = "resolver_propagation" // Severity classifies a finding. type Severity string const ( SeverityInfo Severity = "info" SeverityWarn Severity = "warn" SeverityCrit Severity = "crit" ) // Finding codes: stable machine-readable identifiers surfaced in the UI. const ( // Zone-wide. CodeNoResolvers = "rprop_no_resolvers" CodeAllResolversDown = "rprop_all_resolvers_down" CodeSerialDrift = "rprop_serial_drift" CodeStaleCache = "rprop_stale_cache" CodeDNSSECFailure = "rprop_dnssec_failure" CodeDNSSECUnvalidated = "rprop_dnssec_not_validated" CodeRegionalSplit = "rprop_regional_split" CodePartialPropagation = "rprop_partial_propagation" CodeAnswerDrift = "rprop_answer_drift" CodeUnexpectedNXDOMAIN = "rprop_unexpected_nxdomain" CodeUnexpectedSERVFAIL = "rprop_unexpected_servfail" // Per-resolver. CodeResolverUnreachable = "rprop_resolver_unreachable" CodeResolverTimeout = "rprop_resolver_timeout" CodeResolverRewrote = "rprop_resolver_rewrote_answer" CodeResolverFilteredHit = "rprop_resolver_filtered_hit" CodeResolverHighLatency = "rprop_resolver_high_latency" ) // Transport identifies the protocol used to reach a resolver. type Transport string const ( TransportUDP Transport = "udp" TransportTCP Transport = "tcp" TransportDoT Transport = "dot" TransportDoH Transport = "doh" ) // Finding is a single observation produced during collection. type Finding struct { Code string `json:"code"` Severity Severity `json:"severity"` Message string `json:"message"` // Resolver is the resolver ID when the finding is scoped to one. Resolver string `json:"resolver,omitempty"` // RRset is "name/TYPE" when the finding is scoped to one RR set. RRset string `json:"rrset,omitempty"` // Remedy is a short, user-facing sentence describing what to do. Remedy string `json:"remedy,omitempty"` } // RRProbe is the observation for a single (resolver, RRset) pair. type RRProbe struct { // Rcode is the response rcode in text form (NOERROR / NXDOMAIN / // SERVFAIL / REFUSED / …). Empty when the probe failed before a // response was parsed. Rcode string `json:"rcode,omitempty"` // Signature is the sorted, TTL-stripped RDATA joined with "|". Two // resolvers agree on an answer iff their signatures are equal. Signature string `json:"signature,omitempty"` // Records is the list of record RDATA strings as returned by the // resolver, sorted. Records []string `json:"records,omitempty"` // MinTTL is the smallest TTL across the RRset. Useful to spot stale // caches (TTL close to 0 means the resolver just refreshed). MinTTL uint32 `json:"min_ttl,omitempty"` // AD indicates the resolver set the AD bit on the response (DNSSEC // validated). Only meaningful on AD-capable resolvers. AD bool `json:"ad,omitempty"` // LatencyMs is the observed round-trip time in milliseconds. LatencyMs int64 `json:"latency_ms,omitempty"` // Transport is the protocol used for this probe. Transport Transport `json:"transport,omitempty"` // Error describes a transport/protocol failure. Set means the probe // did not complete and Rcode/Signature are empty. Error string `json:"error,omitempty"` } // ResolverView aggregates every probe performed against a single resolver. type ResolverView struct { ID string `json:"id"` Name string `json:"name"` IP string `json:"ip"` Region string `json:"region"` Filtered bool `json:"filtered,omitempty"` Transport Transport `json:"transport"` // Reachable is true when at least one probe against this resolver // produced a valid response (any rcode, including NXDOMAIN). Reachable bool `json:"reachable"` // Probes is one RRProbe per "name/TYPE" string. Probes map[string]*RRProbe `json:"probes,omitempty"` } // RRsetView is the cross-resolver picture of a single (name, type): which // signatures were seen, which resolvers returned each signature, and which // one we pick as "consensus". The consensus is the most-returned signature // from unfiltered, reachable resolvers. type RRsetView struct { Name string `json:"name"` Type string `json:"type"` // Expected is the signature computed from the user's declared zone. Used // to distinguish "resolvers disagree with each other" from "resolvers // agree but are wrong". Expected string `json:"expected,omitempty"` ExpectedRecords []string `json:"expected_records,omitempty"` // Groups buckets resolvers by signature. Groups []SignatureGroup `json:"groups,omitempty"` // ConsensusSig is the signature returned by the majority of unfiltered // reachable resolvers. ConsensusSig string `json:"consensus_sig,omitempty"` // Agreeing / Dissenting are resolver IDs relative to the consensus. Agreeing []string `json:"agreeing,omitempty"` Dissenting []string `json:"dissenting,omitempty"` // MatchesExpected is true when the consensus matches the expected // signature. When Expected is empty we skip this check. MatchesExpected bool `json:"matches_expected"` } // SignatureGroup is one bucket in RRsetView: a signature + its records + the // resolvers that returned it. type SignatureGroup struct { Signature string `json:"signature"` Records []string `json:"records,omitempty"` Resolvers []string `json:"resolvers,omitempty"` Rcode string `json:"rcode,omitempty"` } // ResolverPropagationData is the top-level observation payload. type ResolverPropagationData struct { Zone string `json:"zone"` // Names lists the owner names probed: apex + user-provided subdomains. Names []string `json:"names"` // Types lists the RR types probed (text: "A", "AAAA", "MX", …). Types []string `json:"types"` // Resolvers is the per-resolver view, keyed by resolver ID. Resolvers map[string]*ResolverView `json:"resolvers,omitempty"` // RRsets is the per-RRset cross-resolver view, keyed by "name/TYPE". RRsets map[string]*RRsetView `json:"rrsets,omitempty"` // DeclaredSerial is the SOA serial saved by happyDomain (when available). DeclaredSerial uint32 `json:"declared_serial,omitempty"` // RunDurationMs is the wall-clock duration of the probe round. RunDurationMs int64 `json:"run_duration_ms,omitempty"` // Stats summarizes the run. Stats Stats `json:"stats"` } // Stats is a rollup of resolver health, useful for the dashboard. type Stats struct { TotalResolvers int `json:"total_resolvers"` ReachableResolvers int `json:"reachable_resolvers"` UnfilteredProbed int `json:"unfiltered_probed"` FilteredProbed int `json:"filtered_probed"` CountriesCovered int `json:"countries_covered"` UnfilteredAgreeing int `json:"unfiltered_agreeing"` } // originService mirrors happyDomain's abstract.Origin payload (same shape as // checker-propagation). We only need the NS list + SOA to detect "this zone // is supposed to exist". type originService struct { SOA *dns.SOA `json:"soa,omitempty"` NameServers []*dns.NS `json:"ns"` } // serviceMessage mirrors happyDomain's ServiceMessage envelope. type serviceMessage struct { Type string `json:"_svctype"` Domain string `json:"_domain"` Service json.RawMessage `json:"Service"` } // rrsetKey builds the "name/TYPE" identifier used to index RRsets. func rrsetKey(name, typ string) string { return dns.Fqdn(name) + "/" + typ }