363 lines
9.4 KiB
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
|
|
}
|