Initial commit

This commit is contained in:
nemunaire 2026-04-26 11:27:12 +07:00
commit 67c955129d
20 changed files with 2203 additions and 0 deletions

278
checker/collect.go Normal file
View file

@ -0,0 +1,278 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"net"
"regexp"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Collect gathers raw PTR observation data. It does NOT judge: no severity,
// no pass/fail, no pre-derived findings. CheckRule implementations turn the
// raw fields into CheckStates.
func (p *ptrProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
owner, declaredTarget, declaredTTL, err := resolvePTRInputs(opts)
if err != nil {
return nil, err
}
data := &PTRData{
OwnerName: owner,
DeclaredTarget: declaredTarget,
DeclaredTTL: declaredTTL,
}
// Structural classification: is this a reverse-arpa name, and can we
// decode an IP from it?
data.InReverseArpa = isReverseArpa(owner)
data.IsIPv6 = strings.HasSuffix(strings.TrimSuffix(lowerFQDN(owner), "."), ".ip6.arpa")
ip := reverseNameToIP(owner)
if ip != nil {
data.ReverseIP = ip.String()
} else if data.InReverseArpa {
data.OwnerDecodeFailed = true
}
// Reverse zone location.
zone, servers, zerr := findReverseZone(ctx, owner)
data.ReverseZone = zone
data.ReverseNS = servers
if zerr != nil {
data.ZoneLookupError = zerr.Error()
}
// PTR query at authoritative servers (fall back to the system resolver).
observed, observedTTL, rcode, qerr := queryPTR(ctx, owner, servers)
data.Rcode = rcode
data.ObservedTargets = observed
data.ObservedTTL = observedTTL
if qerr != nil {
data.QueryError = qerr.Error()
}
// Effective target for hostname hygiene / FCrDNS: prefer observed,
// fall back to declared.
declNorm := lowerFQDN(declaredTarget)
normalizedObserved := make([]string, len(observed))
for i, o := range observed {
normalizedObserved[i] = lowerFQDN(o)
}
target := declNorm
if len(normalizedObserved) > 0 {
target = normalizedObserved[0]
}
data.EffectiveTarget = target
if target != "" {
_, syntaxOK := dns.IsDomainName(strings.TrimSuffix(target, "."))
data.TargetSyntaxValid = syntaxOK
if ip != nil {
data.TargetLooksGeneric = looksGeneric(target, ip)
}
}
// Forward-Confirmed Reverse DNS: resolve target and compare with
// ReverseIP.
if target != "" && ip != nil {
addrs, tResolves := resolveForward(ctx, target)
data.ForwardAddresses = addrs
data.TargetResolves = tResolves
for _, a := range addrs {
if ipEqual(a.Address, ip) {
data.ForwardMatch = true
break
}
}
}
return data, nil
}
// resolvePTRInputs extracts the PTR owner, declared target and TTL from the
// auto-filled options.
func resolvePTRInputs(opts sdk.CheckerOptions) (owner, target string, ttl uint32, err error) {
if svcMsg, ok := sdk.GetOption[serviceMessage](opts, "service"); ok && len(svcMsg.Service) > 0 {
if svcMsg.Type != "" && svcMsg.Type != "svcs.PTR" {
return "", "", 0, fmt.Errorf("service is %s, expected svcs.PTR", svcMsg.Type)
}
var s ptrService
if err := json.Unmarshal(svcMsg.Service, &s); err == nil && s.Record != nil {
ownerName := s.Record.Hdr.Name
if ownerName == "" || ownerName == "@" {
ownerName = svcMsg.Domain
} else if !strings.HasSuffix(ownerName, ".") {
if svcMsg.Domain != "" {
ownerName = ownerName + "." + strings.TrimSuffix(svcMsg.Domain, ".")
}
}
declared := ""
if s.Record.Ptr != "" {
declared = lowerFQDN(s.Record.Ptr)
}
return lowerFQDN(ownerName), declared, s.Record.Hdr.Ttl, nil
}
}
parent, _ := sdk.GetOption[string](opts, "domain_name")
sub, _ := sdk.GetOption[string](opts, "subdomain")
if parent == "" {
return "", "", 0, fmt.Errorf("missing 'service' and 'domain_name' options")
}
expected, _ := sdk.GetOption[string](opts, "expected_target")
expected = strings.TrimSpace(expected)
declared := ""
if expected != "" {
declared = lowerFQDN(expected)
}
parent = strings.TrimSuffix(parent, ".")
if sub == "" || sub == "@" {
return lowerFQDN(parent), declared, 0, nil
}
sub = strings.TrimSuffix(sub, ".")
return lowerFQDN(sub + "." + parent), declared, 0, nil
}
// queryPTR asks for the PTR RRset at owner. It uses the supplied authoritative
// servers when available; otherwise it falls back to the system resolver.
func queryPTR(ctx context.Context, owner string, authServers []string) ([]string, uint32, string, error) {
q := dns.Question{Name: dns.Fqdn(owner), Qtype: dns.TypePTR, Qclass: dns.ClassINET}
var r *dns.Msg
var err error
if len(authServers) > 0 {
r, _, err = queryAtAuth(ctx, authServers, q)
} else {
r, err = dnsExchange(ctx, "", systemResolver(), q, true)
}
if err != nil {
return nil, 0, "", err
}
rcode := rcodeText(r.Rcode)
var targets []string
var ttl uint32
for _, rr := range r.Answer {
if ptr, ok := rr.(*dns.PTR); ok && strings.EqualFold(dns.Fqdn(ptr.Hdr.Name), dns.Fqdn(owner)) {
targets = append(targets, lowerFQDN(ptr.Ptr))
if ttl == 0 || ptr.Hdr.Ttl < ttl {
ttl = ptr.Hdr.Ttl
}
}
}
return targets, ttl, rcode, nil
}
// resolveForward runs the forward lookup of name via the system resolver.
func resolveForward(ctx context.Context, name string) ([]ForwardAddress, bool) {
var resolver net.Resolver
var out []ForwardAddress
ips, err := resolver.LookupIP(ctx, "ip", strings.TrimSuffix(name, "."))
if err != nil || len(ips) == 0 {
// Fall back to direct DNS queries (system resolver may filter AAAA).
for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} {
q := dns.Question{Name: dns.Fqdn(name), Qtype: qt, Qclass: dns.ClassINET}
r, rerr := dnsExchange(ctx, "", systemResolver(), q, true)
if rerr != nil || r == nil {
continue
}
for _, rr := range r.Answer {
switch v := rr.(type) {
case *dns.A:
out = append(out, ForwardAddress{Type: "A", Address: v.A.String(), TTL: v.Hdr.Ttl})
case *dns.AAAA:
out = append(out, ForwardAddress{Type: "AAAA", Address: v.AAAA.String(), TTL: v.Hdr.Ttl})
}
}
}
return out, len(out) > 0
}
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
out = append(out, ForwardAddress{Type: "A", Address: v4.String()})
} else {
out = append(out, ForwardAddress{Type: "AAAA", Address: ip.String()})
}
}
return out, true
}
// ipEqual compares an address string with a net.IP (normalising IPv4-in-IPv6).
func ipEqual(addr string, ip net.IP) bool {
parsed := net.ParseIP(addr)
if parsed == nil {
return false
}
return parsed.Equal(ip)
}
// looksGeneric reports whether hostname embeds the dotted/hyphenated IP or
// matches the common ISP auto-generated patterns that mail filters penalise.
//
// The pattern requires a "-" or "." separator before the digit run so legitimate
// names like "host1.example.com" or "static-www" do not match; auto-generated
// PTRs almost always look like "dhcp-1-2-3-4", "pool.10.20", "dyn-203" etc.
var genericHints = regexp.MustCompile(`(?i)\b(dhcp|dyn(amic)?|dsl|cable|ppp|pool|client|broadband|static|user|host|ip)[-.]\d+([-.]\d+){1,3}\b`)
func looksGeneric(hostname string, ip net.IP) bool {
h := strings.ToLower(hostname)
if v4 := ip.To4(); v4 != nil {
ipStr := v4.String()
if strings.Contains(h, ipStr) {
return true
}
if strings.Contains(h, strings.ReplaceAll(ipStr, ".", "-")) {
return true
}
} else if v6 := ip.To16(); v6 != nil {
// Build the 32-nibble hex form, then check the common embedded
// shapes: continuous ("20010db8…"), dash-grouped ("2001-0db8-…"),
// dot-grouped ("2001.0db8.…"), and full nibble-by-nibble ("2.0.0.1.0.d.b.8.…").
var hex [32]byte
const hexdigits = "0123456789abcdef"
for i, b := range v6 {
hex[i*2] = hexdigits[b>>4]
hex[i*2+1] = hexdigits[b&0x0f]
}
flat := string(hex[:])
if strings.Contains(h, flat) {
return true
}
groups := []string{
flat[0:4], flat[4:8], flat[8:12], flat[12:16],
flat[16:20], flat[20:24], flat[24:28], flat[28:32],
}
// At least four consecutive groups must be present to claim the
// hostname embeds the address (avoids false positives on short
// hex-looking labels).
for _, sep := range []string{"-", "."} {
for start := 0; start <= 4; start++ {
probe := strings.Join(groups[start:start+4], sep)
if strings.Contains(h, probe) {
return true
}
}
}
// Nibble-per-label form, as appears in some ISP PTRs.
nibbles := make([]string, 32)
for i, c := range flat {
nibbles[i] = string(c)
}
for start := 0; start <= 32-16; start++ {
probe := strings.Join(nibbles[start:start+16], ".")
if strings.Contains(h, probe) {
return true
}
}
}
return genericHints.MatchString(h)
}

160
checker/collect_test.go Normal file
View file

@ -0,0 +1,160 @@
package checker
import (
"encoding/json"
"net"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestLooksGeneric(t *testing.T) {
v4 := net.ParseIP("203.0.113.42")
v6 := net.ParseIP("2001:db8::1")
cases := []struct {
name string
host string
ip net.IP
want bool
}{
{"dotted ip embedded", "host-203.0.113.42.example.net", v4, true},
{"hyphenated ip embedded", "203-0-113-42.isp.example.net", v4, true},
{"dhcp pattern", "dhcp-10-20-30.isp.example.net", v4, true},
{"pool pattern", "pool.10.20.30.isp.example.net", v4, true},
{"dyn pattern", "dyn-203-0-113.isp.example.net", v4, true},
{"clean hostname", "mail.example.com", v4, false},
{"hostname with single digit suffix (not generic)", "host1.example.com", v4, false},
{"static-www should not match", "static-www.example.com", v4, false},
{"v6 short prefix only", "ipv6-2001-db8.example.net", v6, false},
{"v6 dash-grouped embedded", "host-2001-0db8-0000-0000-0000-0000-0000-0001.isp.example.net", v6, true},
{"v6 flat hex embedded", "h20010db8000000000000000000000001.isp.example.net", v6, true},
{"v6 dotted nibble embedded", "host.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.example.net", v6, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := looksGeneric(tc.host, tc.ip); got != tc.want {
t.Errorf("looksGeneric(%q, %s) = %v, want %v", tc.host, tc.ip, got, tc.want)
}
})
}
}
func TestIPEqual(t *testing.T) {
v4 := net.ParseIP("1.2.3.4")
if !ipEqual("1.2.3.4", v4) {
t.Error("expected 1.2.3.4 to equal itself")
}
if !ipEqual("::ffff:1.2.3.4", v4) {
t.Error("expected v4-mapped v6 to equal v4")
}
if ipEqual("1.2.3.5", v4) {
t.Error("different addresses should not be equal")
}
if ipEqual("not-an-ip", v4) {
t.Error("invalid input must not be equal")
}
}
func TestResolvePTRInputs_FromDomainSubdomain(t *testing.T) {
opts := sdk.CheckerOptions{
"domain_name": "3.2.1.in-addr.arpa",
"subdomain": "4",
}
owner, target, ttl, err := resolvePTRInputs(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "4.3.2.1.in-addr.arpa." {
t.Errorf("owner = %q, want 4.3.2.1.in-addr.arpa.", owner)
}
if target != "" {
t.Errorf("target = %q, want empty", target)
}
if ttl != 0 {
t.Errorf("ttl = %d, want 0", ttl)
}
}
func TestResolvePTRInputs_ApexSubdomain(t *testing.T) {
opts := sdk.CheckerOptions{
"domain_name": "4.3.2.1.in-addr.arpa.",
"subdomain": "@",
}
owner, _, _, err := resolvePTRInputs(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "4.3.2.1.in-addr.arpa." {
t.Errorf("owner = %q, want 4.3.2.1.in-addr.arpa.", owner)
}
}
func TestResolvePTRInputs_ExpectedTarget(t *testing.T) {
opts := sdk.CheckerOptions{
"domain_name": "3.2.1.in-addr.arpa",
"subdomain": "4",
"expected_target": "Mail.Example.COM",
}
_, target, _, err := resolvePTRInputs(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if target != "mail.example.com." {
t.Errorf("target = %q, want mail.example.com.", target)
}
}
func TestResolvePTRInputs_MissingDomain(t *testing.T) {
if _, _, _, err := resolvePTRInputs(sdk.CheckerOptions{}); err == nil {
t.Fatal("expected error for missing domain_name")
}
}
func TestResolvePTRInputs_FromService(t *testing.T) {
rec := map[string]any{
"Hdr": map[string]any{
"Name": "4",
"Rrtype": 12,
"Class": 1,
"Ttl": 3600,
},
"Ptr": "Mail.Example.COM.",
}
svc, _ := json.Marshal(map[string]any{"Record": rec})
envelope, _ := json.Marshal(map[string]any{
"_svctype": "svcs.PTR",
"_domain": "3.2.1.in-addr.arpa",
"Service": json.RawMessage(svc),
})
opts := sdk.CheckerOptions{"service": json.RawMessage(envelope)}
owner, target, ttl, err := resolvePTRInputs(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "4.3.2.1.in-addr.arpa." {
t.Errorf("owner = %q, want 4.3.2.1.in-addr.arpa.", owner)
}
if target != "mail.example.com." {
t.Errorf("target = %q, want mail.example.com.", target)
}
if ttl != 3600 {
t.Errorf("ttl = %d, want 3600", ttl)
}
}
func TestResolvePTRInputs_WrongServiceType(t *testing.T) {
envelope, _ := json.Marshal(map[string]any{
"_svctype": "svcs.A",
"_domain": "example.com",
"Service": json.RawMessage(`{"Record":null}`),
})
opts := sdk.CheckerOptions{"service": json.RawMessage(envelope)}
if _, _, _, err := resolvePTRInputs(opts); err == nil {
t.Fatal("expected error for non-PTR service type")
}
}

87
checker/definition.go Normal file
View file

@ -0,0 +1,87 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
var Version = "built-in"
// Definition returns the CheckerDefinition for the PTR checker.
func (p *ptrProvider) Definition() *sdk.CheckerDefinition {
def := &sdk.CheckerDefinition{
ID: "ptr",
Name: "PTR / Reverse DNS",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"svcs.PTR"},
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyPTR},
Options: sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "requireForwardMatch",
Type: "bool",
Label: "Require forward-confirmed reverse DNS (FCrDNS)",
Description: "When enabled, a PTR whose target does not resolve back to the original IP is reported as critical (otherwise as warning). Mail servers and many SSH setups require FCrDNS.",
Default: true,
},
{
Id: "allowMultiplePTR",
Type: "bool",
Label: "Allow multiple PTR records on the same IP",
Description: "When disabled, more than one PTR at the same owner name is reported as warning (RFC 1912 §2.1 recommends a single PTR per IP).",
Default: false,
},
{
Id: "minTTL",
Type: "uint",
Label: "Minimum PTR TTL (seconds)",
Description: "PTR records with a TTL below this threshold are flagged as warning. Very short TTLs degrade resolver cache efficiency.",
Default: float64(300),
},
{
Id: "flagGenericPTR",
Type: "bool",
Label: "Flag generic-looking PTR hostnames",
Description: "When enabled, PTR targets that embed the dotted IP or match common ISP auto-generated patterns are reported as warning.",
Default: true,
},
},
ServiceOpts: []sdk.CheckerOptionDocumentation{
{
Id: "service_type",
Label: "Service type",
AutoFill: sdk.AutoFillServiceType,
},
{
Id: "service",
Label: "Service",
AutoFill: sdk.AutoFillService,
},
{
Id: "domain_name",
Label: "Reverse zone",
AutoFill: sdk.AutoFillDomainName,
},
{
Id: "subdomain",
Label: "PTR record",
AutoFill: sdk.AutoFillSubdomain,
},
},
},
Rules: Rules(),
HasHTMLReport: true,
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 24 * time.Hour,
Default: 1 * time.Hour,
},
}
def.BuildRulesInfo()
return def
}

207
checker/dns.go Normal file
View file

@ -0,0 +1,207 @@
package checker
import (
"context"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/miekg/dns"
)
const dnsTimeout = 5 * time.Second
// FallbackResolver is the resolver used when /etc/resolv.conf is missing or
// empty. It can be overridden at startup (e.g. via a CLI flag) so operators
// don't silently leak lookups to a third party.
var FallbackResolver = net.JoinHostPort("1.1.1.1", "53")
// dnsExchange sends a single query. proto="" uses UDP and retries over TCP on
// truncation; recursion controls the RD flag.
func dnsExchange(ctx context.Context, proto, server string, q dns.Question, recursion bool) (*dns.Msg, error) {
client := dns.Client{Net: proto, Timeout: dnsTimeout}
m := new(dns.Msg)
m.Id = dns.Id()
m.Question = []dns.Question{q}
m.RecursionDesired = recursion
m.SetEdns0(4096, true)
if deadline, ok := ctx.Deadline(); ok {
if d := time.Until(deadline); d > 0 && d < client.Timeout {
client.Timeout = d
}
}
r, _, err := client.Exchange(m, server)
if err != nil {
return nil, err
}
if r == nil {
return nil, fmt.Errorf("nil response from %s", server)
}
if r.Truncated && proto == "" {
tcpClient := dns.Client{Net: "tcp", Timeout: client.Timeout}
if r2, _, err2 := tcpClient.Exchange(m, server); err2 == nil && r2 != nil {
return r2, nil
}
}
return r, nil
}
// systemResolver returns the first configured resolver of the local system.
func systemResolver() string {
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil || len(cfg.Servers) == 0 {
return FallbackResolver
}
return net.JoinHostPort(cfg.Servers[0], cfg.Port)
}
// hostPort returns "host:port", correctly bracketing IPv6 literals.
func hostPort(host, port string) string {
host = strings.TrimSuffix(host, ".")
return net.JoinHostPort(host, port)
}
// findReverseZone walks up the labels of fqdn until it finds a zone cut
// (SOA). Returns the apex FQDN and the list of "host:53" authoritative
// servers. The walk stops at the reverse-arpa apex (in-addr.arpa or
// ip6.arpa) so we never accept a non-reverse zone (or the root) as a match.
func findReverseZone(ctx context.Context, fqdn string) (apex string, servers []string, err error) {
resolver := systemResolver()
labels := dns.SplitDomainName(fqdn)
for i := range labels {
candidate := dns.Fqdn(strings.Join(labels[i:], "."))
if !isReverseArpa(candidate) {
break
}
q := dns.Question{Name: candidate, Qtype: dns.TypeSOA, Qclass: dns.ClassINET}
r, rerr := dnsExchange(ctx, "", resolver, q, true)
if rerr != nil {
continue
}
if r.Rcode != dns.RcodeSuccess {
continue
}
hasSOA := false
for _, rr := range r.Answer {
if _, ok := rr.(*dns.SOA); ok {
hasSOA = true
break
}
}
if !hasSOA {
continue
}
apex = candidate
// NS resolution failures are non-fatal: we still located the zone,
// and queryPTR will fall back to the system resolver. Returning an
// error here would make reverseZoneRule wrongly report "zone not
// found".
servers, _ := resolveZoneNSAddrs(ctx, apex)
return apex, servers, nil
}
return "", nil, fmt.Errorf("could not locate reverse zone of %s", fqdn)
}
// resolveZoneNSAddrs returns "host:53" entries for every NS of the zone.
func resolveZoneNSAddrs(ctx context.Context, zone string) ([]string, error) {
var resolver net.Resolver
nss, err := resolver.LookupNS(ctx, strings.TrimSuffix(zone, "."))
if err != nil {
return nil, err
}
var out []string
for _, ns := range nss {
addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(ns.Host, "."))
if err != nil || len(addrs) == 0 {
continue
}
for _, a := range addrs {
out = append(out, hostPort(a, "53"))
}
}
return out, nil
}
// queryAtAuth sends q to the first reachable server of the list.
func queryAtAuth(ctx context.Context, servers []string, q dns.Question) (*dns.Msg, string, error) {
var lastErr error
for _, s := range servers {
r, err := dnsExchange(ctx, "", s, q, false)
if err != nil {
lastErr = err
continue
}
return r, s, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no servers provided")
}
return nil, "", lastErr
}
// rcodeText returns the textual name of an rcode or a fallback string.
func rcodeText(r int) string {
if s, ok := dns.RcodeToString[r]; ok {
return s
}
return fmt.Sprintf("RCODE(%d)", r)
}
// lowerFQDN returns the canonical lowercase FQDN form of name.
func lowerFQDN(name string) string {
return strings.ToLower(dns.Fqdn(name))
}
// reverseNameToIP decodes a reverse-arpa name back to a net.IP. It accepts
// both in-addr.arpa (IPv4) and ip6.arpa (IPv6). Returns nil if the name is
// malformed.
func reverseNameToIP(name string) net.IP {
n := strings.ToLower(strings.TrimSuffix(dns.Fqdn(name), "."))
switch {
case strings.HasSuffix(n, ".in-addr.arpa"):
labels := strings.Split(strings.TrimSuffix(n, ".in-addr.arpa"), ".")
if len(labels) != 4 {
return nil
}
// Reverse: "4.3.2.1" -> "1.2.3.4"
out := make([]string, 4)
for i, l := range labels {
if _, err := strconv.Atoi(l); err != nil {
return nil
}
out[3-i] = l
}
return net.ParseIP(strings.Join(out, "."))
case strings.HasSuffix(n, ".ip6.arpa"):
labels := strings.Split(strings.TrimSuffix(n, ".ip6.arpa"), ".")
if len(labels) != 32 {
return nil
}
// Reverse the nibbles and regroup into 8 × 4-hex blocks.
var sb strings.Builder
for i := len(labels) - 1; i >= 0; i-- {
if len(labels[i]) != 1 {
return nil
}
sb.WriteString(labels[i])
if i > 0 && (len(labels)-i)%4 == 0 {
sb.WriteByte(':')
}
}
return net.ParseIP(sb.String())
}
return nil
}
// isReverseArpa reports whether name lies inside in-addr.arpa or ip6.arpa.
func isReverseArpa(name string) bool {
n := lowerFQDN(name)
return strings.HasSuffix(n, ".in-addr.arpa.") || strings.HasSuffix(n, ".ip6.arpa.")
}

111
checker/dns_test.go Normal file
View file

@ -0,0 +1,111 @@
package checker
import (
"net"
"testing"
)
func TestReverseNameToIP(t *testing.T) {
cases := []struct {
name string
in string
want string // empty = expect nil
}{
{"ipv4 ok", "4.3.2.1.in-addr.arpa.", "1.2.3.4"},
{"ipv4 no trailing dot", "4.3.2.1.in-addr.arpa", "1.2.3.4"},
{"ipv4 uppercase suffix", "4.3.2.1.IN-ADDR.ARPA.", "1.2.3.4"},
{"ipv4 too few labels", "3.2.1.in-addr.arpa.", ""},
{"ipv4 too many labels", "5.4.3.2.1.in-addr.arpa.", ""},
{"ipv4 non-numeric label", "a.3.2.1.in-addr.arpa.", ""},
{"ipv4 octet out of range", "256.3.2.1.in-addr.arpa.", ""},
{
"ipv6 ok",
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
"2001:db8::1",
},
{"ipv6 too few nibbles", "0.0.8.b.d.0.ip6.arpa.", ""},
{
"ipv6 multi-char label",
"00.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
"",
},
{"not arpa", "example.com.", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := reverseNameToIP(tc.in)
if tc.want == "" {
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
return
}
if got == nil {
t.Fatalf("expected %s, got nil", tc.want)
}
want := net.ParseIP(tc.want)
if !got.Equal(want) {
t.Fatalf("expected %s, got %s", want, got)
}
})
}
}
func TestIsReverseArpa(t *testing.T) {
cases := map[string]bool{
"4.3.2.1.in-addr.arpa.": true,
"4.3.2.1.in-addr.arpa": true,
"1.0.0.0.ip6.arpa.": true,
"IN-ADDR.ARPA.": false, // bare apex, no leading label
"example.com.": false,
"": false,
}
for in, want := range cases {
if got := isReverseArpa(in); got != want {
t.Errorf("isReverseArpa(%q) = %v, want %v", in, got, want)
}
}
}
func TestLowerFQDN(t *testing.T) {
cases := map[string]string{
"Example.COM": "example.com.",
"example.com.": "example.com.",
"": ".",
}
for in, want := range cases {
if got := lowerFQDN(in); got != want {
t.Errorf("lowerFQDN(%q) = %q, want %q", in, got, want)
}
}
}
func TestHostPort(t *testing.T) {
cases := []struct {
host, port, want string
}{
{"1.2.3.4", "53", "1.2.3.4:53"},
{"ns1.example.com.", "53", "ns1.example.com:53"},
{"2001:db8::1", "53", "[2001:db8::1]:53"},
}
for _, tc := range cases {
if got := hostPort(tc.host, tc.port); got != tc.want {
t.Errorf("hostPort(%q,%q) = %q, want %q", tc.host, tc.port, got, tc.want)
}
}
}
func TestRcodeText(t *testing.T) {
if got := rcodeText(0); got != "NOERROR" {
t.Errorf("rcodeText(0) = %q, want NOERROR", got)
}
if got := rcodeText(3); got != "NXDOMAIN" {
t.Errorf("rcodeText(3) = %q, want NXDOMAIN", got)
}
if got := rcodeText(999); got != "RCODE(999)" {
t.Errorf("rcodeText(999) = %q, want RCODE(999)", got)
}
}

73
checker/interactive.go Normal file
View file

@ -0,0 +1,73 @@
//go:build standalone
package checker
import (
"errors"
"net"
"net/http"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm exposes a minimal form accepting either an IP address or a
// reverse-arpa owner, plus an optional expected hostname (for FCrDNS).
func (p *ptrProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "ip",
Type: "string",
Label: "IP address or reverse name",
Placeholder: "2001:db8::1 or 4.3.2.1.in-addr.arpa",
Required: true,
Description: "IPv4, IPv6 or fully-qualified reverse name. The checker derives one from the other automatically.",
},
{
Id: "expected",
Type: "string",
Label: "Expected hostname",
Placeholder: "mail.example.com",
Description: "Optional. When set, the checker compares it to the PTR served by the reverse zone.",
},
}
}
// ParseForm turns the submitted input into a minimal CheckerOptions set
// suitable for the Collect pipeline. We encode the inputs as a synthetic
// svcs.PTR service so Collect's existing unmarshaller picks it up.
func (p *ptrProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
raw := strings.TrimSpace(r.FormValue("ip"))
if raw == "" {
return nil, errors.New("IP address or reverse name is required")
}
var owner string
if ip := net.ParseIP(raw); ip != nil {
rev, err := dns.ReverseAddr(ip.String())
if err != nil {
return nil, err
}
owner = rev
} else {
owner = dns.Fqdn(raw)
}
expected := strings.TrimSpace(r.FormValue("expected"))
if expected == "" {
// Build a service payload with no declared target: the checker will
// still do existence + FCrDNS.
return sdk.CheckerOptions{
"domain_name": strings.TrimSuffix(owner, "."),
"subdomain": "@",
}, nil
}
return sdk.CheckerOptions{
"domain_name": strings.TrimSuffix(owner, "."),
"subdomain": "@",
"expected_target": expected,
}, nil
}

16
checker/provider.go Normal file
View file

@ -0,0 +1,16 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new PTR observation provider.
func Provider() sdk.ObservationProvider {
return &ptrProvider{}
}
type ptrProvider struct{}
func (p *ptrProvider) Key() sdk.ObservationKey {
return ObservationKeyPTR
}

443
checker/report.go Normal file
View file

@ -0,0 +1,443 @@
package checker
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// GetHTMLReport renders an HTML summary of the last PTR run. Hints and fixes
// are driven exclusively by the CheckStates produced by this checker's
// rules (exposed via ctx.States()); when no states are available the report
// renders the raw PTR observation without hint sections.
func (p *ptrProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data PTRData
if raw := ctx.Data(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return "", fmt.Errorf("parse PTR data: %w", err)
}
}
view := buildReportView(&data, ctx.States())
buf := &bytes.Buffer{}
if err := reportTmpl.Execute(buf, view); err != nil {
return "", err
}
return buf.String(), nil
}
// topFailureCodes orders the findings surfaced in the "Fix these first"
// section of the report. The order reflects impact: the canonical FCrDNS
// failure modes come first because they block mail delivery.
var topFailureCodes = []string{
"ptr_missing",
"ptr_rcode",
"ptr_target_unresolvable",
"ptr_forward_mismatch",
"ptr_declared_mismatch",
"ptr_not_in_reverse_zone",
"ptr_no_reverse_zone",
"ptr_owner_malformed",
"ptr_target_invalid",
"ptr_multiple",
"ptr_generic_hostname",
"ptr_query_failed",
"ptr_ipv6_missing",
}
type reportView struct {
Owner string
ReverseIP string
ReverseZone string
ReverseNS []string
DeclaredTarget string
ObservedTargets []string
ObservedTTL uint32
ForwardAddresses []ForwardAddress
ForwardMatch bool
TargetResolves bool
Rcode string
OverallStatus string
OverallStatusText string
OverallClass string
TopFailures []topFailure
OtherFindings []stateView
HasStates bool
}
type topFailure struct {
Code string
Title string
Severity string
Messages []string
Hint string
Subject string
}
type stateView struct {
Severity string
Code string
Subject string
Message string
Hint string
}
// statusToSeverity maps SDK statuses to the severity strings used by the
// HTML template. Empty string = no banner-worthy issue (pass/info/unknown
// rendered as "info" when they surface in tables, otherwise ignored).
func statusToSeverity(s sdk.Status) string {
switch s {
case sdk.StatusCrit, sdk.StatusError:
return "crit"
case sdk.StatusWarn:
return "warn"
case sdk.StatusInfo:
return "info"
}
return ""
}
func severityWeight(sev string) int {
switch sev {
case "crit":
return 3
case "warn":
return 2
case "info":
return 1
}
return 0
}
func hintFromMeta(meta map[string]any) string {
if meta == nil {
return ""
}
// Rules expose the fix under "hint"; also accept "fix" as an alias so
// either convention works.
for _, key := range []string{"hint", "fix"} {
if v, ok := meta[key]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
}
return ""
}
func buildReportView(data *PTRData, states []sdk.CheckState) *reportView {
v := &reportView{
Owner: data.OwnerName,
ReverseIP: data.ReverseIP,
ReverseZone: data.ReverseZone,
ReverseNS: data.ReverseNS,
DeclaredTarget: data.DeclaredTarget,
ObservedTargets: data.ObservedTargets,
ObservedTTL: data.ObservedTTL,
ForwardAddresses: data.ForwardAddresses,
ForwardMatch: data.ForwardMatch,
TargetResolves: data.TargetResolves,
Rcode: data.Rcode,
HasStates: len(states) > 0,
}
// Filter to actionable states (crit/warn/info); drop pass/unknown.
type issue struct {
code string
severity string
message string
subject string
hint string
}
var issues []issue
worst := ""
for _, st := range states {
sev := statusToSeverity(st.Status)
if sev == "" {
continue
}
if severityWeight(sev) > severityWeight(worst) {
worst = sev
}
issues = append(issues, issue{
code: st.Code,
severity: sev,
message: st.Message,
subject: st.Subject,
hint: hintFromMeta(st.Meta),
})
}
switch worst {
case "crit":
v.OverallStatus = "crit"
v.OverallStatusText = "Critical issues detected"
v.OverallClass = "status-crit"
case "warn":
v.OverallStatus = "warn"
v.OverallStatusText = "Warnings detected"
v.OverallClass = "status-warn"
case "info":
v.OverallStatus = "info"
v.OverallStatusText = "Informational notes"
v.OverallClass = "status-info"
default:
v.OverallStatus = "ok"
if v.HasStates {
v.OverallStatusText = "PTR is healthy (FCrDNS confirmed)"
} else {
v.OverallStatusText = "PTR observation"
}
v.OverallClass = "status-ok"
}
topIndex := map[string]int{}
for i, c := range topFailureCodes {
topIndex[c] = i
}
topMap := map[string]*topFailure{}
for _, f := range issues {
if _, isTop := topIndex[f.code]; isTop {
tf, ok := topMap[f.code]
if !ok {
tf = &topFailure{
Code: f.code,
Title: titleFor(f.code),
Subject: f.subject,
}
topMap[f.code] = tf
}
tf.Messages = append(tf.Messages, f.message)
if tf.Hint == "" {
tf.Hint = f.hint
}
if severityWeight(f.severity) > severityWeight(tf.Severity) {
tf.Severity = f.severity
}
continue
}
v.OtherFindings = append(v.OtherFindings, stateView{
Severity: f.severity,
Code: f.code,
Subject: f.subject,
Message: f.message,
Hint: f.hint,
})
}
for _, code := range topFailureCodes {
if tf, ok := topMap[code]; ok {
v.TopFailures = append(v.TopFailures, *tf)
}
}
return v
}
func titleFor(code string) string {
switch code {
case "ptr_missing":
return "No PTR record published"
case "ptr_rcode":
return "Reverse zone returned an error"
case "ptr_target_unresolvable":
return "PTR target does not resolve (forward DNS missing)"
case "ptr_forward_mismatch":
return "Forward / reverse mismatch (FCrDNS fails)"
case "ptr_declared_mismatch":
return "Authoritative PTR disagrees with the declared target"
case "ptr_not_in_reverse_zone":
return "Record is not in a reverse (*.arpa) zone"
case "ptr_no_reverse_zone":
return "Reverse zone not found"
case "ptr_owner_malformed":
return "Reverse name is malformed"
case "ptr_target_invalid":
return "PTR target is not a valid hostname"
case "ptr_multiple":
return "Multiple PTR records on the same IP"
case "ptr_generic_hostname":
return "PTR target looks auto-generated"
case "ptr_query_failed":
return "Could not reach the reverse zone servers"
case "ptr_ipv6_missing":
return "IPv6 PTR record missing"
}
return strings.ReplaceAll(code, "_", " ")
}
var reportTmpl = template.Must(template.New("ptr-report").Parse(reportTemplate))
// reportTemplate is the single-file HTML report. Styles are inlined so the
// report embeds cleanly in an iframe with no asset dependencies.
const reportTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PTR / reverse DNS report {{.Owner}}</title>
<style>
:root {
--ok: #1e9e5d;
--info: #3b82f6;
--warn: #d97706;
--crit: #dc2626;
--bg: #f7f7f8;
--card: #ffffff;
--border: #e5e7eb;
--text: #111827;
--muted: #6b7280;
}
body { margin: 0; padding: 1.2rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); line-height: 1.45; }
h1 { font-size: 1.4rem; margin: 0 0 .3rem 0; }
h2 { font-size: 1.05rem; margin: 1.5rem 0 .6rem 0; border-bottom: 1px solid var(--border); padding-bottom: .25rem; }
h3 { font-size: .95rem; margin: 0 0 .35rem 0; }
.muted { color: var(--muted); }
.status-banner { display: flex; align-items: center; justify-content: space-between; padding: .8rem 1rem; border-radius: 8px; color: #fff; margin-bottom: 1rem; }
.status-ok { background: var(--ok); }
.status-info { background: var(--info); }
.status-warn { background: var(--warn); }
.status-crit { background: var(--crit); }
.status-banner .label { font-weight: 600; font-size: 1rem; }
.status-banner .sub { opacity: .9; font-size: .85rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; }
.card .k { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; }
.card .v { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95rem; word-break: break-all; }
.top-failure { border-left: 4px solid var(--crit); background: #fef2f2; padding: .8rem 1rem; border-radius: 6px; margin-bottom: .6rem; }
.top-failure.severity-warn { border-color: var(--warn); background: #fffbeb; }
.top-failure.severity-info { border-color: var(--info); background: #eff6ff; }
.top-failure h3 { margin-bottom: .25rem; }
.top-failure ul { margin: .25rem 0 .35rem 1.1rem; padding: 0; font-size: .9rem; }
.top-failure .fix { background: rgba(0,0,0,.04); padding: .45rem .6rem; border-radius: 4px; font-size: .9rem; }
.top-failure .fix strong { display: block; color: var(--text); margin-bottom: .15rem; }
.roundtrip { display: flex; align-items: center; gap: .6rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92rem; margin-bottom: .6rem; flex-wrap: wrap; }
.roundtrip .step { padding: .15rem .5rem; border-radius: 4px; background: #eef2ff; }
.roundtrip .arrow { color: var(--muted); }
.roundtrip.match { border-color: var(--ok); }
.roundtrip.miss { border-color: var(--crit); }
.badge { display: inline-block; background: #e5e7eb; padding: .05rem .4rem; border-radius: 4px; font-size: .75rem; color: var(--text); }
.badge.on { background: #dcfce7; color: #14532d; }
.badge.off { background: #fee2e2; color: #7f1d1d; }
table { width: 100%; border-collapse: collapse; font-size: .88rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
th, td { text-align: left; padding: .45rem .7rem; border-bottom: 1px solid var(--border); }
th { background: #f3f4f6; font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
tr:last-child td { border-bottom: none; }
.sev { display: inline-block; padding: .08rem .4rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; }
.sev-info { background: var(--info); }
.sev-warn { background: var(--warn); }
.sev-crit { background: var(--crit); }
details { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .8rem; }
details pre { max-height: 360px; overflow: auto; font-size: .8rem; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
</style>
</head>
<body>
<div class="status-banner {{.OverallClass}}">
<div>
<div class="label">{{.OverallStatusText}}</div>
<div class="sub">for <code>{{.Owner}}</code>{{if .ReverseIP}} (<code>{{.ReverseIP}}</code>){{end}}</div>
</div>
<div class="sub">
{{if .ObservedTargets}}observed PTR: <code>{{index .ObservedTargets 0}}</code>{{else if eq .OverallStatus "crit"}}no PTR served{{end}}
</div>
</div>
{{if and .ReverseIP .ObservedTargets}}
<div class="roundtrip {{if .ForwardMatch}}match{{else}}miss{{end}}">
<span class="step"><code>{{.ReverseIP}}</code></span>
<span class="arrow"> PTR </span>
<span class="step"><code>{{index .ObservedTargets 0}}</code></span>
<span class="arrow"> A/AAAA </span>
<span class="step">
{{if .ForwardAddresses}}
{{range $i, $a := .ForwardAddresses}}{{if $i}}, {{end}}<code>{{$a.Address}}</code>{{end}}
{{else}}
<span class="muted">unresolved</span>
{{end}}
</span>
<span class="arrow">·</span>
{{if .ForwardMatch}}<span class="badge on">FCrDNS match</span>{{else}}<span class="badge off">FCrDNS mismatch</span>{{end}}
</div>
{{end}}
<div class="grid">
<div class="card"><div class="k">Reverse name</div><div class="v">{{.Owner}}</div></div>
<div class="card"><div class="k">Decoded IP</div><div class="v">{{if .ReverseIP}}{{.ReverseIP}}{{else}}<span class="muted"></span>{{end}}</div></div>
<div class="card"><div class="k">Reverse zone</div><div class="v">{{if .ReverseZone}}{{.ReverseZone}}{{else}}<span class="muted"></span>{{end}}</div></div>
<div class="card"><div class="k">Declared PTR target</div><div class="v">{{if .DeclaredTarget}}{{.DeclaredTarget}}{{else}}<span class="muted"></span>{{end}}</div></div>
<div class="card"><div class="k">Observed PTR target(s)</div>
<div class="v">{{if .ObservedTargets}}{{range .ObservedTargets}}{{.}}<br>{{end}}{{else}}<span class="muted">none</span>{{end}}</div>
</div>
<div class="card"><div class="k">Observed TTL</div><div class="v">{{if .ObservedTTL}}{{.ObservedTTL}}s{{else}}<span class="muted"></span>{{end}}</div></div>
<div class="card"><div class="k">Rcode</div><div class="v">{{if .Rcode}}{{.Rcode}}{{else}}<span class="muted"></span>{{end}}</div></div>
<div class="card"><div class="k">FCrDNS</div>
<div class="v">
{{if .ForwardMatch}}<span class="badge on">match</span>
{{else if .TargetResolves}}<span class="badge off">mismatch</span>
{{else}}<span class="badge off">target unresolved</span>{{end}}
</div>
</div>
</div>
{{if .TopFailures}}
<h2>Fix these first</h2>
{{range .TopFailures}}
<div class="top-failure severity-{{.Severity}}">
<h3>{{.Title}} <span class="sev sev-{{.Severity}}">{{.Severity}}</span></h3>
<ul>
{{range .Messages}}<li>{{.}}</li>{{end}}
</ul>
{{if .Hint}}<div class="fix"><strong>How to fix</strong>{{.Hint}}</div>{{end}}
</div>
{{end}}
{{end}}
{{if .ForwardAddresses}}
<h2>Forward resolution of the PTR target</h2>
<table>
<thead><tr><th>Type</th><th>Address</th><th>TTL</th></tr></thead>
<tbody>
{{range .ForwardAddresses}}
<tr>
<td><code>{{.Type}}</code></td>
<td><code>{{.Address}}</code></td>
<td>{{if .TTL}}{{.TTL}}s{{else}}<span class="muted"></span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .ReverseNS}}
<h2>Reverse zone name servers</h2>
<table>
<thead><tr><th>Server</th></tr></thead>
<tbody>
{{range .ReverseNS}}<tr><td><code>{{.}}</code></td></tr>{{end}}
</tbody>
</table>
{{end}}
{{if .OtherFindings}}
<h2>Additional findings</h2>
<table>
<thead><tr><th>Severity</th><th>Code</th><th>Subject</th><th>Message</th></tr></thead>
<tbody>
{{range .OtherFindings}}
<tr>
<td><span class="sev sev-{{.Severity}}">{{.Severity}}</span></td>
<td><code>{{.Code}}</code></td>
<td><code>{{.Subject}}</code></td>
<td>{{.Message}}{{if .Hint}}<br><span class="muted">{{.Hint}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</body>
</html>`

85
checker/rule.go Normal file
View file

@ -0,0 +1,85 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules returns the full list of CheckRules exposed by the PTR checker.
// Each rule covers one concern so callers can see at a glance which checks
// passed and which did not.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&reverseArpaRule{},
&ownerDecodeRule{},
&reverseZoneRule{},
&queryOutcomeRule{},
&ptrPresentRule{},
&singlePTRRule{},
&declaredMatchRule{},
&targetSyntaxRule{},
&genericHostnameRule{},
&targetResolvesRule{},
&fcrdnsMatchRule{},
&ipv6PTRRule{},
&ttlHygieneRule{},
}
}
// loadPTR fetches the raw observation. On error, the returned CheckState is
// what the caller should emit to short-circuit.
func loadPTR(ctx context.Context, obs sdk.ObservationGetter) (*PTRData, *sdk.CheckState) {
var data PTRData
if err := obs.Get(ctx, ObservationKeyPTR, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Code: "ptr.observation_error",
Message: fmt.Sprintf("failed to get PTR data: %v", err),
}
}
return &data, nil
}
func passState(code, message, subject string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusOK,
Code: code,
Message: message,
Subject: subject,
}
}
func skipState(code, message string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusUnknown,
Code: code,
Message: message,
}
}
func critState(code, message, subject, hint string) sdk.CheckState {
return withHint(sdk.CheckState{
Status: sdk.StatusCrit,
Code: code,
Message: message,
Subject: subject,
}, hint)
}
func warnState(code, message, subject, hint string) sdk.CheckState {
return withHint(sdk.CheckState{
Status: sdk.StatusWarn,
Code: code,
Message: message,
Subject: subject,
}, hint)
}
func withHint(st sdk.CheckState, hint string) sdk.CheckState {
if hint != "" {
st.Meta = map[string]any{"hint": hint}
}
return st
}

388
checker/rules.go Normal file
View file

@ -0,0 +1,388 @@
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
}

115
checker/types.go Normal file
View file

@ -0,0 +1,115 @@
package checker
import (
"encoding/json"
"github.com/miekg/dns"
)
// ObservationKeyPTR is the observation key for the PTR checker payload.
const ObservationKeyPTR = "ptr"
// ForwardAddress records a single A/AAAA answer collected for the PTR target.
type ForwardAddress struct {
Type string `json:"type"` // "A" or "AAAA"
Address string `json:"address"`
TTL uint32 `json:"ttl,omitempty"`
}
// PTRData is the raw observation payload persisted by the checker. It
// contains NO judgement: severity, pass/fail and derived issues are the
// responsibility of CheckRule implementations.
type PTRData struct {
// OwnerName is the FQDN of the PTR record as declared by the service
// (the reverse-arpa name, e.g. "4.3.2.1.in-addr.arpa.").
OwnerName string `json:"owner_name"`
// DeclaredTarget is the hostname the service says the PTR should point
// to. Always fully-qualified and lowercased.
DeclaredTarget string `json:"declared_target"`
// DeclaredTTL is the TTL declared by the service.
DeclaredTTL uint32 `json:"declared_ttl,omitempty"`
// InReverseArpa reports whether OwnerName lies under in-addr.arpa or
// ip6.arpa.
InReverseArpa bool `json:"in_reverse_arpa"`
// IsIPv6 reports whether OwnerName is an ip6.arpa name.
IsIPv6 bool `json:"is_ipv6"`
// ReverseIP is the IP address reconstructed from OwnerName (if parseable).
ReverseIP string `json:"reverse_ip,omitempty"`
// OwnerDecodeFailed is true when OwnerName lies under *.arpa but no IP
// could be decoded from it (malformed labels).
OwnerDecodeFailed bool `json:"owner_decode_failed,omitempty"`
// ReverseZone is the apex of the reverse zone serving OwnerName (where
// the SOA lives). Empty when it could not be located.
ReverseZone string `json:"reverse_zone,omitempty"`
// ReverseNS are the authoritative servers of the reverse zone.
ReverseNS []string `json:"reverse_ns,omitempty"`
// ZoneLookupError captures the transport/NXDOMAIN-style failure
// encountered while walking up to find the SOA. Empty on success.
ZoneLookupError string `json:"zone_lookup_error,omitempty"`
// ObservedTargets lists every PTR target observed at OwnerName. In a
// healthy setup, this has exactly one entry equal to DeclaredTarget.
ObservedTargets []string `json:"observed_targets,omitempty"`
// ObservedTTL is the TTL of the PTR RRset as seen from authoritative
// servers.
ObservedTTL uint32 `json:"observed_ttl,omitempty"`
// QueryError captures a transport-level failure while querying the PTR
// RRset (unreachable servers, timeouts, …). Empty on success.
QueryError string `json:"query_error,omitempty"`
// Rcode is the textual rcode of the PTR lookup (e.g. "NOERROR",
// "NXDOMAIN", "SERVFAIL"); empty when not applicable.
Rcode string `json:"rcode,omitempty"`
// EffectiveTarget is the hostname actually examined for hygiene and
// FCrDNS (the first observed target, or the declared one when none is
// observed). Empty when neither is available.
EffectiveTarget string `json:"effective_target,omitempty"`
// TargetSyntaxValid reports whether EffectiveTarget parses as a valid
// DNS hostname. False when EffectiveTarget is empty or malformed.
TargetSyntaxValid bool `json:"target_syntax_valid,omitempty"`
// TargetLooksGeneric reports whether EffectiveTarget embeds the IP or
// matches common ISP auto-generated patterns.
TargetLooksGeneric bool `json:"target_looks_generic,omitempty"`
// ForwardAddresses are the A/AAAA addresses the target resolves to
// (recursive resolution from the system resolver).
ForwardAddresses []ForwardAddress `json:"forward_addresses,omitempty"`
// ForwardMatch is true when ReverseIP appears among ForwardAddresses
// (Forward-Confirmed Reverse DNS).
ForwardMatch bool `json:"forward_match,omitempty"`
// TargetResolves is true when the PTR target produced at least one A or
// AAAA. Distinct from ForwardMatch: a target can resolve yet point
// somewhere else.
TargetResolves bool `json:"target_resolves,omitempty"`
}
// ptrService is the minimal local mirror of happyDomain's `svcs.PTR`. It
// carries a single *dns.PTR. The JSON key matches the Go struct field name
// used in happyDomain (`Record`).
type ptrService struct {
Record *dns.PTR `json:"Record"`
}
// serviceMessage is the minimal local mirror of happyDomain's ServiceMessage
// envelope.
type serviceMessage struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}