checker-dangling/checker/collect.go

363 lines
9.4 KiB
Go

package checker
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"sort"
"strings"
"time"
"github.com/miekg/dns"
"golang.org/x/net/publicsuffix"
contract "git.happydns.org/checker-dangling/contract"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// resolverTimeout caps each lookup so a blackholed nameserver cannot stall the whole scan.
const resolverTimeout = 4 * time.Second
// resolveHost is a package-level var so tests can stub DNS without hitting the network.
var resolveHost = defaultResolveHost
func (p *danglingProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
zone, err := readZone(opts)
if err != nil {
return nil, err
}
zoneApex := strings.TrimSuffix(zone.DomainName, ".")
if zoneApex == "" {
if name, ok := sdk.GetOption[string](opts, "domain_name"); ok {
zoneApex = strings.TrimSuffix(name, ".")
}
}
zoneRegistrable, _ := publicsuffix.EffectiveTLDPlusOne(zoneApex)
skipResolution, _ := sdk.GetOption[bool](opts, "skip_resolution")
data := &DanglingData{Zone: zoneApex}
// Sort subdomains for deterministic output.
subs := make([]string, 0, len(zone.Services))
for s := range zone.Services {
subs = append(subs, s)
}
sort.Strings(subs)
// Track unique (owner, rrtype, target) so duplicate services do
// not produce duplicate findings.
seen := map[string]bool{}
for _, sub := range subs {
if err := ctx.Err(); err != nil {
return nil, err
}
for _, svc := range zone.Services[sub] {
data.ServicesScanned++
pts, perr := extractPointers(sub, zoneApex, svc)
if perr != nil {
data.CollectErrors = append(data.CollectErrors,
fmt.Sprintf("%s/%s: %v", displaySubdomain(sub), svc.Type, perr))
continue
}
for _, pt := range pts {
key := pt.Owner + "|" + pt.Rrtype + "|" + pt.Target
if seen[key] {
continue
}
seen[key] = true
classifyExternal(&pt, zoneRegistrable)
if skipResolution {
pt.Resolution = "skipped"
} else {
pt.Resolution, pt.ResolutionDetail = resolveHost(ctx, pt.Target)
}
data.Pointers = append(data.Pointers, pt)
}
}
}
return data, nil
}
// DiscoverEntries emits in-zone pointers too so future reachability checkers can subscribe,
// even though this checker ignores observations attached to them.
func (p *danglingProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*DanglingData)
if !ok || d == nil {
return nil, nil
}
out := make([]sdk.DiscoveryEntry, 0, len(d.Pointers))
for _, pt := range d.Pointers {
if pt.External && pt.Registrable != "" {
entry, err := contract.NewExternalEntry(contract.ExternalTarget{
Owner: pt.Owner,
Rrtype: pt.Rrtype,
Target: pt.Target,
Registrable: pt.Registrable,
})
if err != nil {
return nil, err
}
out = append(out, entry)
continue
}
entry, err := contract.NewInZoneEntry(contract.InZoneTarget{
Owner: pt.Owner,
Rrtype: pt.Rrtype,
Target: pt.Target,
Registrable: pt.Registrable,
})
if err != nil {
return nil, err
}
out = append(out, entry)
}
return out, nil
}
// readZone normalises the zone option (native struct or JSON).
func readZone(opts sdk.CheckerOptions) (*rawZone, error) {
v, ok := opts["zone"]
if !ok || v == nil {
return nil, fmt.Errorf("missing 'zone' option (AutoFillZone): the host did not provide a working zone")
}
raw, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("re-marshal zone option: %w", err)
}
z := &rawZone{}
if err := json.Unmarshal(raw, z); err != nil {
return nil, fmt.Errorf("decode zone option: %w", err)
}
return z, nil
}
// extractPointers returns pointer records from one service body.
// Unrecognised service shapes return (nil, nil) to avoid polluting CollectErrors for A/AAAA/TXT zones.
func extractPointers(sub, apex string, svc rawService) ([]Pointer, error) {
if len(svc.Service) == 0 {
return nil, nil
}
owner := ownerFQDN(svc.Domain, sub, apex)
switch svc.Type {
case "svcs.CNAME", "svcs.SpecialCNAME":
var b cnameBody
if err := json.Unmarshal(svc.Service, &b); err != nil {
return nil, fmt.Errorf("decode cname body: %w", err)
}
target := normaliseTarget(b.Record.Target, owner, apex)
if target == "" {
return nil, nil
}
ptOwner := preferRRName(b.Record.Hdr.Name, owner)
return []Pointer{{
Owner: ptOwner,
Subdomain: sub,
Rrtype: "CNAME",
Target: target,
ServiceType: svc.Type,
}}, nil
case "svcs.MXs":
var b mxsBody
if err := json.Unmarshal(svc.Service, &b); err != nil {
return nil, fmt.Errorf("decode mxs body: %w", err)
}
out := make([]Pointer, 0, len(b.MXs))
for _, r := range b.MXs {
target := normaliseTarget(r.Mx, owner, apex)
if target == "" {
continue
}
out = append(out, Pointer{
Owner: preferRRName(r.Hdr.Name, owner),
Subdomain: sub,
Rrtype: "MX",
Target: target,
ServiceType: svc.Type,
})
}
return out, nil
case "svcs.UnknownSRV":
var b srvsBody
if err := json.Unmarshal(svc.Service, &b); err != nil {
return nil, fmt.Errorf("decode srv body: %w", err)
}
out := make([]Pointer, 0, len(b.Records))
for _, r := range b.Records {
target := normaliseTarget(r.Target, owner, apex)
if target == "" {
continue
}
out = append(out, Pointer{
Owner: preferRRName(r.Hdr.Name, owner),
Subdomain: sub,
Rrtype: "SRV",
Target: target,
ServiceType: svc.Type,
})
}
return out, nil
case "svcs.Orphan":
var b orphanRecord
if err := json.Unmarshal(svc.Service, &b); err != nil {
return nil, fmt.Errorf("decode orphan body: %w", err)
}
ptOwner := preferRRName(b.Record.Hdr.Name, owner)
switch b.Record.Hdr.Rrtype {
case dns.TypeNS:
target := normaliseTarget(b.Record.Ns, ptOwner, apex)
if target == "" {
return nil, nil
}
return []Pointer{{
Owner: ptOwner,
Subdomain: sub,
Rrtype: "NS",
Target: target,
ServiceType: svc.Type,
}}, nil
case dns.TypeCNAME:
target := normaliseTarget(b.Record.Target, ptOwner, apex)
if target == "" {
return nil, nil
}
return []Pointer{{
Owner: ptOwner,
Subdomain: sub,
Rrtype: "CNAME",
Target: target,
ServiceType: svc.Type,
}}, nil
case dns.TypeMX:
target := normaliseTarget(b.Record.Mx, ptOwner, apex)
if target == "" {
return nil, nil
}
return []Pointer{{
Owner: ptOwner,
Subdomain: sub,
Rrtype: "MX",
Target: target,
ServiceType: svc.Type,
}}, nil
}
return nil, nil
}
return nil, nil
}
// classifyExternal marks pt.External/Registrable via eTLD+1.
// For non-PSL names (e.g. ".internal") it falls back to suffix comparison, which treats
// sub-zones of the same registrable as in-zone — acceptable given the edge-case scope.
func classifyExternal(pt *Pointer, zoneRegistrable string) {
target := strings.TrimSuffix(pt.Target, ".")
if target == "" {
return
}
reg, err := publicsuffix.EffectiveTLDPlusOne(target)
if err != nil {
// Fall back to suffix comparison for non-PSL names (e.g. ".internal").
suffix := strings.TrimSuffix(zoneRegistrable, ".")
if suffix == "" || (target != suffix && !strings.HasSuffix(target, "."+suffix)) {
pt.External = true
}
return
}
pt.Registrable = reg
if zoneRegistrable == "" || !strings.EqualFold(reg, zoneRegistrable) {
pt.External = true
}
}
// defaultResolveHost performs an A/AAAA lookup and maps the outcome to a verdict string.
func defaultResolveHost(ctx context.Context, target string) (verdict, detail string) {
target = strings.TrimSuffix(target, ".")
if target == "" {
return "skipped", "empty target"
}
cctx, cancel := context.WithTimeout(ctx, resolverTimeout)
defer cancel()
ips, err := net.DefaultResolver.LookupHost(cctx, target)
if err == nil {
if len(ips) == 0 {
return "no_answer", ""
}
return "ok", ""
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
switch {
case dnsErr.IsNotFound:
return "nxdomain", dnsErr.Err
case dnsErr.IsTimeout:
return "timeout", dnsErr.Err
case strings.Contains(strings.ToLower(dnsErr.Err), "servfail"):
return "servfail", dnsErr.Err
default:
return "error", dnsErr.Err
}
}
return "error", err.Error()
}
// ownerFQDN returns the record owner FQDN, preferring the service's _domain field over subdomain+apex.
func ownerFQDN(svcDomain, sub, apex string) string {
if svcDomain != "" {
return strings.TrimSuffix(svcDomain, ".")
}
if apex == "" {
return sub
}
if sub == "" || sub == "@" {
return apex
}
return sub + "." + apex
}
// preferRRName returns the RR header Name when present, as it is authoritative over the service-derived owner.
func preferRRName(rrName, fallback string) string {
rrName = strings.TrimSuffix(rrName, ".")
if rrName != "" {
return rrName
}
return fallback
}
// normaliseTarget converts a target to FQDN form; happyDomain stores in-zone targets relative, external ones absolute.
func normaliseTarget(target, owner, apex string) string {
t := strings.TrimSpace(target)
if t == "" {
return ""
}
if trimmed, ok := strings.CutSuffix(t, "."); ok {
return trimmed
}
// Relative target: anchor under apex (empty apex only occurs in tests that omit domain_name).
if apex != "" {
return t + "." + apex
}
return t + "." + owner
}
func displaySubdomain(s string) string {
if s == "" || s == "@" {
return "@"
}
return s
}