Initial commit

This commit is contained in:
nemunaire 2026-04-23 19:37:14 +07:00
commit 0c4e7d8d89
18 changed files with 1799 additions and 0 deletions

595
checker/collect.go Normal file
View file

@ -0,0 +1,595 @@
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 testsuite and returns an *AliasData populated with
// findings, a resolution chain, and optional coexistence / DNSSEC observations.
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)
minTTL := uint32(sdk.GetIntOption(opts, "minTargetTTL", 60))
requireTarget := sdk.GetBoolOption(opts, "requireResolvableTarget", true)
allowApexCNAME := sdk.GetBoolOption(opts, "allowApexCNAME", false)
recognizeApex := sdk.GetBoolOption(opts, "recognizeApexFlattening", true)
data := &AliasData{Owner: owner}
resolver := systemResolver()
// 1. Find apex and authoritative servers.
apex, servers, err := findApex(ctx, owner, resolver)
if err != nil {
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_no_apex",
Severity: SeverityCrit,
Message: fmt.Sprintf("could not locate zone apex of %s: %v", owner, err),
Subject: owner,
Hint: "Check that the parent delegation exists and that the zone is published.",
})
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,
minTTL: minTTL,
servers: servers,
apex: apex,
seenOwners: map[string]bool{},
recFallback: resolver,
followTarget: requireTarget,
}
chainCtx.walk(ctx, owner)
// 4. Apex checks (flattening, CNAME-at-apex coexistence).
if data.OwnerIsApex {
checkApex(ctx, data, servers, apex, allowApexCNAME, recognizeApex)
}
// 5. Coexistence at owner (applies at any level, not just apex).
checkCoexistence(ctx, data, servers, owner, allowApexCNAME, recognizeApex)
// 6. DNSSEC checks.
checkDNSSEC(ctx, data, servers, apex, owner)
// 7. Chain-level validations (loops, length, TTL, target resolvability).
validateChain(data, requireTarget)
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
minTTL uint32
servers []string
apex string
seenOwners map[string]bool
recFallback string
followTarget bool
}
// walk follows CNAME/DNAME hops starting from name. It writes hops into
// data.Chain and may add findings.
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.Findings = append(c.data.Findings, AliasFinding{
Code: "alias_loop",
Severity: SeverityCrit,
Message: fmt.Sprintf("chain loops back to %s", current),
Subject: current,
Hint: "Break the loop by pointing the last CNAME at an A/AAAA-bearing name.",
})
c.data.FinalTarget = current
return
}
c.seenOwners[current] = true
if i > c.maxLen {
c.data.Findings = append(c.data.Findings, AliasFinding{
Code: "alias_chain_too_long",
Severity: SeverityCrit,
Message: fmt.Sprintf("chain exceeds %d hops at %s; many resolvers will give up", c.maxLen, current),
Subject: current,
Hint: "Flatten intermediate CNAMEs so that the chain is at most a few hops long.",
})
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.Findings = append(c.data.Findings, AliasFinding{
Code: "alias_query_failed",
Severity: SeverityWarn,
Message: fmt.Sprintf("CNAME query for %s failed: %v", current, err),
Subject: current,
})
c.data.FinalTarget = current
return
}
if r.Rcode != dns.RcodeSuccess {
c.data.Rcode = rcodeText(r.Rcode)
c.data.Findings = append(c.data.Findings, AliasFinding{
Code: "alias_rcode",
Severity: SeverityCrit,
Message: fmt.Sprintf("server answered %s for %s", c.data.Rcode, current),
Subject: current,
Hint: "Ensure the zone publishes the expected record; NXDOMAIN/SERVFAIL mid-chain breaks the alias.",
})
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.resolveFinal(ctx, current, currentServers)
return
}
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,
})
if ttl < c.minTTL {
c.data.Findings = append(c.data.Findings, AliasFinding{
Code: "alias_low_ttl",
Severity: SeverityWarn,
Message: fmt.Sprintf("hop %s → %s has TTL %ds (< %d)", current, target, ttl, c.minTTL),
Subject: current,
Hint: "Raise the CNAME TTL to improve cache efficiency (515 minutes is a common floor).",
})
}
// 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.Rcode = 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
}
// checkApex verifies that a CNAME at apex does not break SOA/NS, and
// detects ALIAS/ANAME provider-side flattening.
func checkApex(ctx context.Context, data *AliasData, servers []string, apex string, allowApexCNAME, recognizeApex bool) {
// Collect A/AAAA at apex.
var hasA bool
var hasAMu sync.Mutex
var wg sync.WaitGroup
wg.Add(2)
for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} {
go func() {
defer wg.Done()
q := dns.Question{Name: apex, Qtype: qt, Qclass: dns.ClassINET}
r, _, err := queryAtAuth(ctx, "", servers, q)
if err != nil || r == nil {
return
}
for _, rr := range r.Answer {
switch rr.(type) {
case *dns.A, *dns.AAAA:
hasAMu.Lock()
hasA = true
hasAMu.Unlock()
}
}
}()
}
wg.Wait()
// CNAME at apex?
hasCNAME := false
for _, h := range data.Chain {
if h.Kind == KindCNAME && lowerFQDN(h.Owner) == lowerFQDN(apex) {
hasCNAME = true
break
}
}
if hasCNAME {
sev := SeverityCrit
if allowApexCNAME {
sev = SeverityWarn
}
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_cname_at_apex",
Severity: sev,
Message: fmt.Sprintf("CNAME at apex %s conflicts with the SOA/NS records a zone apex must carry (RFC 1912 §2.4)", apex),
Subject: apex,
Hint: "Use the provider's ALIAS/ANAME flattening, an HTTP redirect, or move content to a sub-label such as www.",
})
}
if hasA && !hasCNAME {
// A present at apex alongside SOA/NS — classic ALIAS/ANAME flattening.
data.ApexFlattening = true
if recognizeApex {
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_apex_flattening",
Severity: SeverityInfo,
Message: fmt.Sprintf("apex %s serves A/AAAA directly (provider-side ALIAS/ANAME flattening)", apex),
Subject: apex,
Hint: "Keep the upstream target's TTL in mind: apex A/AAAA will only update as fast as the provider re-flattens.",
})
}
}
}
// checkCoexistence verifies that a CNAME at owner is the only record type
// present (RFC 1034 §3.6.2, RFC 2181 §10.1).
func checkCoexistence(ctx context.Context, data *AliasData, servers []string, owner string, allowApexCNAME, recognizeApex bool) {
hasCNAME := false
for _, h := range data.Chain {
if h.Kind == KindCNAME && lowerFQDN(h.Owner) == lowerFQDN(owner) {
hasCNAME = true
break
}
}
if !hasCNAME {
return
}
// Query a handful of common sibling types at owner.
siblings := []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeMX, dns.TypeTXT,
dns.TypeNS, dns.TypeSRV, dns.TypeCAA,
}
seen := map[string]uint32{}
var seenMu 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
}
seenMu.Lock()
seen[dns.TypeToString[qt]] = rr.Header().Ttl
seenMu.Unlock()
break
}
}()
}
wg.Wait()
// Apex with ALIAS/ANAME flattening is a known exception when requested.
isApex := lowerFQDN(owner) == lowerFQDN(data.Apex)
for t, ttl := range seen {
// A/AAAA at apex alongside a CNAME is impossible in a standard zone;
// a provider may still serve it through flattening. Still report it
// as critical — two different owners cannot legally exist.
if isApex && (t == "A" || t == "AAAA") && recognizeApex && data.ApexFlattening {
continue
}
sev := SeverityCrit
if isApex && allowApexCNAME {
sev = SeverityWarn
}
data.Coexisting = append(data.Coexisting, CoexistingRRset{Type: t, TTL: ttl})
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_coexisting_rrset",
Severity: sev,
Message: fmt.Sprintf("%s and CNAME both exist at %s (RFC 1034 §3.6.2 / RFC 2181 §10.1)", t, owner),
Subject: owner,
Hint: "Remove the sibling record or move it under a different label; a name cannot simultaneously carry a CNAME and other data.",
})
}
}
// checkDNSSEC verifies that, if the zone is signed, the CNAME at owner is
// properly signed (RRSIG covers it).
func checkDNSSEC(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.CNAMESigned = sawSig
if !sawSig {
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_cname_not_signed",
Severity: SeverityCrit,
Message: fmt.Sprintf("zone %s is DNSSEC-signed but CNAME at %s has no RRSIG", apex, owner),
Subject: owner,
Hint: "Re-sign the zone or verify your signer covers the alias RRset; unsigned answers in a signed zone SERVFAIL at validating resolvers.",
})
}
}
}
// validateChain enforces global chain invariants.
func validateChain(data *AliasData, requireTarget bool) {
if len(data.Chain) == 0 {
return
}
// Target resolvability.
if last := data.Chain[len(data.Chain)-1]; last.Kind == KindTarget {
if len(data.FinalA) == 0 && len(data.FinalAAAA) == 0 {
sev := SeverityWarn
if requireTarget {
sev = SeverityCrit
}
rcode := data.Rcode
if rcode == "" {
rcode = "no A/AAAA"
}
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_target_unresolvable",
Severity: sev,
Message: fmt.Sprintf("final target %s does not resolve to an address (%s)", last.Owner, rcode),
Subject: last.Owner,
Hint: "Point the alias at a name that publishes at least one A or AAAA record, or fix the upstream zone.",
})
}
}
// Multiple CNAME/DNAME kinds with same owner (malformed zone).
seen := map[string]int{}
for _, h := range data.Chain {
if h.Kind == KindCNAME || h.Kind == KindDNAME {
seen[h.Owner]++
}
}
for o, n := range seen {
if n > 1 {
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_multiple_records",
Severity: SeverityCrit,
Message: fmt.Sprintf("%s carries %d CNAME/DNAME records in the chain; only one is legal per owner", o, n),
Subject: o,
Hint: "Keep a single CNAME per name; remove duplicates at the authoritative zone.",
})
}
}
}

103
checker/definition.go Normal file
View file

@ -0,0 +1,103 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
var Version = "built-in"
// Definition returns the CheckerDefinition for the alias checker.
func Definition() *sdk.CheckerDefinition {
def := &sdk.CheckerDefinition{
ID: "alias",
Name: "CNAME / DNAME / ALIAS chain",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
ApplyToDomain: true,
ApplyToZone: true,
LimitToServices: []string{
"svcs.CNAME",
"svcs.SpecialCNAME",
},
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyAlias},
Options: sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "maxChainLength",
Type: "uint",
Label: "Maximum chain length",
Description: "Above this number of hops the chain is reported as critical. Most resolvers give up around 816.",
Default: float64(8),
},
{
Id: "minTargetTTL",
Type: "uint",
Label: "Minimum TTL (seconds)",
Description: "Hops with a TTL below this threshold are flagged as a warning. Very short TTLs degrade cache performance.",
Default: float64(60),
},
{
Id: "requireResolvableTarget",
Type: "bool",
Label: "Require resolvable target",
Description: "When enabled, a chain whose final target returns no A/AAAA is reported as critical (otherwise a warning).",
Default: true,
},
{
Id: "allowApexCNAME",
Type: "bool",
Label: "Allow CNAME at apex",
Description: "When enabled, a CNAME at a zone apex is only reported as warning. RFC 1912 forbids this, so leaving it off is strongly recommended.",
Default: false,
},
{
Id: "recognizeApexFlattening",
Type: "bool",
Label: "Recognize ALIAS/ANAME flattening",
Description: "When enabled, providers that serve A/AAAA at the apex (ALIAS/ANAME pseudo-records) are reported as informational instead of a coexistence violation.",
Default: true,
},
},
DomainOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Label: "Parent domain name",
AutoFill: sdk.AutoFillDomainName,
},
{
Id: "subdomain",
Label: "Subdomain",
AutoFill: sdk.AutoFillSubdomain,
},
},
ServiceOpts: []sdk.CheckerOptionDocumentation{
{
Id: "service_type",
Label: "Service type",
AutoFill: sdk.AutoFillServiceType,
},
{
Id: "service",
Label: "Service",
AutoFill: sdk.AutoFillService,
},
},
},
Rules: []sdk.CheckRule{
Rule(),
},
HasHTMLReport: true,
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 24 * time.Hour,
Default: 1 * time.Hour,
},
}
def.BuildRulesInfo()
return def
}

157
checker/dns.go Normal file
View file

@ -0,0 +1,157 @@
package checker
import (
"context"
"fmt"
"net"
"strings"
"time"
"github.com/miekg/dns"
)
const dnsTimeout = 5 * time.Second
// dnsExchange sends a single query to an authoritative server (no RD).
func dnsExchange(ctx context.Context, proto, server string, q dns.Question, rd, edns bool) (*dns.Msg, error) {
client := dns.Client{Net: proto, Timeout: dnsTimeout}
m := new(dns.Msg)
m.Id = dns.Id()
m.Question = []dns.Question{q}
m.RecursionDesired = rd
if edns {
m.SetEdns0(4096, true)
}
if deadline, ok := ctx.Deadline(); ok {
if d := time.Until(deadline); d > 0 && d < client.Timeout {
client.Timeout = d
}
}
r, _, err := client.Exchange(m, server)
if err != nil {
return nil, err
}
if r == nil {
return nil, fmt.Errorf("nil response from %s", server)
}
return r, nil
}
// recursiveExchange sends a query via a recursive resolver (RD=1). Used for
// fallbacks: resolving NS addresses, following chains across foreign zones.
func recursiveExchange(ctx context.Context, server string, q dns.Question) (*dns.Msg, error) {
return dnsExchange(ctx, "", server, q, true, true)
}
// systemResolver returns the first configured resolver of the local system,
// falling back to a public one if none is configured.
func systemResolver() string {
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil || len(cfg.Servers) == 0 {
return net.JoinHostPort("1.1.1.1", "53")
}
return net.JoinHostPort(cfg.Servers[0], cfg.Port)
}
// hostPort returns "host:port", stripping the trailing dot from FQDNs.
func hostPort(host, port string) string {
return net.JoinHostPort(strings.TrimSuffix(host, "."), port)
}
// findApex walks up the labels of fqdn until it finds a zone cut (SOA), using
// the system resolver. Returns the apex FQDN and the list of "host:53"
// authoritative servers for that zone.
func findApex(ctx context.Context, fqdn, resolver string) (apex string, servers []string, err error) {
labels := dns.SplitDomainName(fqdn)
for i := range labels {
candidate := dns.Fqdn(strings.Join(labels[i:], "."))
q := dns.Question{Name: candidate, Qtype: dns.TypeSOA, Qclass: dns.ClassINET}
r, rerr := recursiveExchange(ctx, resolver, q)
if rerr != nil {
continue
}
if r.Rcode != dns.RcodeSuccess {
continue
}
hasSOA := false
for _, rr := range r.Answer {
if _, ok := rr.(*dns.SOA); ok {
hasSOA = true
break
}
}
if !hasSOA {
continue
}
apex = candidate
servers, err = resolveZoneNSAddrs(ctx, apex)
if err != nil {
return "", nil, err
}
if len(servers) == 0 {
return "", nil, fmt.Errorf("apex %s has no resolvable NS", apex)
}
return apex, servers, nil
}
return "", nil, fmt.Errorf("could not locate apex of %s", fqdn)
}
// resolveZoneNSAddrs returns "host:53" entries for every NS of the zone.
func resolveZoneNSAddrs(ctx context.Context, zone string) ([]string, error) {
var resolver net.Resolver
nss, err := resolver.LookupNS(ctx, strings.TrimSuffix(zone, "."))
if err != nil {
return nil, err
}
var out []string
for _, ns := range nss {
addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(ns.Host, "."))
if err != nil || len(addrs) == 0 {
continue
}
for _, a := range addrs {
out = append(out, hostPort(a, "53"))
}
}
return out, nil
}
// queryAtAuth sends a query to the first reachable server of list.
func queryAtAuth(ctx context.Context, proto string, servers []string, q dns.Question) (*dns.Msg, string, error) {
var lastErr error
for _, s := range servers {
r, err := dnsExchange(ctx, proto, s, q, false, true)
if err != nil {
lastErr = err
continue
}
return r, s, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no servers provided")
}
return nil, "", lastErr
}
// rcodeText returns the textual name of an rcode or a fallback string.
func rcodeText(r int) string {
if s, ok := dns.RcodeToString[r]; ok {
return s
}
return fmt.Sprintf("RCODE(%d)", r)
}
// isSubdomain reports whether child is equal to or sits under parent.
func isSubdomain(child, parent string) bool {
child = strings.ToLower(dns.Fqdn(child))
parent = strings.ToLower(dns.Fqdn(parent))
return child == parent || strings.HasSuffix(child, "."+parent)
}
// lowerFQDN returns the canonical lowercase FQDN form of name.
func lowerFQDN(name string) string {
return strings.ToLower(dns.Fqdn(name))
}

63
checker/evaluate.go Normal file
View file

@ -0,0 +1,63 @@
package checker
import (
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Evaluate turns an AliasData into one CheckState per finding. When the run
// produced no findings, it returns a single StatusOK state describing the
// healthy alias.
func Evaluate(data *AliasData) []sdk.CheckState {
if len(data.Findings) == 0 {
msg := fmt.Sprintf("Alias chain for %s is healthy", data.Owner)
if data.FinalTarget != "" && data.FinalTarget != data.Owner {
msg = fmt.Sprintf("%s → %s resolves cleanly", data.Owner, data.FinalTarget)
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Message: msg,
Subject: data.Owner,
Meta: map[string]any{
"owner": data.Owner,
"apex": data.Apex,
"final_target": data.FinalTarget,
"final_a": data.FinalA,
"final_aaaa": data.FinalAAAA,
"chain_length": len(data.Chain),
},
}}
}
out := make([]sdk.CheckState, 0, len(data.Findings))
for _, f := range data.Findings {
subject := f.Subject
if subject == "" {
subject = data.Owner
}
state := sdk.CheckState{
Status: severityToStatus(f.Severity),
Code: f.Code,
Message: f.Message,
Subject: subject,
}
if f.Hint != "" {
state.Meta = map[string]any{"hint": f.Hint}
}
out = append(out, state)
}
return out
}
func severityToStatus(s Severity) sdk.Status {
switch s {
case SeverityCrit:
return sdk.StatusCrit
case SeverityWarn:
return sdk.StatusWarn
case SeverityInfo:
return sdk.StatusInfo
}
return sdk.StatusOK
}

51
checker/interactive.go Normal file
View file

@ -0,0 +1,51 @@
package checker
import (
"errors"
"net/http"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm exposes a single "name" input for the standalone /check route.
func (p *aliasProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "name",
Type: "string",
Label: "Domain name to check",
Placeholder: "alias.example.com",
Required: true,
Description: "Fully-qualified name carrying (or suspected of carrying) a CNAME / DNAME / ALIAS record.",
},
}
}
// ParseForm turns the submitted name into a minimal CheckerOptions set. We
// let the collector discover the apex on its own.
func (p *aliasProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
return nil, errors.New("name is required")
}
name = strings.TrimSuffix(name, ".")
// Split into subdomain + parent-like domain_name. The collector accepts
// either representation; we hand both keys so rules that autoscope on
// domain_name still work.
parts := strings.SplitN(name, ".", 2)
sub := parts[0]
parent := ""
if len(parts) == 2 {
parent = parts[1]
} else {
parent = name
sub = ""
}
return sdk.CheckerOptions{
"domain_name": parent,
"subdomain": sub,
}, nil
}

22
checker/provider.go Normal file
View file

@ -0,0 +1,22 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new alias observation provider.
func Provider() sdk.ObservationProvider {
return &aliasProvider{}
}
type aliasProvider struct{}
func (p *aliasProvider) Key() sdk.ObservationKey {
return ObservationKeyAlias
}
// Definition implements sdk.CheckerDefinitionProvider so the SDK server can
// expose /definition without an extra argument.
func (p *aliasProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

421
checker/report.go Normal file
View file

@ -0,0 +1,421 @@
package checker
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// GetHTMLReport renders an HTML document summarizing the last alias run.
// Critical findings are surfaced in a dedicated top section with fix hints;
// the chain is visualized as a stepped list; the full findings list sits
// below as a detailed table.
func (p *aliasProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data AliasData
if raw := ctx.Data(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return "", fmt.Errorf("parse alias data: %w", err)
}
}
view := buildReportView(&data)
buf := &bytes.Buffer{}
if err := reportTmpl.Execute(buf, view); err != nil {
return "", err
}
return buf.String(), nil
}
// topFailureCodes lists the findings that deserve a dedicated "fix this first"
// card at the top of the report. Order matters: it drives the visual order.
var topFailureCodes = []string{
"alias_cname_at_apex",
"alias_coexisting_rrset",
"alias_loop",
"alias_chain_too_long",
"alias_target_unresolvable",
"alias_rcode",
"alias_cname_not_signed",
"alias_multiple_records",
}
// reportView is the template payload. We pre-compute everything the template
// needs so the template itself stays dumb.
type reportView struct {
Owner string
Apex string
FinalTarget string
FinalAddresses []string
OverallStatus string
OverallStatusText string
OverallClass string
ChainSteps []chainStep
DNAMEs []ChainHop
Coexisting []CoexistingRRset
ApexFlattening bool
ZoneSigned bool
CNAMESigned bool
TopFailures []topFailure
OtherFindings []AliasFinding
RawJSON string
}
type chainStep struct {
Index int
Owner string
Kind string
Target string
TTL uint32
Server string
IsLast bool
CSSKind string
}
type topFailure struct {
Code string
Title string
Severity string
Messages []string
Hint string
Subject string
}
func buildReportView(data *AliasData) *reportView {
v := &reportView{
Owner: data.Owner,
Apex: data.Apex,
FinalTarget: data.FinalTarget,
DNAMEs: data.DNAMESubstitutions,
Coexisting: data.Coexisting,
ApexFlattening: data.ApexFlattening,
ZoneSigned: data.ZoneSigned,
CNAMESigned: data.CNAMESigned,
}
v.FinalAddresses = append(v.FinalAddresses, data.FinalA...)
v.FinalAddresses = append(v.FinalAddresses, data.FinalAAAA...)
// Overall status = worst severity among findings.
worst := ""
for _, f := range data.Findings {
switch f.Severity {
case SeverityCrit:
worst = "crit"
case SeverityWarn:
if worst != "crit" {
worst = "warn"
}
case SeverityInfo:
if worst == "" {
worst = "info"
}
}
}
switch worst {
case "crit":
v.OverallStatus = "crit"
v.OverallStatusText = "Critical issues detected"
v.OverallClass = "status-crit"
case "warn":
v.OverallStatus = "warn"
v.OverallStatusText = "Warnings detected"
v.OverallClass = "status-warn"
case "info":
v.OverallStatus = "info"
v.OverallStatusText = "Informational notes"
v.OverallClass = "status-info"
default:
v.OverallStatus = "ok"
v.OverallStatusText = "Alias chain healthy"
v.OverallClass = "status-ok"
}
// Chain steps.
for i, h := range data.Chain {
step := chainStep{
Index: i + 1,
Owner: h.Owner,
Kind: string(h.Kind),
Target: h.Target,
TTL: h.TTL,
Server: h.Server,
IsLast: i == len(data.Chain)-1,
}
switch h.Kind {
case KindCNAME:
step.CSSKind = "kind-cname"
case KindDNAME:
step.CSSKind = "kind-dname"
case KindALIAS:
step.CSSKind = "kind-alias"
case KindTarget:
step.CSSKind = "kind-target"
}
v.ChainSteps = append(v.ChainSteps, step)
}
// Bucket findings: top failures (grouped by code) vs. the rest.
topIndex := map[string]int{}
for i, c := range topFailureCodes {
topIndex[c] = i
}
topMap := map[string]*topFailure{}
for _, f := range data.Findings {
if _, isTop := topIndex[f.Code]; isTop {
tf, ok := topMap[f.Code]
if !ok {
tf = &topFailure{
Code: f.Code,
Title: titleFor(f.Code),
Severity: string(f.Severity),
Hint: f.Hint,
Subject: f.Subject,
}
topMap[f.Code] = tf
}
tf.Messages = append(tf.Messages, f.Message)
if tf.Hint == "" {
tf.Hint = f.Hint
}
// Escalate severity to the worst among grouped findings.
if severityRank(f.Severity) > severityRank(Severity(tf.Severity)) {
tf.Severity = string(f.Severity)
}
continue
}
v.OtherFindings = append(v.OtherFindings, f)
}
for _, code := range topFailureCodes {
if tf, ok := topMap[code]; ok {
v.TopFailures = append(v.TopFailures, *tf)
}
}
if raw, err := json.MarshalIndent(data, "", " "); err == nil {
v.RawJSON = string(raw)
}
return v
}
func severityRank(s Severity) int {
switch s {
case SeverityCrit:
return 3
case SeverityWarn:
return 2
case SeverityInfo:
return 1
}
return 0
}
func titleFor(code string) string {
switch code {
case "alias_cname_at_apex":
return "CNAME at zone apex"
case "alias_coexisting_rrset":
return "CNAME coexists with other records"
case "alias_loop":
return "Alias chain loops"
case "alias_chain_too_long":
return "Alias chain too long"
case "alias_target_unresolvable":
return "Target does not resolve"
case "alias_rcode":
return "Alias lookup error"
case "alias_cname_not_signed":
return "CNAME not DNSSEC-signed"
case "alias_multiple_records":
return "Multiple CNAME records at the same name"
}
return strings.ReplaceAll(code, "_", " ")
}
var reportTmpl = template.Must(template.New("alias-report").Parse(reportTemplate))
// reportTemplate is the single-file HTML report. Styles are inlined so the
// report embeds cleanly in an iframe with no asset dependencies.
const reportTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Alias chain report {{.Owner}}</title>
<style>
:root {
--ok: #1e9e5d;
--info: #3b82f6;
--warn: #d97706;
--crit: #dc2626;
--bg: #f7f7f8;
--card: #ffffff;
--border: #e5e7eb;
--text: #111827;
--muted: #6b7280;
}
body { margin: 0; padding: 1.2rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); line-height: 1.45; }
h1 { font-size: 1.4rem; margin: 0 0 .3rem 0; }
h2 { font-size: 1.05rem; margin: 1.5rem 0 .6rem 0; border-bottom: 1px solid var(--border); padding-bottom: .25rem; }
h3 { font-size: .95rem; margin: 0 0 .35rem 0; }
.muted { color: var(--muted); }
.status-banner { display: flex; align-items: center; justify-content: space-between; padding: .8rem 1rem; border-radius: 8px; color: #fff; margin-bottom: 1rem; }
.status-ok { background: var(--ok); }
.status-info { background: var(--info); }
.status-warn { background: var(--warn); }
.status-crit { background: var(--crit); }
.status-banner .label { font-weight: 600; font-size: 1rem; }
.status-banner .sub { opacity: .9; font-size: .85rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; }
.card .k { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; }
.card .v { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95rem; word-break: break-all; }
.top-failure { border-left: 4px solid var(--crit); background: #fef2f2; padding: .8rem 1rem; border-radius: 6px; margin-bottom: .6rem; }
.top-failure.severity-warn { border-color: var(--warn); background: #fffbeb; }
.top-failure.severity-info { border-color: var(--info); background: #eff6ff; }
.top-failure h3 { margin-bottom: .25rem; }
.top-failure ul { margin: .25rem 0 .35rem 1.1rem; padding: 0; font-size: .9rem; }
.top-failure .fix { background: rgba(0,0,0,.04); padding: .45rem .6rem; border-radius: 4px; font-size: .9rem; }
.top-failure .fix strong { display: block; color: var(--text); margin-bottom: .15rem; }
.chain { display: flex; flex-direction: column; gap: .4rem; }
.hop { display: flex; align-items: center; gap: .6rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .8rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; }
.hop .idx { color: var(--muted); font-variant-numeric: tabular-nums; }
.hop .kind { padding: .1rem .45rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; }
.kind-cname { background: #3b82f6; }
.kind-dname { background: #8b5cf6; }
.kind-alias { background: #14b8a6; }
.kind-target { background: #1e9e5d; }
.hop .arrow { color: var(--muted); }
.hop .meta { color: var(--muted); font-size: .78rem; margin-left: auto; }
table { width: 100%; border-collapse: collapse; font-size: .88rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
th, td { text-align: left; padding: .45rem .7rem; border-bottom: 1px solid var(--border); }
th { background: #f3f4f6; font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
tr:last-child td { border-bottom: none; }
.sev { display: inline-block; padding: .08rem .4rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; }
.sev-info { background: var(--info); }
.sev-warn { background: var(--warn); }
.sev-crit { background: var(--crit); }
details { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .8rem; }
details pre { max-height: 360px; overflow: auto; font-size: .8rem; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.badge { display: inline-block; background: #e5e7eb; padding: .05rem .4rem; border-radius: 4px; font-size: .75rem; color: var(--text); }
.badge.on { background: #dcfce7; color: #14532d; }
.badge.off { background: #fee2e2; color: #7f1d1d; }
</style>
</head>
<body>
<div class="status-banner {{.OverallClass}}">
<div>
<div class="label">{{.OverallStatusText}}</div>
<div class="sub">for <code>{{.Owner}}</code></div>
</div>
<div class="sub">
{{if .FinalTarget}}final: <code>{{.FinalTarget}}</code>{{end}}
</div>
</div>
<div class="grid">
<div class="card"><div class="k">Owner</div><div class="v">{{.Owner}}</div></div>
<div class="card"><div class="k">Apex</div><div class="v">{{if .Apex}}{{.Apex}}{{else}}{{end}}</div></div>
<div class="card"><div class="k">Final target</div><div class="v">{{if .FinalTarget}}{{.FinalTarget}}{{else}}{{end}}</div></div>
<div class="card"><div class="k">Final addresses</div>
<div class="v">{{if .FinalAddresses}}{{range .FinalAddresses}}{{.}}<br>{{end}}{{else}}<span class="muted">none</span>{{end}}</div>
</div>
<div class="card"><div class="k">DNSSEC</div>
<div class="v">
{{if .ZoneSigned}}<span class="badge on">signed zone</span>{{else}}<span class="badge off">unsigned</span>{{end}}
{{if .ZoneSigned}}{{if .CNAMESigned}}<span class="badge on">CNAME signed</span>{{else}}<span class="badge off">CNAME unsigned</span>{{end}}{{end}}
</div>
</div>
<div class="card"><div class="k">Apex flattening (ALIAS/ANAME)</div>
<div class="v">{{if .ApexFlattening}}<span class="badge on">detected</span>{{else}}<span class="muted">not detected</span>{{end}}</div>
</div>
</div>
{{if .TopFailures}}
<h2>Fix these first</h2>
{{range .TopFailures}}
<div class="top-failure severity-{{.Severity}}">
<h3>{{.Title}} <span class="sev sev-{{.Severity}}">{{.Severity}}</span></h3>
<ul>
{{range .Messages}}<li>{{.}}</li>{{end}}
</ul>
{{if .Hint}}<div class="fix"><strong>How to fix</strong>{{.Hint}}</div>{{end}}
</div>
{{end}}
{{end}}
{{if .ChainSteps}}
<h2>Resolution chain</h2>
<div class="chain">
{{range .ChainSteps}}
<div class="hop">
<span class="idx">#{{.Index}}</span>
<span class="kind {{.CSSKind}}">{{.Kind}}</span>
<code>{{.Owner}}</code>
{{if .Target}}<span class="arrow"></span><code>{{.Target}}</code>{{end}}
<span class="meta">
{{if .TTL}}TTL {{.TTL}}s{{end}}
{{if .Server}} · {{.Server}}{{end}}
</span>
</div>
{{end}}
</div>
{{end}}
{{if .DNAMEs}}
<h2>DNAME substitutions</h2>
<table>
<thead><tr><th>Owner</th><th>Target</th><th>TTL</th><th>Server</th></tr></thead>
<tbody>
{{range .DNAMEs}}
<tr>
<td><code>{{.Owner}}</code></td>
<td><code>{{.Target}}</code></td>
<td>{{.TTL}}</td>
<td><code>{{.Server}}</code></td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .Coexisting}}
<h2>Records coexisting with CNAME</h2>
<table>
<thead><tr><th>Type</th><th>TTL</th></tr></thead>
<tbody>
{{range .Coexisting}}
<tr><td><code>{{.Type}}</code></td><td>{{.TTL}}</td></tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .OtherFindings}}
<h2>Additional findings</h2>
<table>
<thead><tr><th>Severity</th><th>Code</th><th>Subject</th><th>Message</th></tr></thead>
<tbody>
{{range .OtherFindings}}
<tr>
<td><span class="sev sev-{{.Severity}}">{{.Severity}}</span></td>
<td><code>{{.Code}}</code></td>
<td><code>{{.Subject}}</code></td>
<td>{{.Message}}{{if .Hint}}<br><span class="muted">{{.Hint}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .RawJSON}}
<h2>Raw observation</h2>
<details><summary class="muted">Show raw JSON</summary><pre>{{.RawJSON}}</pre></details>
{{end}}
</body>
</html>`

46
checker/rule.go Normal file
View file

@ -0,0 +1,46 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns the alias check rule.
func Rule() sdk.CheckRule {
return &aliasRule{}
}
type aliasRule struct{}
func (r *aliasRule) Name() string { return "alias_check" }
func (r *aliasRule) Description() string {
return "Verifies a CNAME / DNAME / ALIAS chain: coexistence, loops, length, target resolvability, DNSSEC coverage."
}
func (r *aliasRule) ValidateOptions(opts sdk.CheckerOptions) error {
if v, ok := opts["maxChainLength"]; ok {
f, ok := v.(float64)
if !ok {
return fmt.Errorf("maxChainLength must be a number")
}
if f < 1 {
return fmt.Errorf("maxChainLength must be >= 1")
}
}
return nil
}
func (r *aliasRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data AliasData
if err := obs.Get(ctx, ObservationKeyAlias, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to get alias data: %v", err),
Code: "alias_error",
}}
}
return Evaluate(&data)
}

127
checker/types.go Normal file
View file

@ -0,0 +1,127 @@
package checker
import (
"encoding/json"
"github.com/miekg/dns"
)
// ObservationKeyAlias is the observation key for alias data.
const ObservationKeyAlias = "alias"
// Severity classifies a finding emitted by the alias checker.
type Severity string
const (
SeverityInfo Severity = "info"
SeverityWarn Severity = "warn"
SeverityCrit Severity = "crit"
)
// AliasKind identifies the flavour of indirection involved in a hop.
type AliasKind string
const (
KindCNAME AliasKind = "CNAME"
KindDNAME AliasKind = "DNAME"
KindALIAS AliasKind = "ALIAS" // provider-flattened apex alias (pseudo-record)
KindTarget AliasKind = "TARGET"
)
// ChainHop represents one step of the resolution chain.
type ChainHop struct {
Owner string `json:"owner"`
Kind AliasKind `json:"kind"`
Target string `json:"target,omitempty"`
TTL uint32 `json:"ttl,omitempty"`
// Server is the authoritative server that answered for this hop.
Server string `json:"server,omitempty"`
// Synthesized is true when this hop is a CNAME synthesized from a DNAME.
Synthesized bool `json:"synthesized,omitempty"`
}
// AliasFinding describes a single observation produced while running
// the alias testsuite.
type AliasFinding struct {
Code string `json:"code"`
Severity Severity `json:"severity"`
Message string `json:"message"`
// Subject names the owner/target the finding applies to.
Subject string `json:"subject,omitempty"`
// Hint is a short remediation suggestion, surfaced by the HTML report.
Hint string `json:"hint,omitempty"`
}
// CoexistingRRset records an RRset that sits next to a CNAME at the same owner.
type CoexistingRRset struct {
Type string `json:"type"`
TTL uint32 `json:"ttl,omitempty"`
}
// AliasData is the observation payload persisted by the checker.
type AliasData struct {
// Owner is the name we started resolving from (FQDN).
Owner string `json:"owner"`
// Apex is the zone apex of Owner (where SOA lives).
Apex string `json:"apex,omitempty"`
// AuthServers are the authoritative servers of the apex zone.
AuthServers []string `json:"auth_servers,omitempty"`
// Chain is the ordered list of hops from Owner down to the final
// resolvable (or unresolvable) target.
Chain []ChainHop `json:"chain,omitempty"`
// FinalTarget is the last name in the chain (possibly Owner itself when
// there is no indirection).
FinalTarget string `json:"final_target,omitempty"`
// FinalA / FinalAAAA hold the addresses that the chain ultimately resolves
// to. Empty when the target does not produce any address.
FinalA []string `json:"final_a,omitempty"`
FinalAAAA []string `json:"final_aaaa,omitempty"`
// Rcode is the textual rcode of the final lookup (e.g. "NOERROR",
// "NXDOMAIN", "SERVFAIL"); empty when not applicable.
Rcode string `json:"rcode,omitempty"`
// Coexisting lists RRsets that share the owner with a CNAME. Populated
// only when a CNAME is present at Owner.
Coexisting []CoexistingRRset `json:"coexisting,omitempty"`
// OwnerIsApex is true when the queried name is the zone apex.
OwnerIsApex bool `json:"owner_is_apex,omitempty"`
// ApexFlattening is true when the apex returns A/AAAA alongside SOA/NS
// (classic ALIAS/ANAME provider-side flattening).
ApexFlattening bool `json:"apex_flattening,omitempty"`
// ZoneSigned reports whether the apex has DNSKEY records (DNSSEC signed).
ZoneSigned bool `json:"zone_signed,omitempty"`
// CNAMESigned reports whether the CNAME hop at Owner carries an RRSIG.
CNAMESigned bool `json:"cname_signed,omitempty"`
// DNAMESubstitutions records any DNAME record encountered above Owner
// that rewrote the name during resolution.
DNAMESubstitutions []ChainHop `json:"dname_substitutions,omitempty"`
// Findings is the full list of issues produced by the run.
Findings []AliasFinding `json:"findings"`
}
// cnameService is the minimal local mirror of happyDomain's `svcs.CNAME` and
// `svcs.SpecialCNAME` types. Both carry a single *dns.CNAME under the key
// "cname". github.com/miekg/dns marshals it in the shape happyDomain uses.
type cnameService struct {
Record *dns.CNAME `json:"cname"`
}
// serviceMessage is the minimal local mirror of happyDomain's ServiceMessage
// envelope.
type serviceMessage struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}