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 } }