278 lines
8.3 KiB
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)
|
|
}
|