Initial commit
This commit is contained in:
commit
eea7e4e459
22 changed files with 2520 additions and 0 deletions
497
checker/collect.go
Normal file
497
checker/collect.go
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func (p *aliasProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
owner, err := resolveOwner(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maxChain := sdk.GetIntOption(opts, "maxChainLength", defaultMaxChainLength)
|
||||
|
||||
data := &AliasData{Owner: owner}
|
||||
resolver := systemResolver()
|
||||
|
||||
apex, servers, err := findApex(ctx, owner, resolver)
|
||||
if err != nil {
|
||||
data.ApexLookupError = err.Error()
|
||||
return data, nil
|
||||
}
|
||||
data.Apex = apex
|
||||
data.AuthServers = servers
|
||||
data.OwnerIsApex = lowerFQDN(owner) == lowerFQDN(apex)
|
||||
|
||||
data.DNAMESubstitutions = collectDNAMEs(ctx, servers, owner, apex)
|
||||
|
||||
chainCtx := &chainCtx{
|
||||
data: data,
|
||||
maxLen: maxChain,
|
||||
servers: servers,
|
||||
apex: apex,
|
||||
seenOwners: map[string]bool{},
|
||||
recFallback: resolver,
|
||||
}
|
||||
chainCtx.walk(ctx, owner)
|
||||
|
||||
if data.OwnerIsApex {
|
||||
observeApex(ctx, data, servers, apex)
|
||||
}
|
||||
|
||||
observeCoexistence(ctx, data, servers, owner)
|
||||
observeDNSSEC(ctx, data, servers, apex, owner)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// resolveOwner prefers the "service" option because its dns.CNAME owner is
|
||||
// authoritative; subdomain + domain_name is the fallback for ad-hoc forms.
|
||||
func resolveOwner(opts sdk.CheckerOptions) (string, error) {
|
||||
if svcMsg, ok := sdk.GetOption[serviceMessage](opts, "service"); ok && len(svcMsg.Service) > 0 {
|
||||
var c cnameService
|
||||
if err := json.Unmarshal(svcMsg.Service, &c); err == nil && c.Record != nil && c.Record.Hdr.Name != "" {
|
||||
return lowerFQDN(c.Record.Hdr.Name), nil
|
||||
}
|
||||
}
|
||||
|
||||
parent, _ := sdk.GetOption[string](opts, "domain_name")
|
||||
sub, _ := sdk.GetOption[string](opts, "subdomain")
|
||||
if parent == "" {
|
||||
return "", fmt.Errorf("missing 'domain_name' option")
|
||||
}
|
||||
parent = strings.TrimSuffix(parent, ".")
|
||||
if sub == "" || sub == "@" {
|
||||
return lowerFQDN(parent), nil
|
||||
}
|
||||
sub = strings.TrimSuffix(sub, ".")
|
||||
return lowerFQDN(sub + "." + parent), nil
|
||||
}
|
||||
|
||||
type chainCtx struct {
|
||||
data *AliasData
|
||||
maxLen int
|
||||
servers []string
|
||||
apex string
|
||||
seenOwners map[string]bool
|
||||
recFallback string
|
||||
}
|
||||
|
||||
func (c *chainCtx) walk(ctx context.Context, name string) {
|
||||
current := lowerFQDN(name)
|
||||
currentServers := c.servers
|
||||
currentZone := c.apex
|
||||
|
||||
for i := 0; i <= c.maxLen+1; i++ {
|
||||
if c.seenOwners[current] {
|
||||
c.data.ChainTerminated = ChainTermination{
|
||||
Reason: TermLoop,
|
||||
Subject: current,
|
||||
Detail: fmt.Sprintf("chain loops back to %s", current),
|
||||
}
|
||||
c.data.FinalTarget = current
|
||||
return
|
||||
}
|
||||
c.seenOwners[current] = true
|
||||
|
||||
if i > c.maxLen {
|
||||
c.data.ChainTerminated = ChainTermination{
|
||||
Reason: TermTooLong,
|
||||
Subject: current,
|
||||
Detail: fmt.Sprintf("chain exceeds %d hops at %s", c.maxLen, current),
|
||||
}
|
||||
c.data.FinalTarget = current
|
||||
return
|
||||
}
|
||||
|
||||
q := dns.Question{Name: current, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET}
|
||||
r, server, err := c.queryFor(ctx, currentServers, q)
|
||||
if err != nil {
|
||||
c.data.ChainTerminated = ChainTermination{
|
||||
Reason: TermQueryErr,
|
||||
Subject: current,
|
||||
Detail: err.Error(),
|
||||
}
|
||||
c.data.FinalTarget = current
|
||||
return
|
||||
}
|
||||
|
||||
if r.Rcode != dns.RcodeSuccess {
|
||||
rcode := rcodeText(r.Rcode)
|
||||
c.data.ChainTerminated = ChainTermination{
|
||||
Reason: TermRcode,
|
||||
Subject: current,
|
||||
Rcode: rcode,
|
||||
Detail: fmt.Sprintf("server answered %s for %s", rcode, current),
|
||||
}
|
||||
c.data.FinalTarget = current
|
||||
return
|
||||
}
|
||||
|
||||
cname, synthesizedFromDNAME, ttl := extractCNAME(r, current)
|
||||
if cname == "" {
|
||||
// A NOERROR with NS in Authority is a referral to a child zone:
|
||||
// re-anchor on that zone and re-query before declaring a target.
|
||||
if isReferral(r, current) {
|
||||
zone, ns, zerr := c.reanchor(ctx, current)
|
||||
if zerr == nil && len(ns) > 0 && zone != currentZone {
|
||||
currentZone = zone
|
||||
currentServers = ns
|
||||
continue
|
||||
}
|
||||
}
|
||||
c.data.Chain = append(c.data.Chain, ChainHop{
|
||||
Owner: current,
|
||||
Kind: KindTarget,
|
||||
Server: server,
|
||||
})
|
||||
c.data.FinalTarget = current
|
||||
c.data.ChainTerminated = ChainTermination{Reason: TermOK}
|
||||
c.resolveFinal(ctx, current, currentServers)
|
||||
return
|
||||
}
|
||||
|
||||
if current == c.data.Owner && !synthesizedFromDNAME {
|
||||
c.data.OwnerHasCNAME = true
|
||||
}
|
||||
|
||||
target := lowerFQDN(cname)
|
||||
kind := KindCNAME
|
||||
if synthesizedFromDNAME {
|
||||
kind = KindDNAME
|
||||
}
|
||||
c.data.Chain = append(c.data.Chain, ChainHop{
|
||||
Owner: current,
|
||||
Kind: kind,
|
||||
Target: target,
|
||||
TTL: ttl,
|
||||
Server: server,
|
||||
Synthesized: synthesizedFromDNAME,
|
||||
})
|
||||
|
||||
// Re-anchor for the next hop. Even within the original apex, the
|
||||
// target may live in a delegated child zone whose CNAMEs are not
|
||||
// answered by the parent's auth set.
|
||||
zone, ns, zerr := c.reanchor(ctx, target)
|
||||
if zerr != nil {
|
||||
c.data.ChainTerminated = ChainTermination{
|
||||
Reason: TermQueryErr,
|
||||
Subject: target,
|
||||
Detail: fmt.Sprintf("re-anchor for %s failed: %v", target, zerr),
|
||||
}
|
||||
c.data.FinalTarget = target
|
||||
return
|
||||
}
|
||||
if len(ns) == 0 {
|
||||
currentServers = []string{c.recFallback}
|
||||
} else {
|
||||
currentServers = ns
|
||||
}
|
||||
currentZone = zone
|
||||
current = target
|
||||
}
|
||||
}
|
||||
|
||||
// reanchor finds the apex of name and resolves its NS addresses. Errors are
|
||||
// returned so the caller can record them rather than masking with the resolver.
|
||||
func (c *chainCtx) reanchor(ctx context.Context, name string) (string, []string, error) {
|
||||
zone, _, err := findApex(ctx, name, c.recFallback)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
ns, err := resolveZoneNSAddrs(ctx, zone)
|
||||
if err != nil {
|
||||
return zone, nil, err
|
||||
}
|
||||
return zone, ns, nil
|
||||
}
|
||||
|
||||
// isReferral detects "NOERROR + no Answer for owner + NS in Authority": the
|
||||
// shape of a delegation response from a parent auth.
|
||||
func isReferral(r *dns.Msg, owner string) bool {
|
||||
if r == nil || r.Rcode != dns.RcodeSuccess || len(r.Answer) > 0 {
|
||||
return false
|
||||
}
|
||||
target := lowerFQDN(owner)
|
||||
for _, rr := range r.Ns {
|
||||
if ns, ok := rr.(*dns.NS); ok {
|
||||
zone := lowerFQDN(ns.Hdr.Name)
|
||||
if target == zone || strings.HasSuffix(target, "."+zone) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *chainCtx) queryFor(ctx context.Context, servers []string, q dns.Question) (*dns.Msg, string, error) {
|
||||
if len(servers) == 0 {
|
||||
r, err := recursiveExchange(ctx, c.recFallback, q)
|
||||
return r, c.recFallback, err
|
||||
}
|
||||
return queryAtAuth(ctx, "", servers, q, false)
|
||||
}
|
||||
|
||||
// extractCNAME also reports DNAME synthesis so the walker can tag the hop:
|
||||
// a synthesized CNAME is not itself a zone-published CNAME.
|
||||
func extractCNAME(r *dns.Msg, owner string) (target string, fromDNAME bool, ttl uint32) {
|
||||
for _, rr := range r.Answer {
|
||||
if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(dns.Fqdn(c.Hdr.Name), dns.Fqdn(owner)) {
|
||||
target = c.Target
|
||||
ttl = c.Hdr.Ttl
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
return "", false, 0
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
if _, ok := rr.(*dns.DNAME); ok {
|
||||
fromDNAME = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *chainCtx) resolveFinal(ctx context.Context, name string, servers []string) {
|
||||
type result struct {
|
||||
addrs []string
|
||||
rcode string
|
||||
}
|
||||
|
||||
query := func(qtype uint16) result {
|
||||
q := dns.Question{Name: dns.Fqdn(name), Qtype: qtype, Qclass: dns.ClassINET}
|
||||
var (
|
||||
r *dns.Msg
|
||||
err error
|
||||
)
|
||||
if len(servers) > 0 {
|
||||
r, _, err = queryAtAuth(ctx, "", servers, q, false)
|
||||
} else {
|
||||
r, err = recursiveExchange(ctx, c.recFallback, q)
|
||||
}
|
||||
if err != nil || r == nil {
|
||||
return result{}
|
||||
}
|
||||
var res result
|
||||
if r.Rcode != dns.RcodeSuccess {
|
||||
res.rcode = rcodeText(r.Rcode)
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
switch v := rr.(type) {
|
||||
case *dns.A:
|
||||
if qtype == dns.TypeA {
|
||||
res.addrs = append(res.addrs, v.A.String())
|
||||
}
|
||||
case *dns.AAAA:
|
||||
if qtype == dns.TypeAAAA {
|
||||
res.addrs = append(res.addrs, v.AAAA.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var aRes, aaaaRes result
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); aRes = query(dns.TypeA) }()
|
||||
go func() { defer wg.Done(); aaaaRes = query(dns.TypeAAAA) }()
|
||||
wg.Wait()
|
||||
|
||||
c.data.FinalA = append(c.data.FinalA, aRes.addrs...)
|
||||
c.data.FinalAAAA = append(c.data.FinalAAAA, aaaaRes.addrs...)
|
||||
|
||||
// Surface either rcode; A wins when both fail because A is the more common
|
||||
// resolver-driven lookup and operators usually act on it first.
|
||||
switch {
|
||||
case aRes.rcode != "":
|
||||
c.data.FinalRcode = aRes.rcode
|
||||
case aaaaRes.rcode != "":
|
||||
c.data.FinalRcode = aaaaRes.rcode
|
||||
}
|
||||
}
|
||||
|
||||
func collectDNAMEs(ctx context.Context, servers []string, owner, apex string) []ChainHop {
|
||||
labels := dns.SplitDomainName(owner)
|
||||
apexLabels := dns.SplitDomainName(apex)
|
||||
stop := max(len(labels)-len(apexLabels), 0)
|
||||
|
||||
results := make([][]ChainHop, stop)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(stop)
|
||||
for i := range stop {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
name := dns.Fqdn(strings.Join(labels[i:], "."))
|
||||
q := dns.Question{Name: name, Qtype: dns.TypeDNAME, Qclass: dns.ClassINET}
|
||||
r, server, err := queryAtAuth(ctx, "", servers, q, false)
|
||||
if err != nil || r == nil || r.Rcode != dns.RcodeSuccess {
|
||||
return
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
if d, ok := rr.(*dns.DNAME); ok {
|
||||
results[i] = append(results[i], ChainHop{
|
||||
Owner: lowerFQDN(d.Hdr.Name),
|
||||
Kind: KindDNAME,
|
||||
Target: lowerFQDN(d.Target),
|
||||
TTL: d.Hdr.Ttl,
|
||||
Server: server,
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var out []ChainHop
|
||||
for _, hops := range results {
|
||||
out = append(out, hops...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func observeApex(ctx context.Context, data *AliasData, servers []string, apex string) {
|
||||
hasRR := func(qtype uint16) bool {
|
||||
q := dns.Question{Name: apex, Qtype: qtype, Qclass: dns.ClassINET}
|
||||
r, _, err := queryAtAuth(ctx, "", servers, q, false)
|
||||
if err != nil || r == nil {
|
||||
return false
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
if rr.Header().Rrtype == qtype {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var hasA, hasAAAA bool
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); hasA = hasRR(dns.TypeA) }()
|
||||
go func() { defer wg.Done(); hasAAAA = hasRR(dns.TypeAAAA) }()
|
||||
wg.Wait()
|
||||
|
||||
data.ApexHasA = hasA
|
||||
data.ApexHasAAAA = hasAAAA
|
||||
|
||||
for _, h := range data.Chain {
|
||||
if h.Kind == KindCNAME && h.Owner == lowerFQDN(apex) {
|
||||
data.ApexHasCNAME = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (hasA || hasAAAA) && !data.ApexHasCNAME {
|
||||
data.ApexFlattening = true
|
||||
// Synthesize a pseudo-hop so the report's chain view shows the ALIAS
|
||||
// indirection that would otherwise be invisible from the wire.
|
||||
data.Chain = append(data.Chain, ChainHop{
|
||||
Owner: lowerFQDN(apex),
|
||||
Kind: KindALIAS,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func observeCoexistence(ctx context.Context, data *AliasData, servers []string, owner string) {
|
||||
if !data.OwnerHasCNAME {
|
||||
return
|
||||
}
|
||||
|
||||
siblings := []uint16{
|
||||
dns.TypeA, dns.TypeAAAA, dns.TypeMX, dns.TypeTXT,
|
||||
dns.TypeNS, dns.TypeSRV, dns.TypeCAA,
|
||||
}
|
||||
seen := map[string]uint32{}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(siblings))
|
||||
for _, qt := range siblings {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
q := dns.Question{Name: owner, Qtype: qt, Qclass: dns.ClassINET}
|
||||
r, _, err := queryAtAuth(ctx, "", servers, q, false)
|
||||
if err != nil || r == nil {
|
||||
return
|
||||
}
|
||||
// Filter on owner+type because a DNAME-synthesized CNAME would
|
||||
// otherwise count as a sibling of every queried type.
|
||||
for _, rr := range r.Answer {
|
||||
if rr.Header().Rrtype != qt {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(dns.Fqdn(rr.Header().Name), dns.Fqdn(owner)) {
|
||||
continue
|
||||
}
|
||||
mu.Lock()
|
||||
seen[dns.TypeToString[qt]] = rr.Header().Ttl
|
||||
mu.Unlock()
|
||||
break
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for t, ttl := range seen {
|
||||
data.Coexisting = append(data.Coexisting, CoexistingRRset{Type: t, TTL: ttl})
|
||||
}
|
||||
}
|
||||
|
||||
func observeDNSSEC(ctx context.Context, data *AliasData, servers []string, apex, owner string) {
|
||||
qk := dns.Question{Name: apex, Qtype: dns.TypeDNSKEY, Qclass: dns.ClassINET}
|
||||
r, _, err := queryAtAuth(ctx, "", servers, qk, true)
|
||||
// DNSKEY responses can exceed the UDP buffer; retry over TCP on truncation.
|
||||
if err == nil && r != nil && r.Truncated {
|
||||
r, _, err = queryAtAuth(ctx, "tcp", servers, qk, true)
|
||||
}
|
||||
if err != nil || r == nil || r.Rcode != dns.RcodeSuccess {
|
||||
return
|
||||
}
|
||||
signed := false
|
||||
for _, rr := range r.Answer {
|
||||
if _, ok := rr.(*dns.DNSKEY); ok {
|
||||
signed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
data.ZoneSigned = signed
|
||||
if !signed {
|
||||
return
|
||||
}
|
||||
|
||||
q := dns.Question{Name: owner, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET}
|
||||
r, _, err = queryAtAuth(ctx, "", servers, q, true)
|
||||
if err == nil && r != nil && r.Truncated {
|
||||
r, _, err = queryAtAuth(ctx, "tcp", servers, q, true)
|
||||
}
|
||||
if err != nil || r == nil {
|
||||
return
|
||||
}
|
||||
sawCNAME := false
|
||||
sawSig := false
|
||||
for _, rr := range r.Answer {
|
||||
switch v := rr.(type) {
|
||||
case *dns.CNAME:
|
||||
sawCNAME = true
|
||||
case *dns.RRSIG:
|
||||
if v.TypeCovered == dns.TypeCNAME {
|
||||
sawSig = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if sawCNAME {
|
||||
data.CNAMESigCheckDone = true
|
||||
data.CNAMESigned = sawSig
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue