Initial commit
This commit is contained in:
commit
30caf67389
18 changed files with 2098 additions and 0 deletions
397
checker/collect.go
Normal file
397
checker/collect.go
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue