package checker import ( "context" "encoding/json" "net" "regexp" "strings" "sync" "github.com/miekg/dns" sdk "git.happydns.org/checker-sdk-go/checker" ) // Collect runs forward resolution for each PTR; severity decisions are left to rules. func (p *reverseZoneProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { zoneName, _ := sdk.GetOption[string](opts, "domain_name") zoneName = lowerFQDN(zoneName) data := &ReverseZoneData{ Zone: zoneName, IsReverseZone: isReverseArpa(zoneName), IsIPv6: isIPv6Arpa(zoneName), } zoneObj, ok := sdk.GetOption[zoneMessage](opts, "zone") if !ok || zoneObj.Services == nil { data.LoadError = "no zone data available (missing 'zone' auto-fill)" return data, nil } maxToCheck := sdk.GetIntOption(opts, "maxPTRsToCheck", 1024) if maxToCheck <= 0 { maxToCheck = 1024 } type rawPTR struct { owner string sub string target string ttl uint32 } var raws []rawPTR for sub, services := range zoneObj.Services { for _, svc := range services { if svc.Type != "svcs.PTR" || len(svc.Service) == 0 { continue } var s ptrService if err := json.Unmarshal(svc.Service, &s); err != nil || s.Record == nil { continue } owner := buildOwnerName(sub, zoneName) target := "" if s.Record.Ptr != "" { target = lowerFQDN(s.Record.Ptr) } raws = append(raws, rawPTR{ owner: owner, sub: sub, target: target, ttl: s.Record.Hdr.Ttl, }) } } data.PTRCount = len(raws) if len(raws) > maxToCheck { data.Truncated = true raws = raws[:maxToCheck] } entriesByOwner := make(map[string]*PTREntry) var ordered []string for _, r := range raws { entry, exists := entriesByOwner[r.owner] if !exists { ip := reverseNameToIP(r.owner) ipStr := "" if ip != nil { ipStr = ip.String() } entry = &PTREntry{ OwnerName: r.owner, Subdomain: r.sub, ReverseIP: ipStr, TTL: r.ttl, } entriesByOwner[r.owner] = entry ordered = append(ordered, r.owner) } if r.target != "" && !contains(entry.Targets, r.target) { entry.Targets = append(entry.Targets, r.target) } // When several PTRs share an owner, surface the shortest non-zero TTL: // the cache lifetime of the RRset is bounded by the smallest member. if r.ttl > 0 && (entry.TTL == 0 || r.ttl < entry.TTL) { entry.TTL = r.ttl } } // Forward-resolve each effective target in parallel. Bound the fan-out so // a 1024-PTR zone does not burst into a thousand simultaneous lookups. const maxConcurrent = 16 sem := make(chan struct{}, maxConcurrent) var wg sync.WaitGroup for _, owner := range ordered { if ctx.Err() != nil { break } entry := entriesByOwner[owner] if len(entry.Targets) == 0 { continue } target := entry.Targets[0] if _, ok := dns.IsDomainName(strings.TrimSuffix(target, ".")); ok { entry.TargetSyntaxValid = true } ip := reverseNameToIP(entry.OwnerName) if ip != nil { entry.TargetLooksGeneric = looksGeneric(target, ip) } wg.Add(1) sem <- struct{}{} go func(e *PTREntry, target string, ip net.IP) { defer wg.Done() defer func() { <-sem }() addrs, ferr := resolveForward(ctx, target) match := false for _, a := range addrs { if ip != nil && ipEqual(a.Address, ip) { match = true break } } e.ForwardAddresses = addrs e.TargetResolves = len(addrs) > 0 e.ForwardMatch = match if ferr != "" { e.ForwardError = ferr } }(entry, target, ip) } wg.Wait() data.Entries = make([]PTREntry, len(ordered)) for i, owner := range ordered { data.Entries[i] = *entriesByOwner[owner] } return data, nil } // buildOwnerName joins subdomain to zone apex; "" / "@" means apex. func buildOwnerName(sub, zone string) string { zone = strings.TrimSuffix(lowerFQDN(zone), ".") if sub == "" || sub == "@" { return dns.Fqdn(zone) } sub = strings.TrimSuffix(strings.ToLower(sub), ".") if zone == "" { return dns.Fqdn(sub) } return dns.Fqdn(sub + "." + zone) } func contains(haystack []string, needle string) bool { for _, s := range haystack { if s == needle { return true } } return false } 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 { var hexBuf [32]byte const hexdigits = "0123456789abcdef" for i, b := range v6 { hexBuf[i*2] = hexdigits[b>>4] hexBuf[i*2+1] = hexdigits[b&0x0f] } flat := string(hexBuf[:]) 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], } 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 } } } 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) }