checker-ptr/checker/collect.go

278 lines
8.3 KiB
Go

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