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