package checker import ( "context" "fmt" "slices" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // ---------- structural ---------- type reverseArpaRule struct{} func (reverseArpaRule) Name() string { return "ptr.in_reverse_arpa" } func (reverseArpaRule) Description() string { return "Verifies the PTR owner lies under in-addr.arpa or ip6.arpa." } func (reverseArpaRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if !data.InReverseArpa { return []sdk.CheckState{critState( "ptr_not_in_reverse_zone", fmt.Sprintf("PTR owner %s is not under in-addr.arpa or ip6.arpa", data.OwnerName), data.OwnerName, "Move the PTR record into the appropriate reverse zone served by the IP owner (your ISP or LIR). PTR outside *.arpa is not usable for reverse DNS.", )} } return []sdk.CheckState{passState("ptr.in_reverse_arpa.ok", "Owner is in a reverse (*.arpa) zone.", data.OwnerName)} } type ownerDecodeRule struct{} func (ownerDecodeRule) Name() string { return "ptr.owner_decodable" } func (ownerDecodeRule) Description() string { return "Verifies the reverse-arpa owner name decodes back to an IP address." } func (ownerDecodeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if !data.InReverseArpa { return []sdk.CheckState{skipState("ptr.owner_decodable.skipped", "Owner is not in *.arpa; decoding does not apply.")} } if data.OwnerDecodeFailed { return []sdk.CheckState{critState( "ptr_owner_malformed", fmt.Sprintf("cannot decode an IP from PTR owner %s", data.OwnerName), data.OwnerName, "Reverse names must use 4 numeric labels for IPv4 or 32 hexadecimal nibbles for IPv6.", )} } return []sdk.CheckState{passState("ptr.owner_decodable.ok", fmt.Sprintf("Owner decodes to %s.", data.ReverseIP), data.OwnerName)} } // ---------- zone location & query ---------- type reverseZoneRule struct{} func (reverseZoneRule) Name() string { return "ptr.reverse_zone_located" } func (reverseZoneRule) Description() string { return "Verifies the reverse zone serving the PTR owner can be located (SOA found)." } func (reverseZoneRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if data.ZoneLookupError != "" || data.ReverseZone == "" { msg := fmt.Sprintf("could not locate the reverse zone of %s", data.OwnerName) if data.ZoneLookupError != "" { msg = fmt.Sprintf("%s: %s", msg, data.ZoneLookupError) } return []sdk.CheckState{critState( "ptr_no_reverse_zone", msg, data.OwnerName, "The reverse zone is usually delegated by your IP provider. Make sure the parent delegation exists and publishes an SOA.", )} } return []sdk.CheckState{passState("ptr.reverse_zone_located.ok", fmt.Sprintf("Reverse zone is %s.", data.ReverseZone), data.OwnerName)} } type queryOutcomeRule struct{} func (queryOutcomeRule) Name() string { return "ptr.query_succeeded" } func (queryOutcomeRule) Description() string { return "Verifies the PTR query returns NOERROR from the authoritative servers." } func (queryOutcomeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if data.QueryError != "" { return []sdk.CheckState{critState( "ptr_query_failed", fmt.Sprintf("PTR query for %s failed: %s", data.OwnerName, data.QueryError), data.OwnerName, "Check that the reverse zone's name servers are reachable and that you can query them over UDP/53.", )} } if data.Rcode != "" && data.Rcode != "NOERROR" { return []sdk.CheckState{critState( "ptr_rcode", fmt.Sprintf("authoritative server answered %s for %s", data.Rcode, data.OwnerName), data.OwnerName, "NXDOMAIN almost always means the PTR record was never published at the reverse zone: your provider may not have delegated the sub-zone, or the record is missing.", )} } return []sdk.CheckState{passState("ptr.query_succeeded.ok", "PTR query returned NOERROR.", data.OwnerName)} } // ---------- record content ---------- type ptrPresentRule struct{} func (ptrPresentRule) Name() string { return "ptr.record_present" } func (ptrPresentRule) Description() string { return "Verifies at least one PTR record is served at the owner name." } func (ptrPresentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if data.QueryError != "" { return []sdk.CheckState{skipState("ptr.record_present.skipped", "PTR query did not complete.")} } if len(data.ObservedTargets) == 0 { return []sdk.CheckState{critState( "ptr_missing", fmt.Sprintf("no PTR record found at %s", data.OwnerName), data.OwnerName, "Add a PTR record at the reverse zone. Without it, mail servers will reject your IP and many SSH/VPN setups will refuse connections.", )} } return []sdk.CheckState{passState("ptr.record_present.ok", fmt.Sprintf("PTR found: %s.", strings.Join(data.ObservedTargets, ", ")), data.OwnerName)} } type singlePTRRule struct{} func (singlePTRRule) Name() string { return "ptr.single_record" } func (singlePTRRule) Description() string { return "Flags multiple PTR records on the same IP (RFC 1912 §2.1 recommends exactly one)." } func (singlePTRRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } allowMultiple := sdk.GetBoolOption(opts, "allowMultiplePTR", false) if allowMultiple { return []sdk.CheckState{skipState("ptr.single_record.skipped", "Multiple PTRs are explicitly allowed by configuration.")} } if len(data.ObservedTargets) == 0 { return []sdk.CheckState{skipState("ptr.single_record.skipped", "No PTR record observed.")} } if len(data.ObservedTargets) > 1 { return []sdk.CheckState{warnState( "ptr_multiple", fmt.Sprintf("%d PTR records at %s (%s)", len(data.ObservedTargets), data.OwnerName, strings.Join(data.ObservedTargets, ", ")), data.OwnerName, "RFC 1912 §2.1 recommends a single PTR per IP. Multiple PTRs confuse reverse-lookup consumers (mail filters, logs): keep exactly one canonical hostname.", )} } return []sdk.CheckState{passState("ptr.single_record.ok", "Exactly one PTR record is published.", data.OwnerName)} } type declaredMatchRule struct{} func (declaredMatchRule) Name() string { return "ptr.declared_match" } func (declaredMatchRule) Description() string { return "Verifies the PTR target served by the authoritative servers matches the declared target." } func (declaredMatchRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if data.DeclaredTarget == "" { return []sdk.CheckState{skipState("ptr.declared_match.skipped", "No declared PTR target to compare against.")} } if len(data.ObservedTargets) == 0 { return []sdk.CheckState{skipState("ptr.declared_match.skipped", "No PTR record observed.")} } if slices.Contains(data.ObservedTargets, data.DeclaredTarget) { return []sdk.CheckState{passState("ptr.declared_match.ok", "Authoritative PTR matches the declared target.", data.OwnerName)} } return []sdk.CheckState{critState( "ptr_declared_mismatch", fmt.Sprintf("declared PTR target %s not served; authoritative answer: %s", data.DeclaredTarget, strings.Join(data.ObservedTargets, ", ")), data.OwnerName, "The zone served by the authoritative servers disagrees with what happyDomain has for this record: push the current version of the zone, or refresh the imported state.", )} } // ---------- target hygiene ---------- type targetSyntaxRule struct{} func (targetSyntaxRule) Name() string { return "ptr.target_syntax_valid" } func (targetSyntaxRule) Description() string { return "Verifies the PTR target is a syntactically valid hostname (RFC 952/1123)." } func (targetSyntaxRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if data.EffectiveTarget == "" { return []sdk.CheckState{skipState("ptr.target_syntax_valid.skipped", "No PTR target available.")} } if !data.TargetSyntaxValid { return []sdk.CheckState{critState( "ptr_target_invalid", fmt.Sprintf("PTR target %q is not a valid hostname", data.EffectiveTarget), data.OwnerName, "PTR targets must be syntactically valid domain names (RFC 952/1123 letters, digits, hyphens; labels 1-63 chars).", )} } return []sdk.CheckState{passState("ptr.target_syntax_valid.ok", "PTR target is a valid hostname.", data.EffectiveTarget)} } type genericHostnameRule struct{} func (genericHostnameRule) Name() string { return "ptr.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 := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if !sdk.GetBoolOption(opts, "flagGenericPTR", true) { return []sdk.CheckState{skipState("ptr.generic_hostname.skipped", "Generic-hostname check disabled by configuration.")} } if data.EffectiveTarget == "" || data.ReverseIP == "" { return []sdk.CheckState{skipState("ptr.generic_hostname.skipped", "No PTR target or reverse IP available.")} } if data.TargetLooksGeneric { return []sdk.CheckState{warnState( "ptr_generic_hostname", fmt.Sprintf("PTR target %s looks auto-generated (contains the IP or a typical ISP pattern)", data.EffectiveTarget), data.OwnerName, "Mail servers and anti-spam filters penalise generic PTRs (those embedding the IP, or using pool/dynamic/dsl-style labels). Prefer a stable, service-specific hostname.", )} } return []sdk.CheckState{passState("ptr.generic_hostname.ok", "PTR target does not look auto-generated.", data.EffectiveTarget)} } // ---------- FCrDNS ---------- type targetResolvesRule struct{} func (targetResolvesRule) Name() string { return "ptr.target_resolves" } func (targetResolvesRule) Description() string { return "Verifies the 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 := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if data.EffectiveTarget == "" || data.ReverseIP == "" { return []sdk.CheckState{skipState("ptr.target_resolves.skipped", "No PTR target or reverse IP available.")} } if data.TargetResolves { return []sdk.CheckState{passState("ptr.target_resolves.ok", "PTR target resolves in the forward DNS.", data.EffectiveTarget)} } st := critState( "ptr_target_unresolvable", fmt.Sprintf("PTR target %s does not resolve to any A/AAAA record", data.EffectiveTarget), data.EffectiveTarget, "The hostname in the PTR must exist in the forward DNS. Publish an A and/or AAAA record matching the IP at that name; this is the canonical Forward-Confirmed Reverse DNS (FCrDNS) contract expected by mail servers.", ) if !sdk.GetBoolOption(opts, "requireForwardMatch", true) { st.Status = sdk.StatusWarn } return []sdk.CheckState{st} } type fcrdnsMatchRule struct{} func (fcrdnsMatchRule) Name() string { return "ptr.fcrdns_match" } func (fcrdnsMatchRule) Description() string { return "Verifies the PTR target's A/AAAA resolves back to the original IP (Forward-Confirmed Reverse DNS)." } func (fcrdnsMatchRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if data.EffectiveTarget == "" || data.ReverseIP == "" { return []sdk.CheckState{skipState("ptr.fcrdns_match.skipped", "No PTR target or reverse IP available.")} } if !data.TargetResolves { return []sdk.CheckState{skipState("ptr.fcrdns_match.skipped", "PTR target does not resolve; FCrDNS comparison skipped.")} } if data.ForwardMatch { return []sdk.CheckState{passState("ptr.fcrdns_match.ok", fmt.Sprintf("%s → %s → %s (FCrDNS confirmed)", data.ReverseIP, data.EffectiveTarget, data.ReverseIP), data.OwnerName)} } addrStrs := make([]string, len(data.ForwardAddresses)) for i, a := range data.ForwardAddresses { addrStrs[i] = a.Address } st := critState( "ptr_forward_mismatch", fmt.Sprintf("PTR target %s resolves to %s, which does not include %s (FCrDNS check failed)", data.EffectiveTarget, strings.Join(addrStrs, ", "), data.ReverseIP), data.OwnerName, "Add the original IP to the A/AAAA RRset of the PTR target, or change the PTR to point at a hostname whose A/AAAA already includes this IP. Mail servers reject connections when the PTR does not round-trip back.", ) if !sdk.GetBoolOption(opts, "requireForwardMatch", true) { st.Status = sdk.StatusWarn } return []sdk.CheckState{st} } // ---------- IPv6 ---------- type ipv6PTRRule struct{} func (ipv6PTRRule) Name() string { return "ptr.ipv6" } func (ipv6PTRRule) Description() string { return "Reports whether the PTR concerns an IPv6 (ip6.arpa) address." } func (ipv6PTRRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if !data.IsIPv6 { return []sdk.CheckState{skipState("ptr.ipv6.skipped", "Owner is not an ip6.arpa name.")} } if len(data.ObservedTargets) == 0 { return []sdk.CheckState{critState( "ptr_ipv6_missing", fmt.Sprintf("no PTR record found for IPv6 address %s", data.ReverseIP), data.OwnerName, "IPv6 reverse DNS is just as important as IPv4 for mail delivery. Publish a PTR at the ip6.arpa name.", )} } return []sdk.CheckState{passState("ptr.ipv6.ok", fmt.Sprintf("IPv6 PTR present for %s.", data.ReverseIP), data.OwnerName)} } // ---------- TTL hygiene ---------- type ttlHygieneRule struct{} func (ttlHygieneRule) Name() string { return "ptr.ttl_hygiene" } func (ttlHygieneRule) Description() string { return "Verifies the PTR TTL is at or above the configured minimum." } func (ttlHygieneRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadPTR(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } minTTL := uint32(sdk.GetIntOption(opts, "minTTL", 300)) var out []sdk.CheckState if data.ObservedTTL > 0 && data.ObservedTTL < minTTL { out = append(out, warnState( "ptr_low_ttl", fmt.Sprintf("PTR TTL is %ds (< %d)", data.ObservedTTL, minTTL), data.OwnerName, "Raise the PTR TTL. Reverse lookups are cache-heavy on the consumer side (mail, SSH) and frequent changes rarely help.", )) } if data.DeclaredTTL > 0 && data.DeclaredTTL < minTTL { out = append(out, sdk.CheckState{ Status: sdk.StatusInfo, Code: "ptr_declared_low_ttl", Message: fmt.Sprintf("declared PTR TTL is %ds (< %d)", data.DeclaredTTL, minTTL), Subject: data.OwnerName, }) } if len(out) == 0 { return []sdk.CheckState{passState("ptr.ttl_hygiene.ok", "PTR TTL is at or above the minimum.", data.OwnerName)} } return out }