checker-reverse-zone/checker/rules.go

336 lines
12 KiB
Go

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,
)}
}