Initial commit

This commit is contained in:
nemunaire 2026-04-23 19:37:14 +07:00
commit 46883846b4
22 changed files with 2625 additions and 0 deletions

474
checker/collect.go Normal file
View file

@ -0,0 +1,474 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Collect runs the alias observation and returns an *AliasData populated with
// raw facts: the resolution chain, apex/DNSSEC flags, coexisting RRsets, and
// chain-termination reason. It does not judge; judgement lives in the rules.
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", 8)
requireTarget := sdk.GetBoolOption(opts, "requireResolvableTarget", true)
_ = requireTarget // Consumed by target_resolvable rule, not collection.
data := &AliasData{Owner: owner}
resolver := systemResolver()
// 1. Find apex and authoritative servers.
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)
// 2. Detect DNAME substitutions from owner up to apex (exclusive of apex).
data.DNAMESubstitutions = collectDNAMEs(ctx, servers, owner, apex)
// 3. Walk the CNAME/DNAME chain.
chainCtx := &chainCtx{
data: data,
maxLen: maxChain,
servers: servers,
apex: apex,
seenOwners: map[string]bool{},
recFallback: resolver,
}
chainCtx.walk(ctx, owner)
// 4. Apex-level observations (flattening, CNAME-at-apex).
if data.OwnerIsApex {
observeApex(ctx, data, servers, apex)
}
// 5. Coexistence at owner (applies at any level, not just apex).
observeCoexistence(ctx, data, servers, owner)
// 6. DNSSEC observations.
observeDNSSEC(ctx, data, servers, apex, owner)
return data, nil
}
// resolveOwner derives the FQDN to check from the auto-filled options. The
// "service" option takes precedence (it carries a dns.CNAME whose owner is
// authoritative); otherwise we fall back to subdomain + domain_name.
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
}
// chainCtx carries the mutable state of a chain walk.
type chainCtx struct {
data *AliasData
maxLen int
servers []string
apex string
seenOwners map[string]bool
recFallback string
}
// walk follows CNAME/DNAME hops starting from name. It writes hops into
// data.Chain and records the termination reason in data.ChainTerminated.
func (c *chainCtx) walk(ctx context.Context, name string) {
current := lowerFQDN(name)
currentServers := c.servers
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 == "" {
// No CNAME at this name: terminal hop, resolve A/AAAA.
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-evaluate servers for the next hop: if target leaves the apex,
// we need its own authoritative servers. Out-of-zone targets are
// resolved via the system resolver (recursive path).
if isSubdomain(target, c.apex) {
currentServers = c.servers
} else {
zone, _, _ := findApex(ctx, target, c.recFallback)
ns, err := resolveZoneNSAddrs(ctx, zone)
if err != nil || len(ns) == 0 {
currentServers = []string{c.recFallback}
} else {
currentServers = ns
}
}
current = target
}
}
// queryFor sends q, retrying via the recursive resolver if the authoritative
// set is empty (useful for foreign targets).
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)
}
// extractCNAME returns the first CNAME target matched for owner, and reports
// whether it was synthesized from a DNAME present in the same response.
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
}
// resolveFinal fetches A/AAAA of the final target and records them.
func (c *chainCtx) resolveFinal(ctx context.Context, name string, servers []string) {
var wg sync.WaitGroup
var finalA, finalAAAA []string
var rcode string
wg.Add(2)
go func() {
defer wg.Done()
q := dns.Question{Name: dns.Fqdn(name), Qtype: dns.TypeA, Qclass: dns.ClassINET}
var r *dns.Msg
var err error
if len(servers) > 0 {
r, _, err = queryAtAuth(ctx, "", servers, q)
} else {
r, err = recursiveExchange(ctx, c.recFallback, q)
}
if err == nil && r != nil {
if r.Rcode != dns.RcodeSuccess {
rcode = rcodeText(r.Rcode)
}
for _, rr := range r.Answer {
if a, ok := rr.(*dns.A); ok {
finalA = append(finalA, a.A.String())
}
}
}
}()
go func() {
defer wg.Done()
q := dns.Question{Name: dns.Fqdn(name), Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}
var r *dns.Msg
var err error
if len(servers) > 0 {
r, _, err = queryAtAuth(ctx, "", servers, q)
} else {
r, err = recursiveExchange(ctx, c.recFallback, q)
}
if err == nil && r != nil {
for _, rr := range r.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
finalAAAA = append(finalAAAA, aaaa.AAAA.String())
}
}
}
}()
wg.Wait()
c.data.FinalA = append(c.data.FinalA, finalA...)
c.data.FinalAAAA = append(c.data.FinalAAAA, finalAAAA...)
if rcode != "" {
c.data.FinalRcode = rcode
}
}
// collectDNAMEs queries every label from owner up to (but excluding) apex for
// a DNAME record, returning any substitutions found.
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)
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
}
// observeApex records apex-level signals: A/AAAA presence, CNAME-at-apex,
// ALIAS/ANAME flattening.
func observeApex(ctx context.Context, data *AliasData, servers []string, apex string) {
var hasA, hasAAAA bool
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
q := dns.Question{Name: apex, Qtype: dns.TypeA, Qclass: dns.ClassINET}
r, _, err := queryAtAuth(ctx, "", servers, q)
if err != nil || r == nil {
return
}
for _, rr := range r.Answer {
if _, ok := rr.(*dns.A); ok {
hasA = true
return
}
}
}()
go func() {
defer wg.Done()
q := dns.Question{Name: apex, Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}
r, _, err := queryAtAuth(ctx, "", servers, q)
if err != nil || r == nil {
return
}
for _, rr := range r.Answer {
if _, ok := rr.(*dns.AAAA); ok {
hasAAAA = true
return
}
}
}()
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
}
}
// observeCoexistence records any sibling RRsets that sit next to a CNAME at
// the owner.
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)
if err != nil || r == nil {
return
}
// A synthesized CNAME from DNAME will be present in Answer for
// any type; only count answers whose owner matches and whose
// type is qt.
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})
}
}
// observeDNSSEC records whether the apex zone is signed and, when a CNAME is
// present at owner, whether it carries an RRSIG.
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, "tcp", servers, qk)
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
}
// Query CNAME with DO; check for an RRSIG covering it.
q := dns.Question{Name: owner, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET}
r, _, err = queryAtAuth(ctx, "tcp", servers, q)
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
}
}