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