package checker import ( "encoding/json" "fmt" "github.com/miekg/dns" ) // maxNSResultErrors caps the per-NS error list so a flaky server with many // addresses cannot bloat the JSON observation payload. Once the cap is // reached, further errors are dropped and a single sentinel entry records the // number of suppressed messages. const maxNSResultErrors = 16 // ObservationKey is the observation key for observation data. const ObservationKey = "authoritative-consistency" // Severity classifies a finding emitted by the authoritative-consistency checker. type Severity string const ( SeverityInfo Severity = "info" SeverityWarn Severity = "warn" SeverityCrit Severity = "crit" ) // Finding codes: stable machine-readable identifiers used by the UI to // localize and link to remediation docs. const ( CodeSerialDrift = "authoritative_consistency_serial_drift" CodeSerialStaleVsSaved = "authoritative_consistency_serial_stale_vs_saved" CodeSerialAheadOfSaved = "authoritative_consistency_serial_ahead_of_saved" CodeNSUnreachable = "authoritative_consistency_ns_unreachable" CodeNSUDPFailed = "authoritative_consistency_ns_udp_failed" CodeNSTCPFailed = "authoritative_consistency_ns_tcp_failed" CodeNSUnresolvable = "authoritative_consistency_ns_unresolvable" CodeLame = "authoritative_consistency_lame" CodeNoSOA = "authoritative_consistency_no_soa" CodeNSRRsetDrift = "authoritative_consistency_ns_rrset_drift" CodeNSRRsetMismatchConfig = "authoritative_consistency_ns_rrset_mismatch_config" CodeParentDrift = "authoritative_consistency_parent_drift" CodeParentQueryFailed = "authoritative_consistency_parent_query_failed" CodeSOAFieldsDrift = "authoritative_consistency_soa_fields_drift" CodeSlowNS = "authoritative_consistency_slow_ns" CodeEDNSUnsupported = "authoritative_consistency_edns_unsupported" CodeTooFewNS = "authoritative_consistency_too_few_ns" CodeNoNS = "authoritative_consistency_no_ns" ) // Finding describes a single observation produced while running the // checker testsuite. type Finding struct { // Code is a stable machine-readable identifier (e.g. // "authoritative_consistency_serial_drift"). Code string `json:"code"` // Severity grades the finding. Severity Severity `json:"severity"` // Message is a human-readable explanation. Message string `json:"message"` // Server is the name server the finding applies to, when the issue is // scoped to a specific NS. Empty for zone-wide findings. Server string `json:"server,omitempty"` // Addr is the IP:port actually queried when the issue was raised. Useful // to distinguish IPv4/IPv6 problems on the same NS name. Addr string `json:"addr,omitempty"` } // NSResult is the per-name-server view of the zone, populated during Collect. // It carries every signal the evaluator needs to decide whether the zone is // propagated correctly, plus what the UI needs to render an actionable // report. type NSResult struct { // Name is the NS hostname (FQDN, lowercase). Name string `json:"name"` // Addresses is the list of A/AAAA addresses tried for this NS. Addresses []string `json:"addresses,omitempty"` // ResolveError is set when no address could be resolved for this NS. ResolveError string `json:"resolve_error,omitempty"` // UDPReachable is true when the NS answered at least once over UDP/53. UDPReachable bool `json:"udp_reachable"` // TCPReachable is true when the NS answered at least once over TCP/53. TCPReachable bool `json:"tcp_reachable"` // Authoritative is true when at least one authoritative (AA=1) answer // was received for the zone. Authoritative bool `json:"authoritative"` // Serial is the SOA serial returned by this NS (0 when not reachable or // the answer does not carry a SOA). Serial uint32 `json:"serial,omitempty"` // SOA is the full SOA RR returned by this NS, useful for per-field // comparison in the report. SOA *dns.SOA `json:"soa,omitempty"` // NSRRset is the NS RRset this server returns for the zone (lowercase // FQDNs). NSRRset []string `json:"ns_rrset,omitempty"` // EDNSSupported is true when the NS answered correctly to an EDNS0 // query. EDNSSupported bool `json:"edns_supported"` // LatencyMs is the duration (milliseconds) of the SOA query used for // the reachability test. 0 when not reachable. LatencyMs int64 `json:"latency_ms,omitempty"` // Errors collects low-level query errors encountered while probing this // NS. Exposed to help operators debug network/firewall issues. Capped // at maxNSResultErrors entries; appendError is the only intended writer. Errors []string `json:"errors,omitempty"` // suppressedErrors counts the messages that were dropped after the cap // was reached. Reflected back into Errors as a sentinel line so the // operator knows the list is truncated. suppressedErrors int } // appendError records a probe error on the NS result, deduplicating identical // messages and capping the total to maxNSResultErrors. Suppressed entries are // summarised in a trailing sentinel. func (n *NSResult) appendError(format string, args ...any) { msg := fmt.Sprintf(format, args...) for _, e := range n.Errors { if e == msg { return } } if len(n.Errors) >= maxNSResultErrors { n.suppressedErrors++ sentinel := fmt.Sprintf("(%d more error(s) suppressed)", n.suppressedErrors) // Replace the previous sentinel in place when present. if last := len(n.Errors) - 1; last >= 0 && len(n.Errors[last]) > 0 && n.Errors[last][0] == '(' { n.Errors[last] = sentinel return } n.Errors = append(n.Errors, sentinel) return } n.Errors = append(n.Errors, msg) } // ObservationData is the observation payload stored by the checker. It // carries every finding emitted by the testsuite plus the raw observed state // from each authoritative server. type ObservationData struct { // Zone is the FQDN of the zone under test. Zone string `json:"zone"` // HasSOA indicates whether the service declares a SOA record (Origin // versus NSOnlyOrigin). Drives which tests run. HasSOA bool `json:"has_soa"` // DeclaredSerial is the SOA serial saved in happyDomain for this zone. // Zero when the service is an NSOnlyOrigin. DeclaredSerial uint32 `json:"declared_serial,omitempty"` // DeclaredNS is the list of NS hostnames declared by the service, // lowercased and FQDN-normalized. DeclaredNS []string `json:"declared_ns,omitempty"` // ParentNS is the list of NS hostnames returned by the parent zone's // referral, when parent discovery is enabled. Empty when the parent // query is disabled or failed (see ParentQueryError). ParentNS []string `json:"parent_ns,omitempty"` // ParentQueryError is set when the parent referral query failed. ParentQueryError string `json:"parent_query_error,omitempty"` // Probed is the final list of NS names that were actually probed // (union of DeclaredNS and ParentNS, de-duplicated). Probed []string `json:"probed,omitempty"` // Results holds the per-NS probe results, keyed by NS hostname. Results map[string]*NSResult `json:"results,omitempty"` // Findings is the list of issues / observations produced by the run, // ordered by (severity desc, code asc, server asc). Findings []Finding `json:"findings"` } // originService is the minimal local mirror of happyDomain's // `services/abstract.Origin` type. It is duplicated on purpose so that this // checker does not have to import the (heavy) happyDomain server module // just to decode the service payload. github.com/miekg/dns marshals // dns.SOA / dns.NS to JSON in the same shape happyDomain uses. type originService struct { SOA *dns.SOA `json:"soa,omitempty"` NameServers []*dns.NS `json:"ns"` } // serviceMessage is the minimal local mirror of happyDomain's ServiceMessage // envelope. We only need the embedded service JSON and the type tag; the // rest of the meta fields are ignored. type serviceMessage struct { Type string `json:"_svctype"` Domain string `json:"_domain"` Service json.RawMessage `json:"Service"` }