package checker import ( "context" "fmt" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // ---------- structural ---------- type isReverseZoneRule struct{} func (isReverseZoneRule) Name() string { return "reverse_zone.is_reverse_arpa" } func (isReverseZoneRule) Description() string { return "Verifies the zone is under in-addr.arpa or ip6.arpa." } func (isReverseZoneRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if data.LoadError != "" { return []sdk.CheckState{{ Status: sdk.StatusError, Code: "reverse_zone.load_error", Message: data.LoadError, }} } if !data.IsReverseZone { return []sdk.CheckState{critState( "reverse_zone_not_arpa", fmt.Sprintf("zone %s is not under in-addr.arpa or ip6.arpa", data.Zone), data.Zone, "This checker is meant for reverse zones. Attach it to a domain whose name ends in in-addr.arpa or ip6.arpa.", )} } return []sdk.CheckState{passState("reverse_zone.is_reverse_arpa.ok", fmt.Sprintf("Zone %s is a reverse zone.", data.Zone), data.Zone)} } type hasPTRsRule struct{} func (hasPTRsRule) Name() string { return "reverse_zone.has_ptrs" } func (hasPTRsRule) Description() string { return "Verifies the reverse zone declares at least one PTR record." } func (hasPTRsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if !data.IsReverseZone { return []sdk.CheckState{skipState("reverse_zone.has_ptrs.skipped", "Zone is not a reverse zone.")} } if data.PTRCount == 0 { return []sdk.CheckState{warnState( "reverse_zone_empty", fmt.Sprintf("no PTR records declared in %s", data.Zone), data.Zone, "A reverse zone exists to publish PTR records. Add at least one PTR for an IP that lives in this delegation.", )} } return []sdk.CheckState{passState("reverse_zone.has_ptrs.ok", fmt.Sprintf("%d PTR records declared.", data.PTRCount), data.Zone)} } // ---------- FCrDNS (the dominant failure mode) ---------- type fcrdnsRule struct{} func (fcrdnsRule) Name() string { return "reverse_zone.fcrdns" } func (fcrdnsRule) Description() string { return "Verifies every PTR target's A/AAAA round-trips back to the original IP (Forward-Confirmed Reverse DNS)." } func (fcrdnsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if len(data.Entries) == 0 { return []sdk.CheckState{skipState("reverse_zone.fcrdns.skipped", "No PTR records to evaluate.")} } requireMatch := sdk.GetBoolOption(opts, "requireForwardMatch", true) var states []sdk.CheckState for _, e := range data.Entries { if len(e.Targets) == 0 || e.ReverseIP == "" { continue } if !e.TargetResolves { // targetResolvesRule reports this; skip here to avoid duplicate // findings. continue } if e.ForwardMatch { states = append(states, passState( "reverse_zone.fcrdns.ok", fmt.Sprintf("%s โ†’ %s โ†’ %s (FCrDNS confirmed)", e.ReverseIP, e.Targets[0], e.ReverseIP), e.OwnerName, )) continue } addrStrs := make([]string, len(e.ForwardAddresses)) for i, a := range e.ForwardAddresses { addrStrs[i] = a.Address } st := critState( "ptr_forward_mismatch", fmt.Sprintf("PTR %s โ†’ %s, but %s resolves to %s (does not include %s)", e.OwnerName, e.Targets[0], e.Targets[0], strings.Join(addrStrs, ", "), e.ReverseIP), e.OwnerName, fmt.Sprintf("Add %s to the A/AAAA records of %s in the forward zone, or change the PTR to a hostname that already resolves to %s. Mail servers reject SMTP connections when reverse DNS does not round-trip.", e.ReverseIP, e.Targets[0], e.ReverseIP), ) if !requireMatch { st.Status = sdk.StatusWarn } states = append(states, st) } if len(states) == 0 { return []sdk.CheckState{skipState("reverse_zone.fcrdns.skipped", "No PTR target had a forward resolution to compare against.")} } return states } // ---------- target resolves ---------- type targetResolvesRule struct{} func (targetResolvesRule) Name() string { return "reverse_zone.target_resolves" } func (targetResolvesRule) Description() string { return "Verifies every PTR target resolves to at least one A or AAAA record." } func (targetResolvesRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if len(data.Entries) == 0 { return []sdk.CheckState{skipState("reverse_zone.target_resolves.skipped", "No PTR records to evaluate.")} } requireMatch := sdk.GetBoolOption(opts, "requireForwardMatch", true) var states []sdk.CheckState for _, e := range data.Entries { if len(e.Targets) == 0 { continue } if e.TargetResolves { continue } msg := fmt.Sprintf("PTR target %s does not resolve to any A/AAAA record", e.Targets[0]) if e.ForwardError != "" { msg = fmt.Sprintf("%s (%s)", msg, e.ForwardError) } st := critState( "ptr_target_unresolvable", msg, e.OwnerName, fmt.Sprintf("Publish A and/or AAAA records for %s in the forward zone, pointing at %s. Without forward records the PTR is unusable for FCrDNS-aware consumers.", e.Targets[0], e.ReverseIP), ) if !requireMatch { st.Status = sdk.StatusWarn } states = append(states, st) } if len(states) == 0 { return []sdk.CheckState{passState("reverse_zone.target_resolves.ok", "All PTR targets resolve in the forward DNS.", data.Zone)} } return states } // ---------- single PTR per IP ---------- type singlePTRRule struct{} func (singlePTRRule) Name() string { return "reverse_zone.single_ptr_per_ip" } func (singlePTRRule) Description() string { return "Flags IPs with multiple PTR records (RFC 1912 ยง2.1 recommends exactly one)." } func (singlePTRRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if sdk.GetBoolOption(opts, "allowMultiplePTR", false) { return []sdk.CheckState{skipState("reverse_zone.single_ptr_per_ip.skipped", "Multiple PTRs are explicitly allowed by configuration.")} } if len(data.Entries) == 0 { return []sdk.CheckState{skipState("reverse_zone.single_ptr_per_ip.skipped", "No PTR records to evaluate.")} } var states []sdk.CheckState for _, e := range data.Entries { if len(e.Targets) > 1 { states = append(states, warnState( "ptr_multiple", fmt.Sprintf("%d PTR records at %s (%s)", len(e.Targets), e.OwnerName, strings.Join(e.Targets, ", ")), e.OwnerName, "Keep exactly one canonical hostname per IP. Multiple PTRs confuse mail filters, log analyzers and any consumer that takes the first answer.", )) } } if len(states) == 0 { return []sdk.CheckState{passState("reverse_zone.single_ptr_per_ip.ok", "Each IP has at most one PTR.", data.Zone)} } return states } // ---------- target syntax ---------- type targetSyntaxRule struct{} func (targetSyntaxRule) Name() string { return "reverse_zone.target_syntax" } func (targetSyntaxRule) Description() string { return "Verifies every PTR target is a syntactically valid hostname." } func (targetSyntaxRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if len(data.Entries) == 0 { return []sdk.CheckState{skipState("reverse_zone.target_syntax.skipped", "No PTR records to evaluate.")} } var states []sdk.CheckState for _, e := range data.Entries { if len(e.Targets) == 0 { continue } if !e.TargetSyntaxValid { states = append(states, critState( "ptr_target_invalid", fmt.Sprintf("PTR target %q at %s is not a valid hostname", e.Targets[0], e.OwnerName), e.OwnerName, "PTR targets must be syntactically valid domain names (RFC 952/1123 letters, digits, hyphens; labels 1-63 chars).", )) } } if len(states) == 0 { return []sdk.CheckState{passState("reverse_zone.target_syntax.ok", "All PTR targets are syntactically valid hostnames.", data.Zone)} } return states } // ---------- generic hostname ---------- type genericHostnameRule struct{} func (genericHostnameRule) Name() string { return "reverse_zone.generic_hostname" } func (genericHostnameRule) Description() string { return "Flags PTR targets that embed the IP or match common ISP auto-generated patterns." } func (genericHostnameRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if !sdk.GetBoolOption(opts, "flagGenericPTR", true) { return []sdk.CheckState{skipState("reverse_zone.generic_hostname.skipped", "Generic-hostname check disabled by configuration.")} } if len(data.Entries) == 0 { return []sdk.CheckState{skipState("reverse_zone.generic_hostname.skipped", "No PTR records to evaluate.")} } var states []sdk.CheckState for _, e := range data.Entries { if e.TargetLooksGeneric { states = append(states, warnState( "ptr_generic_hostname", fmt.Sprintf("PTR target %s at %s looks auto-generated", e.Targets[0], e.OwnerName), e.OwnerName, "Mail servers and anti-spam filters penalise generic PTRs. Prefer a stable, service-specific hostname instead of one that embeds the IP.", )) } } if len(states) == 0 { return []sdk.CheckState{passState("reverse_zone.generic_hostname.ok", "No PTR target looks auto-generated.", data.Zone)} } return states } // ---------- TTL hygiene ---------- type ttlHygieneRule struct{} func (ttlHygieneRule) Name() string { return "reverse_zone.ttl_hygiene" } func (ttlHygieneRule) Description() string { return "Flags PTR records whose TTL is below the configured minimum." } func (ttlHygieneRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if len(data.Entries) == 0 { return []sdk.CheckState{skipState("reverse_zone.ttl_hygiene.skipped", "No PTR records to evaluate.")} } minTTL := uint32(sdk.GetIntOption(opts, "minTTL", 300)) var states []sdk.CheckState for _, e := range data.Entries { if e.TTL > 0 && e.TTL < minTTL { states = append(states, warnState( "ptr_low_ttl", fmt.Sprintf("PTR %s has TTL %ds (< %d)", e.OwnerName, e.TTL, minTTL), e.OwnerName, "Raise the PTR TTL. Reverse lookups are cache-heavy on the consumer side (mail, SSH); short TTLs rarely help.", )) } } if len(states) == 0 { return []sdk.CheckState{passState("reverse_zone.ttl_hygiene.ok", "All PTR TTLs meet the minimum.", data.Zone)} } return states } // ---------- truncation ---------- type truncationRule struct{} func (truncationRule) Name() string { return "reverse_zone.truncated" } func (truncationRule) Description() string { return "Reports when the zone has more PTRs than the configured cap allows to inspect." } func (truncationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if !data.Truncated { return []sdk.CheckState{skipState("reverse_zone.truncated.skipped", "Inspection covered all PTR records.")} } return []sdk.CheckState{infoState( "reverse_zone_truncated", fmt.Sprintf("only the first %d of %d PTR records were inspected", len(data.Entries), data.PTRCount), data.Zone, )} }