Initial commit
This commit is contained in:
commit
a8c6eb7727
20 changed files with 2202 additions and 0 deletions
278
checker/collect.go
Normal file
278
checker/collect.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue