397 lines
11 KiB
Go
397 lines
11 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 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
|
||
}
|