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, ".") } } return lowerFQDN(ownerName), lowerFQDN(s.Record.Ptr), 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) ipStr := ip.String() if strings.Contains(h, ipStr) { return true } if strings.Contains(h, strings.ReplaceAll(ipStr, ".", "-")) { return true } return genericHints.MatchString(h) }