336 lines
12 KiB
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,
|
|
)}
|
|
}
|