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, apex) 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, apex), 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, apex), 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, apex) 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 as an FQDN when present. // happyDomain encodes service-embedded record owners relative to the zone // apex, so the rrName must be joined with apex unless it already contains // the apex suffix (services published with absolute owners). func preferRRName(rrName, fallback, apex string) string { rrName = strings.TrimSuffix(rrName, ".") if rrName == "" { return fallback } apex = strings.TrimSuffix(apex, ".") if apex == "" || rrName == apex || strings.HasSuffix(rrName, "."+apex) { return rrName } return rrName + "." + apex } // 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 }