checker-reverse-zone/checker/collect.go

227 lines
5.4 KiB
Go

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