diff --git a/.drone-manifest.yml b/.drone-manifest.yml deleted file mode 100644 index 3c50a1c..0000000 --- a/.drone-manifest.yml +++ /dev/null @@ -1,22 +0,0 @@ -image: happydomain/checker-dangling:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} -{{#if build.tags}} -tags: -{{#each build.tags}} - - {{this}} -{{/each}} -{{/if}} -manifests: - - image: happydomain/checker-dangling:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 - platform: - architecture: amd64 - os: linux - - image: happydomain/checker-dangling:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 - platform: - architecture: arm64 - os: linux - variant: v8 - - image: happydomain/checker-dangling:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm - platform: - architecture: arm - os: linux - variant: v7 diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index e5bf907..0000000 --- a/.drone.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -kind: pipeline -type: docker -name: build-amd64 - -platform: - os: linux - arch: amd64 - -steps: - - name: checker build - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" - CGO_ENABLED: 0 - when: - event: - exclude: - - tag - - - name: checker build tag - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_SEMVER}" - CGO_ENABLED: 0 - when: - event: - - tag - - - name: publish on Docker Hub - image: plugins/docker - settings: - repo: happydomain/checker-dangling - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - exclude: - - tag - - - name: publish on Docker Hub (tag) - image: plugins/docker - settings: - repo: happydomain/checker-dangling - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_SEMVER} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - - tag - -trigger: - branch: - exclude: - - renovate/* - event: - - cron - - push - - tag - ---- -kind: pipeline -type: docker -name: build-arm64 - -platform: - os: linux - arch: arm64 - -steps: - - name: checker build - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" - CGO_ENABLED: 0 - when: - event: - exclude: - - tag - - - name: checker build tag - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_SEMVER}" - CGO_ENABLED: 0 - when: - event: - - tag - - - name: publish on Docker Hub - image: plugins/docker - settings: - repo: happydomain/checker-dangling - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - exclude: - - tag - - - name: publish on Docker Hub (tag) - image: plugins/docker - settings: - repo: happydomain/checker-dangling - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_SEMVER} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - - tag - -trigger: - event: - - cron - - push - - tag - ---- -kind: pipeline -name: docker-manifest - -platform: - os: linux - arch: arm64 - -steps: - - name: publish on Docker Hub - image: plugins/manifest - settings: - auto_tag: true - ignore_missing: true - spec: .drone-manifest.yml - username: - from_secret: docker_username - password: - from_secret: docker_password - -trigger: - branch: - exclude: - - renovate/* - event: - - cron - - push - - tag - -depends_on: - - build-amd64 - - build-arm64 diff --git a/checker/collect.go b/checker/collect.go index ee326e3..517c657 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -17,12 +17,21 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// resolverTimeout caps each lookup so a blackholed nameserver cannot stall the whole scan. +// resolverTimeout caps each individual lookup so a slow / blackholed +// authoritative server cannot stall a zone scan. Set conservatively: +// the host can re-run the check at any time, and a deadline beats a +// hang. const resolverTimeout = 4 * time.Second -// resolveHost is a package-level var so tests can stub DNS without hitting the network. +// resolveHost is the function used to classify a target. It is a +// package-level variable so tests can stub it deterministically without +// reaching the network. var resolveHost = defaultResolveHost +// Collect walks the working zone, extracts every pointer record +// (CNAME / MX / SRV / NS), classifies each target as in-zone or +// external relative to the zone's registrable domain, and resolves +// each target on the live DNS to detect immediate breakage. func (p *danglingProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { if err := ctx.Err(); err != nil { return nil, err @@ -88,8 +97,11 @@ func (p *danglingProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) return data, nil } -// DiscoverEntries emits in-zone pointers too so future reachability checkers can subscribe, -// even though this checker ignores observations attached to them. +// DiscoverEntries publishes one DiscoveryEntry per external pointer so +// a subscriber (typically domain_expiry) can RDAP/WHOIS each target's +// registrable domain. In-zone pointers also get an entry so future +// reachability checkers can subscribe; this checker does not currently +// rely on observations attached to those entries. func (p *danglingProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { d, ok := data.(*DanglingData) if !ok || d == nil { @@ -124,7 +136,7 @@ func (p *danglingProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, erro return out, nil } -// readZone normalises the zone option (native struct or JSON). +// readZone normalises the zone option (native struct or JSON object). func readZone(opts sdk.CheckerOptions) (*rawZone, error) { v, ok := opts["zone"] if !ok || v == nil { @@ -141,8 +153,11 @@ func readZone(opts sdk.CheckerOptions) (*rawZone, error) { return z, nil } -// extractPointers returns pointer records from one service body. -// Unrecognised service shapes return (nil, nil) to avoid polluting CollectErrors for A/AAAA/TXT zones. +// extractPointers walks one service body and returns every +// (owner, rrtype, target) triple it carries. It is best-effort: +// services that do not match any known pointer shape return (nil, nil) +// so the common case of a pure A/AAAA/TXT zone produces no noise in +// CollectErrors. func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { if len(svc.Service) == 0 { return nil, nil @@ -159,7 +174,7 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { if target == "" { return nil, nil } - ptOwner := preferRRName(b.Record.Hdr.Name, owner, apex) + ptOwner := preferRRName(b.Record.Hdr.Name, owner) return []Pointer{{ Owner: ptOwner, Subdomain: sub, @@ -180,7 +195,7 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { continue } out = append(out, Pointer{ - Owner: preferRRName(r.Hdr.Name, owner, apex), + Owner: preferRRName(r.Hdr.Name, owner), Subdomain: sub, Rrtype: "MX", Target: target, @@ -201,7 +216,7 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { continue } out = append(out, Pointer{ - Owner: preferRRName(r.Hdr.Name, owner, apex), + Owner: preferRRName(r.Hdr.Name, owner), Subdomain: sub, Rrtype: "SRV", Target: target, @@ -215,7 +230,7 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { if err := json.Unmarshal(svc.Service, &b); err != nil { return nil, fmt.Errorf("decode orphan body: %w", err) } - ptOwner := preferRRName(b.Record.Hdr.Name, owner, apex) + ptOwner := preferRRName(b.Record.Hdr.Name, owner) switch b.Record.Hdr.Rrtype { case dns.TypeNS: target := normaliseTarget(b.Record.Ns, ptOwner, apex) @@ -260,9 +275,12 @@ func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) { return nil, nil } -// classifyExternal marks pt.External/Registrable via eTLD+1. -// For non-PSL names (e.g. ".internal") it falls back to suffix comparison, which treats -// sub-zones of the same registrable as in-zone — acceptable given the edge-case scope. +// classifyExternal sets pt.External and pt.Registrable based on +// publicsuffix-derived eTLD+1. When publicsuffix cannot resolve an +// eTLD+1 (e.g. internal TLD), we fall back to suffix-comparing the +// target against the zone's registrable name. This fallback is +// imprecise for sub-zones (a target under the parent registrable will +// be treated as in-zone), but it is only reached for non-PSL names. func classifyExternal(pt *Pointer, zoneRegistrable string) { target := strings.TrimSuffix(pt.Target, ".") if target == "" { @@ -270,7 +288,8 @@ func classifyExternal(pt *Pointer, zoneRegistrable string) { } reg, err := publicsuffix.EffectiveTLDPlusOne(target) if err != nil { - // Fall back to suffix comparison for non-PSL names (e.g. ".internal"). + // Fall back to suffix comparison when target is not a + // PSL-known name (e.g. ".internal", ".lan"). suffix := strings.TrimSuffix(zoneRegistrable, ".") if suffix == "" || (target != suffix && !strings.HasSuffix(target, "."+suffix)) { pt.External = true @@ -283,7 +302,15 @@ func classifyExternal(pt *Pointer, zoneRegistrable string) { } } -// defaultResolveHost performs an A/AAAA lookup and maps the outcome to a verdict string. +// defaultResolveHost performs a single A/AAAA lookup on target and +// classifies the outcome into one of: +// +// - "ok" – at least one A/AAAA returned +// - "no_answer" – NOERROR but the server returned no addresses +// - "nxdomain" – authoritative NXDOMAIN +// - "servfail" – upstream resolver returned SERVFAIL +// - "timeout" – the lookup did not complete in time +// - "error" – any other resolution error func defaultResolveHost(ctx context.Context, target string) (verdict, detail string) { target = strings.TrimSuffix(target, ".") if target == "" { @@ -316,7 +343,9 @@ func defaultResolveHost(ctx context.Context, target string) (verdict, detail str return "error", err.Error() } -// ownerFQDN returns the record owner FQDN, preferring the service's _domain field over subdomain+apex. +// ownerFQDN returns the FQDN of the service's owner. We prefer the +// service's _domain field (already an FQDN with trailing dot in +// happyDomain's wire shape) and fall back to subdomain+apex. func ownerFQDN(svcDomain, sub, apex string) string { if svcDomain != "" { return strings.TrimSuffix(svcDomain, ".") @@ -330,23 +359,20 @@ func ownerFQDN(svcDomain, sub, apex string) string { return sub + "." + apex } -// preferRRName returns the RR header Name as an FQDN when present. -// happyDomain encodes service-embedded record owners relative to the zone -// apex, so the rrName must be joined with apex unless it already contains -// the apex suffix (services published with absolute owners). -func preferRRName(rrName, fallback, apex string) string { +// preferRRName returns the RR header Name when present (it is the +// authoritative owner for the record), otherwise the service-derived +// owner. +func preferRRName(rrName, fallback string) string { rrName = strings.TrimSuffix(rrName, ".") - if rrName == "" { - return fallback - } - apex = strings.TrimSuffix(apex, ".") - if apex == "" || rrName == apex || strings.HasSuffix(rrName, "."+apex) { + if rrName != "" { return rrName } - return rrName + "." + apex + return fallback } -// normaliseTarget converts a target to FQDN form; happyDomain stores in-zone targets relative, external ones absolute. +// normaliseTarget yields the FQDN form of a record target. happyDomain +// stores within-zone targets relative to the zone, and external targets +// fully-qualified. We accept both shapes. func normaliseTarget(target, owner, apex string) string { t := strings.TrimSpace(target) if t == "" { @@ -355,7 +381,8 @@ func normaliseTarget(target, owner, apex string) string { if trimmed, ok := strings.CutSuffix(t, "."); ok { return trimmed } - // Relative target: anchor under apex (empty apex only occurs in tests that omit domain_name). + // Relative: anchor under the zone apex (or the owner when apex is + // empty, which only happens in tests that omit the domain name). if apex != "" { return t + "." + apex } diff --git a/checker/definition.go b/checker/definition.go index 2cba42d..0d3cfa0 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -6,10 +6,14 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Version is overridden at build time via -ldflags. Use SetVersion from entrypoints, not direct assignment. +// Version is overridden at build time via -ldflags by main.go and +// plugin/plugin.go. Use SetVersion from entrypoints rather than +// assigning to it directly. var Version = "built-in" -// SetVersion ignores empty values so a misconfigured ldflags does not erase the default. +// SetVersion updates the package-level Version reported in the +// CheckerDefinition. Empty values are ignored so an entrypoint that +// forgets its own ldflags does not erase the default. func SetVersion(v string) { if v != "" { Version = v @@ -17,7 +21,10 @@ func SetVersion(v string) { } // Definition exposes the checker to the happyDomain host. -// Zone-scoped single pass so findings consolidate by owner rather than one observation per service. +// +// The checker is zone-scoped: it inspects every pointer service in a +// single pass so the report consolidates findings by owner instead of +// fanning one observation out per service. func Definition() *sdk.CheckerDefinition { def := &sdk.CheckerDefinition{ ID: "dangling", diff --git a/checker/report.go b/checker/report.go index da564c0..dc11523 100644 --- a/checker/report.go +++ b/checker/report.go @@ -9,7 +9,10 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// GetHTMLReport renders the dangling-records observation as HTML. +// GetHTMLReport renders the dangling-records observation as a +// self-contained HTML page. The report shows one card per impacted +// owner, sorted by descending severity, with the failing pointer and +// the human-readable reason behind each trigger. func (p *danglingProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var data DanglingData if raw := ctx.Data(); len(raw) > 0 { @@ -73,7 +76,10 @@ func buildReportView(data *DanglingData, states []sdk.CheckState) *reportView { return v } -// cardsFromStates rebuilds per-owner cards from CheckState.Meta so the report and rule never disagree. +// cardsFromStates rebuilds the per-owner cards from the CheckState +// slice the host has already produced. We rely on Meta.triggers (set by +// danglingRule.Evaluate) so the report and the rule never disagree on +// what to show. func cardsFromStates(states []sdk.CheckState) []ownerCard { out := make([]ownerCard, 0, len(states)) for _, st := range states { diff --git a/checker/rule.go b/checker/rule.go index 94eff1b..b811a05 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -12,11 +12,18 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// recentRegistrationDays is the window for flagging re-registered domains. -// Attackers re-register a freshly-released target to take over subdomains pointing at it (Ars Technica 2017). +// recentRegistrationDays defines how recently a registrable domain +// must have been (re-)registered for the rule to flag it as a likely +// takeover candidate. The Ars Technica scenario hinges on attackers +// re-registering a freshly-released domain; surfacing recently-changed +// registrations is what turns a passing NXDOMAIN-free lookup into an +// audit signal. const recentRegistrationDays = 90 -// danglingRule is the single rule for v1. +// danglingRule is the single rule for v1: it walks the observation's +// pointer list, joins it with the related "whois" observations +// produced by domain_expiry on the entries we published, and emits one +// CheckState per impacted owner. type danglingRule struct{} func (r *danglingRule) Name() string { return "dangling_records" } @@ -119,7 +126,9 @@ func (s Severity) String() string { } } -// SignalTrigger captures one reason the rule flagged an owner, stored in CheckState.Meta for the report. +// SignalTrigger captures one reason the rule flagged an owner. Stored +// in the per-owner Meta so the report can render a concise list of +// "why this is dangling". type SignalTrigger struct { Rrtype string `json:"rrtype"` Target string `json:"target"` @@ -135,8 +144,20 @@ type ownerFindings struct { WorstSeverity Severity } -// evaluatePointer returns all signals for a single pointer. -// Multiple triggers are reported individually so the report can explain each reason. +// evaluatePointer applies the v1 verdict matrix to a single pointer: +// +// - Resolution == "nxdomain" → critical (broken pointer). +// - Resolution == "servfail" → warning (likely lame upstream, may +// also indicate decommissioning). +// - Resolution == "no_answer" → info (NOERROR with empty answer +// section is rarely the operator's intent for a pointer). +// - WHOIS Status contains "pendingDelete"/"redemptionPeriod" → critical. +// - WHOIS ExpiryDate already in the past → critical. +// - WHOIS shows a registration < recentRegistrationDays old → warning +// (possible re-registration; surface for review). +// +// Multiple triggers on the same pointer are reported individually so +// the report can explain "why" without ambiguity. func evaluatePointer(pt *Pointer, whoisByRef map[string]*whoisFacts) []SignalTrigger { var out []SignalTrigger @@ -172,7 +193,10 @@ func evaluatePointer(pt *Pointer, whoisByRef map[string]*whoisFacts) []SignalTri return out } -// whoisFacts is the minimal subset of a related WHOIS observation used by evaluateWHOIS. +// whoisFacts is the minimal shape we need from a related "whois" +// observation: ExpiryDate to detect expiration, Status to spot +// registry-side states like pendingDelete, and CreationDate (when +// reported by the upstream RDAP probe) to flag fresh re-registrations. type whoisFacts struct { ExpiryDate time.Time `json:"expiryDate"` CreationDate time.Time `json:"creationDate,omitzero"` @@ -220,11 +244,20 @@ func evaluateWHOIS(pt *Pointer, f *whoisFacts) []SignalTrigger { return out } -// ExternalWhoisObservationKey must stay in sync with happydomain3/checkers/external_expiry.go. +// ExternalWhoisObservationKey names the observation produced by the +// companion checker that subscribes to dangling.external-target.v1 +// entries and runs RDAP/WHOIS per registrable domain. Kept in sync +// with happydomain3/checkers/external_expiry.go. const ExternalWhoisObservationKey = "external_whois" -// loadWHOIS builds a per-Ref index from related WHOIS observations. -// Parse errors are counted but not fatal: WHOIS absence must not turn the rule into Error state. +// loadWHOIS resolves related observations of key external_whois into a +// per-Ref index. A non-fatal error is silently swallowed: WHOIS data +// is best-effort context and its absence must not turn the whole rule +// into an Error state. +// +// The companion checker is expected to return a map[Ref]facts under +// each related observation; we also accept a single-fact payload keyed +// directly by the entry Ref (host-side flattening case). func loadWHOIS(ctx context.Context, obs sdk.ObservationGetter) (map[string]*whoisFacts, int) { out := map[string]*whoisFacts{} related, err := obs.GetRelated(ctx, ExternalWhoisObservationKey) @@ -233,7 +266,8 @@ func loadWHOIS(ctx context.Context, obs sdk.ObservationGetter) (map[string]*whoi } parseErrors := 0 for _, ro := range related { - // Try the per-Ref map shape first (convention from checker-tls). + // Try the per-Ref map shape first (the convention the host's + // external_whois provider uses, mirrored from checker-tls). var asMap struct { Facts map[string]whoisFacts `json:"facts"` } @@ -314,7 +348,9 @@ func plural(n int) string { return "s" } -// sortFindings returns findings sorted by descending severity so the report's top card matches the rule output. +// sortFindings yields a stable, severity-first ordering of the +// per-owner findings so the report's "fix this first" card always +// matches the rule output. func sortFindings(byOwner map[string]*ownerFindings) []*ownerFindings { out := make([]*ownerFindings, 0, len(byOwner)) for _, f := range byOwner { diff --git a/checker/types.go b/checker/types.go index b76882a..2c2e1f1 100644 --- a/checker/types.go +++ b/checker/types.go @@ -1,5 +1,12 @@ -// Package checker detects dangling pointer records (CNAME/MX/SRV/NS) whose external targets -// may have expired or been re-registered, enabling subdomain takeover. +// Package checker implements the happyDomain "dangling records" +// checker: it walks the working zone, identifies every pointer record +// (CNAME / MX / SRV / NS) whose target lives outside the zone, performs +// a light DNS resolution to detect immediate breakage (NXDOMAIN), and +// publishes DiscoveryEntry records so a companion checker (typically +// the host's domain_expiry) can verify each external registrable domain +// via RDAP/WHOIS. The rule layer joins both signals to surface +// subdomains at risk of takeover (the "dangling CNAME" attack class +// publicised by Ars Technica in 2017). package checker import ( @@ -8,31 +15,75 @@ import ( const ObservationKeyDangling = "dangling_records" -// DanglingData is the raw observation payload; one Pointer per (owner, rrtype, target) triple. +// DanglingData is the raw observation payload. It carries one Pointer +// entry per (owner, rrtype, target) triple found in the zone, including +// targets resolved to their DNS verdict. Aggregation by owner happens +// in the rule layer. type DanglingData struct { - Zone string `json:"zone,omitempty"` - ServicesScanned int `json:"services_scanned"` - Pointers []Pointer `json:"pointers,omitempty"` - // CollectErrors surfaces non-fatal scan problems so silent skips don't masquerade as a clean pass. + // Zone is the zone apex, without trailing dot. Empty when the host + // did not provide a domain_name option. + Zone string `json:"zone,omitempty"` + + // ServicesScanned counts every service inspected (matches the same + // field in checker-legacy-records, anchoring the report). + ServicesScanned int `json:"services_scanned"` + + // Pointers lists every pointer record encountered. One entry per + // distinct (owner, rrtype, target). External pointers carry a + // non-empty Registrable; in-zone pointers leave it empty so the + // rule does not request RDAP on the user's own apex. + Pointers []Pointer `json:"pointers,omitempty"` + + // CollectErrors records non-fatal problems encountered during the + // zone walk, surfaced in the report so silent skips do not + // masquerade as a clean pass. CollectErrors []string `json:"collect_errors,omitempty"` } -// Pointer is one (owner, rrtype, target) triple from the zone, with its DNS resolution verdict. +// Pointer is the unit of observation: one (owner, rrtype, target) seen +// in the zone, plus the result of the local DNS resolution. type Pointer struct { - Owner string `json:"owner"` + // Owner is the FQDN that carries the pointer record (CNAME owner, + // MX/SRV owner, NS apex, …). No trailing dot. + Owner string `json:"owner"` + + // Subdomain is Owner relative to the zone apex. "" means apex + // (rendered as "@" in the report). Subdomain string `json:"subdomain"` - Rrtype string `json:"rrtype"` - Target string `json:"target"` - // External flags takeover risk: Target's registrable domain differs from the zone's. - External bool `json:"external"` + + // Rrtype is the textual record type ("CNAME", "MX", "SRV", "NS"). + Rrtype string `json:"rrtype"` + + // Target is the FQDN the record points at. No trailing dot. + Target string `json:"target"` + + // External is true when Target's registrable domain differs from + // the zone's registrable domain (the takeover-risk case). + External bool `json:"external"` + + // Registrable is the eTLD+1 of Target. Empty when External is false + // or when public-suffix lookup failed. Registrable string `json:"registrable,omitempty"` - // ServiceType identifies the happyDomain service for linking back to the edit screen. - ServiceType string `json:"service_type,omitempty"` - Resolution string `json:"resolution"` + + // ServiceType is the happyDomain service that exposed the record + // ("svcs.CNAME", "svcs.MXs", …). Useful for navigating users back + // to the right edit screen in the report. + ServiceType string `json:"service_type,omitempty"` + + // Resolution is the verdict of the local DNS lookup of Target: + // "ok", "nxdomain", "no_answer", "servfail", "timeout", "skipped". + // "skipped" is used when the collector chose not to resolve (for + // example, because lookups are disabled at runtime). + Resolution string `json:"resolution"` + + // ResolutionDetail is a free-form sentence describing the + // resolution outcome (e.g. the underlying error string). Optional. ResolutionDetail string `json:"resolution_detail,omitempty"` } -// rawZone is a partial Zone type redeclared here to avoid importing the happyDomain module. +// rawZone is the minimal slice of happyDomain's *Zone JSON we consume. +// Like checker-legacy-records, we redeclare just the fields we need so +// this checker compiles without depending on the happyDomain module. type rawZone struct { DomainName string `json:"domain_name,omitempty"` Services map[string][]rawService `json:"services"` @@ -44,6 +95,10 @@ type rawService struct { Service json.RawMessage `json:"Service"` } +// Below: minimal JSON shapes for each service body we extract pointers +// from. We only need fields that point at a host name, so the +// definitions are deliberately partial. + type cnameBody struct { Record struct { Hdr struct { @@ -75,7 +130,9 @@ type srvsBody struct { Records []srvRecord `json:"srv"` } -// orphanRecord wraps an svcs.Orphan body; Hdr.Rrtype is sniffed to pick the right field. +// orphanRecord covers the body shape used by svcs.Orphan when the +// embedded RR is a CNAME, NS, MX, or SRV. We sniff Hdr.Rrtype before +// committing to a specific decoder. type orphanRecord struct { Record struct { Hdr struct {