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 individual lookup so a slow / blackholed // authoritative server cannot stall a zone scan. Set conservatively: // the host can re-run the check at any time, and a deadline beats a // hang. const resolverTimeout = 4 * time.Second // resolveHost is the function used to classify a target. It is a // package-level variable so tests can stub it deterministically without // reaching the network. var resolveHost = defaultResolveHost // Collect walks the working zone, extracts every pointer record // (CNAME / MX / SRV / NS), classifies each target as in-zone or // external relative to the zone's registrable domain, and resolves // each target on the live DNS to detect immediate breakage. 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 publishes one DiscoveryEntry per external pointer so // a subscriber (typically domain_expiry) can RDAP/WHOIS each target's // registrable domain. In-zone pointers also get an entry so future // reachability checkers can subscribe; this checker does not currently // rely on observations attached to those entries. 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 object). 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 walks one service body and returns every // (owner, rrtype, target) triple it carries. It is best-effort: // services that do not match any known pointer shape return (nil, nil) // so the common case of a pure A/AAAA/TXT zone produces no noise in // CollectErrors. 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 sets pt.External and pt.Registrable based on // publicsuffix-derived eTLD+1. When publicsuffix cannot resolve an // eTLD+1 (e.g. internal TLD), we fall back to suffix-comparing the // target against the zone's registrable name. This fallback is // imprecise for sub-zones (a target under the parent registrable will // be treated as in-zone), but it is only reached for non-PSL names. 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 when target is not a // PSL-known name (e.g. ".internal", ".lan"). 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 a single A/AAAA lookup on target and // classifies the outcome into one of: // // - "ok" – at least one A/AAAA returned // - "no_answer" – NOERROR but the server returned no addresses // - "nxdomain" – authoritative NXDOMAIN // - "servfail" – upstream resolver returned SERVFAIL // - "timeout" – the lookup did not complete in time // - "error" – any other resolution error 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 FQDN of the service's owner. We prefer the // service's _domain field (already an FQDN with trailing dot in // happyDomain's wire shape) and fall back to 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 (it is the // authoritative owner for the record), otherwise the service-derived // owner. func preferRRName(rrName, fallback string) string { rrName = strings.TrimSuffix(rrName, ".") if rrName != "" { return rrName } return fallback } // normaliseTarget yields the FQDN form of a record target. happyDomain // stores within-zone targets relative to the zone, and external targets // fully-qualified. We accept both shapes. func normaliseTarget(target, owner, apex string) string { t := strings.TrimSpace(target) if t == "" { return "" } if trimmed, ok := strings.CutSuffix(t, "."); ok { return trimmed } // Relative: anchor under the zone apex (or the owner when apex is // empty, which only happens in tests that omit the domain name). if apex != "" { return t + "." + apex } return t + "." + owner } func displaySubdomain(s string) string { if s == "" || s == "@" { return "@" } return s }