227 lines
5.4 KiB
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)
|
|
}
|