388 lines
16 KiB
Go
388 lines
16 KiB
Go
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
|
|
}
|