Initial commit

This commit is contained in:
nemunaire 2026-04-23 19:37:14 +07:00
commit deb030653b
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
}
}

119
checker/definition.go Normal file
View file

@ -0,0 +1,119 @@
package checker
import (
"fmt"
"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: "allowApexCNAME",
Type: "bool",
Label: "Allow CNAME at apex",
Description: "Shared by cname_at_apex and cname_coexistence: when enabled, a CNAME at a zone apex (and its coexistence violations) are downgraded to warnings. RFC 1912 forbids this, so leaving it off is strongly recommended.",
Default: defaultAllowApexCNAME,
},
{
Id: "recognizeApexFlattening",
Type: "bool",
Label: "Recognize ALIAS/ANAME flattening",
Description: "Shared by apex_flattening and cname_coexistence: when enabled, providers that serve A/AAAA at the apex (ALIAS/ANAME pseudo-records) are recognised as intentional and excused from coexistence violations.",
Default: defaultRecognizeApexFlattening,
},
},
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{
apexLookupRule{},
chainLoopRule{},
chainLengthRule{},
chainQueryErrorRule{},
chainRcodeRule{},
hopTTLRule{},
cnameAtApexRule{},
apexFlatteningRule{},
cnameCoexistenceRule{},
cnameDnssecRule{},
targetResolvableRule{},
multipleRecordsRule{},
},
HasHTMLReport: true,
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 24 * time.Hour,
Default: 1 * time.Hour,
},
}
def.BuildRulesInfo()
return def
}
// ValidateOptions is surfaced through the provider (sdk.OptionsValidator) so
// the host validates shared and per-rule options in one place before running
// Collect.
func (p *aliasProvider) 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")
}
}
if v, ok := opts["minTargetTTL"]; ok {
f, ok := v.(float64)
if !ok {
return fmt.Errorf("minTargetTTL must be a number")
}
if f < 0 {
return fmt.Errorf("minTargetTTL must be >= 0")
}
}
return nil
}

170
checker/dns.go Normal file
View file

@ -0,0 +1,170 @@
package checker
import (
"context"
"fmt"
"net"
"strings"
"sync"
"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
}
results := make([][]string, len(nss))
var wg sync.WaitGroup
wg.Add(len(nss))
for i, ns := range nss {
go func() {
defer wg.Done()
addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(ns.Host, "."))
if err != nil || len(addrs) == 0 {
return
}
r := make([]string, len(addrs))
for j, a := range addrs {
r[j] = hostPort(a, "53")
}
results[i] = r
}()
}
wg.Wait()
var out []string
for _, r := range results {
out = append(out, r...)
}
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))
}

53
checker/interactive.go Normal file
View file

@ -0,0 +1,53 @@
//go:build standalone
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()
}

482
checker/report.go Normal file
View file

@ -0,0 +1,482 @@
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.
//
// The report reads rule states via ctx.States() when the host threads them
// through, so the "Fix these first" cards, severity banner, and per-rule hints
// come straight from rule output. When states are absent (older host, ad-hoc
// reporter), the report degrades gracefully to a data-only rendering: the
// chain, DNAME, and coexistence sections still render, but the judgemental
// sections are omitted.
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, ctx.States())
buf := &bytes.Buffer{}
if err := reportTmpl.Execute(buf, view); err != nil {
return "", err
}
return buf.String(), nil
}
// topFailureRules lists the rule names that deserve a dedicated "fix this
// first" card at the top of the report. Order matters: it drives the visual
// order.
var topFailureRules = []string{
"cname_at_apex",
"cname_coexistence",
"chain_loop",
"chain_length",
"target_resolvable",
"chain_rcode",
"cname_dnssec",
"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
HasStates bool
TopFailures []topFailure
OtherFindings []otherFinding
RawJSON string
}
type chainStep struct {
Index int
Owner string
Kind string
Target string
TTL uint32
Server string
IsLast bool
CSSKind string
}
type topFailure struct {
RuleName string
Title string
Severity string
Messages []string
Hint string
Subject string
}
type otherFinding struct {
Severity string
RuleName string
Subject string
Message string
Hint string
}
func buildReportView(data *AliasData, states []sdk.CheckState) *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,
HasStates: len(states) > 0,
}
v.FinalAddresses = append(v.FinalAddresses, data.FinalA...)
v.FinalAddresses = append(v.FinalAddresses, data.FinalAAAA...)
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)
}
if v.HasStates {
worst := worstStatus(states)
v.OverallStatus, v.OverallStatusText, v.OverallClass = statusLabel(worst)
topIndex := map[string]int{}
for i, r := range topFailureRules {
topIndex[r] = i
}
topMap := map[string]*topFailure{}
for _, s := range states {
// Only surface non-OK, non-Unknown states. Info is kept so a
// "skipped" explanation still reaches the operator below.
if s.Status == sdk.StatusOK || s.Status == sdk.StatusUnknown {
continue
}
if _, isTop := topIndex[s.RuleName]; isTop && (s.Status == sdk.StatusWarn || s.Status == sdk.StatusCrit) {
tf, ok := topMap[s.RuleName]
if !ok {
tf = &topFailure{
RuleName: s.RuleName,
Title: titleFor(s.RuleName),
Severity: severityClass(s.Status),
Hint: hintOf(s),
Subject: s.Subject,
}
topMap[s.RuleName] = tf
}
tf.Messages = append(tf.Messages, s.Message)
if tf.Hint == "" {
tf.Hint = hintOf(s)
}
if statusRank(s.Status) > severityRankClass(tf.Severity) {
tf.Severity = severityClass(s.Status)
}
continue
}
v.OtherFindings = append(v.OtherFindings, otherFinding{
Severity: severityClass(s.Status),
RuleName: s.RuleName,
Subject: s.Subject,
Message: s.Message,
Hint: hintOf(s),
})
}
for _, ruleName := range topFailureRules {
if tf, ok := topMap[ruleName]; ok {
v.TopFailures = append(v.TopFailures, *tf)
}
}
} else {
v.OverallStatus = "unknown"
v.OverallStatusText = "Rule output not provided"
v.OverallClass = "status-info"
}
if raw, err := json.MarshalIndent(data, "", " "); err == nil {
v.RawJSON = string(raw)
}
return v
}
// worstStatus returns the worst sdk.Status across the given states, ignoring
// OK and Unknown. It implicitly orders Crit > Warn > Info > OK.
func worstStatus(states []sdk.CheckState) sdk.Status {
worst := sdk.StatusOK
for _, s := range states {
if statusRank(s.Status) > statusRank(worst) {
worst = s.Status
}
}
return worst
}
func statusLabel(s sdk.Status) (status, text, class string) {
switch s {
case sdk.StatusCrit:
return "crit", "Critical issues detected", "status-crit"
case sdk.StatusWarn:
return "warn", "Warnings detected", "status-warn"
case sdk.StatusInfo:
return "info", "Informational notes", "status-info"
default:
return "ok", "Alias chain healthy", "status-ok"
}
}
func severityClass(s sdk.Status) string {
switch s {
case sdk.StatusCrit:
return "crit"
case sdk.StatusWarn:
return "warn"
case sdk.StatusInfo:
return "info"
case sdk.StatusError:
return "crit"
default:
return "ok"
}
}
func statusRank(s sdk.Status) int {
switch s {
case sdk.StatusCrit, sdk.StatusError:
return 4
case sdk.StatusWarn:
return 3
case sdk.StatusInfo:
return 2
case sdk.StatusOK:
return 1
}
return 0
}
func severityRankClass(c string) int {
switch c {
case "crit":
return 4
case "warn":
return 3
case "info":
return 2
case "ok":
return 1
}
return 0
}
func hintOf(s sdk.CheckState) string {
if s.Meta == nil {
return ""
}
h, _ := s.Meta[hintKey].(string)
return h
}
func titleFor(rule string) string {
switch rule {
case "cname_at_apex":
return "CNAME at zone apex"
case "cname_coexistence":
return "CNAME coexists with other records"
case "chain_loop":
return "Alias chain loops"
case "chain_length":
return "Alias chain too long"
case "target_resolvable":
return "Target does not resolve"
case "chain_rcode":
return "Alias lookup error"
case "cname_dnssec":
return "CNAME not DNSSEC-signed"
case "multiple_records":
return "Multiple CNAME records at the same name"
}
return strings.ReplaceAll(rule, "_", " ")
}
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>Rule</th><th>Subject</th><th>Message</th></tr></thead>
<tbody>
{{range .OtherFindings}}
<tr>
<td><span class="sev sev-{{.Severity}}">{{.Severity}}</span></td>
<td><code>{{.RuleName}}</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>`

99
checker/rules_apex.go Normal file
View file

@ -0,0 +1,99 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// apexLookupRule verifies that the apex zone of Owner was located.
type apexLookupRule struct{}
func (apexLookupRule) Name() string { return "apex_lookup" }
func (apexLookupRule) Description() string {
return "Verifies the zone apex (SOA) of the checked name can be located."
}
func (apexLookupRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if data.Apex != "" {
return okState(data.Apex, fmt.Sprintf("apex %s located", data.Apex))
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: data.Owner,
Message: fmt.Sprintf("could not locate zone apex: %s", data.ApexLookupError),
}, "Check that the parent delegation exists and that the zone is published.")}
}
// cnameAtApexRule flags a CNAME that sits at the zone apex. Its severity is
// controlled by the shared allowApexCNAME option: when enabled, the violation
// is downgraded to a warning.
type cnameAtApexRule struct{}
func (cnameAtApexRule) Name() string { return "cname_at_apex" }
func (cnameAtApexRule) Description() string {
return "Flags a CNAME at the zone apex, which conflicts with the SOA/NS records the apex must carry (RFC 1912 §2.4). Honours the shared allowApexCNAME option."
}
func (cnameAtApexRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if !data.OwnerIsApex {
return skipped("owner is not the zone apex")
}
if !data.ApexHasCNAME {
return okState(data.Apex, "no CNAME at apex")
}
status := sdk.StatusCrit
if allowApexCNAME(opts) {
status = sdk.StatusWarn
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: status,
Subject: data.Apex,
Message: fmt.Sprintf("CNAME at apex %s conflicts with SOA/NS (RFC 1912 §2.4)", data.Apex),
}, "Use the provider's ALIAS/ANAME flattening, an HTTP redirect, or move content to a sub-label such as www.")}
}
// apexFlatteningRule surfaces provider-side ALIAS/ANAME flattening as an
// informational notice when the operator opted in via recognizeApexFlattening.
type apexFlatteningRule struct{}
func (apexFlatteningRule) Name() string { return "apex_flattening" }
func (apexFlatteningRule) Description() string {
return "Notes ALIAS/ANAME provider-side flattening (A/AAAA served at apex alongside SOA/NS)."
}
func (apexFlatteningRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if !data.OwnerIsApex {
return skipped("owner is not the zone apex")
}
if !data.ApexFlattening {
return okState(data.Apex, "no ALIAS/ANAME flattening detected")
}
if !recognizeApexFlattening(opts) {
return skipped("recognizeApexFlattening disabled")
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: sdk.StatusInfo,
Subject: data.Apex,
Message: fmt.Sprintf("apex %s serves A/AAAA directly (provider-side ALIAS/ANAME flattening)", data.Apex),
}, "Keep the upstream target's TTL in mind: apex A/AAAA will only update as fast as the provider re-flattens.")}
}

284
checker/rules_chain.go Normal file
View file

@ -0,0 +1,284 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// chainLoopRule fires when the chain walk detected a cycle.
type chainLoopRule struct{}
func (chainLoopRule) Name() string { return "chain_loop" }
func (chainLoopRule) Description() string {
return "Detects CNAME/DNAME cycles in the resolution chain."
}
func (chainLoopRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if data.ChainTerminated.Reason != TermLoop {
return okState(data.Owner, "no loop in the alias chain")
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: data.ChainTerminated.Subject,
Message: fmt.Sprintf("chain loops back to %s", data.ChainTerminated.Subject),
}, "Break the loop by pointing the last CNAME at an A/AAAA-bearing name.")}
}
// chainLengthRule enforces a maximum chain length.
type chainLengthRule struct{}
func (chainLengthRule) Name() string { return "chain_length" }
func (chainLengthRule) Description() string {
return "Flags alias chains longer than the configured maximum (most resolvers give up around 8-16 hops)."
}
func (chainLengthRule) Options() sdk.CheckerOptionsDocumentation {
return 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.",
Default: float64(defaultMaxChainLength),
},
},
}
}
func (chainLengthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
maxLen := sdk.GetIntOption(opts, "maxChainLength", defaultMaxChainLength)
if data.ChainTerminated.Reason != TermTooLong {
return okState(data.Owner, fmt.Sprintf("chain has %d hop(s), within limit of %d", len(data.Chain), maxLen))
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: data.ChainTerminated.Subject,
Message: fmt.Sprintf("chain exceeds %d hops; many resolvers will give up", maxLen),
}, "Flatten intermediate CNAMEs so that the chain is at most a few hops long.")}
}
// chainQueryErrorRule fires when a hop query failed outright (network, timeout).
type chainQueryErrorRule struct{}
func (chainQueryErrorRule) Name() string { return "chain_query_error" }
func (chainQueryErrorRule) Description() string {
return "Flags DNS query failures encountered while walking the alias chain."
}
func (chainQueryErrorRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if data.ChainTerminated.Reason != TermQueryErr {
return okState(data.Owner, "all chain queries succeeded")
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: data.ChainTerminated.Subject,
Message: fmt.Sprintf("CNAME query for %s failed: %s", data.ChainTerminated.Subject, data.ChainTerminated.Detail),
}, "Check authoritative-server reachability and firewall rules; the alias is unusable while queries fail.")}
}
// chainRcodeRule fires when the chain terminated on a non-NOERROR rcode or the
// final A/AAAA lookup returned a non-NOERROR rcode.
type chainRcodeRule struct{}
func (chainRcodeRule) Name() string { return "chain_rcode" }
func (chainRcodeRule) Description() string {
return "Flags NXDOMAIN/SERVFAIL/other rcodes encountered mid-chain or on the final target lookup."
}
func (chainRcodeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
var out []sdk.CheckState
if data.ChainTerminated.Reason == TermRcode {
out = append(out, withHint(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: data.ChainTerminated.Subject,
Message: fmt.Sprintf("server answered %s mid-chain", data.ChainTerminated.Rcode),
}, "Ensure the zone publishes the expected record; NXDOMAIN/SERVFAIL mid-chain breaks the alias."))
}
if data.FinalRcode != "" && data.FinalRcode != "NOERROR" {
out = append(out, withHint(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: data.FinalTarget,
Message: fmt.Sprintf("final A lookup for %s returned %s", data.FinalTarget, data.FinalRcode),
}, "Check the upstream zone's A/AAAA publication."))
}
if len(out) == 0 {
return okState(data.Owner, "all chain and final lookups returned NOERROR")
}
return out
}
// hopTTLRule flags hops whose TTL is below the configured floor.
type hopTTLRule struct{}
func (hopTTLRule) Name() string { return "hop_ttl" }
func (hopTTLRule) Description() string {
return "Flags chain hops whose TTL is below the configured minimum."
}
func (hopTTLRule) Options() sdk.CheckerOptionsDocumentation {
return sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "minTargetTTL",
Type: "uint",
Label: "Minimum TTL (seconds)",
Description: "Hops with a TTL below this threshold are flagged as a warning.",
Default: float64(defaultMinTargetTTL),
},
},
}
}
func (hopTTLRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if len(data.Chain) == 0 {
return skipped("chain is empty")
}
minTTL := uint32(sdk.GetIntOption(opts, "minTargetTTL", defaultMinTargetTTL))
var out []sdk.CheckState
for _, h := range data.Chain {
if h.Kind == KindTarget || h.TTL == 0 {
continue
}
if h.TTL < minTTL {
out = append(out, withHint(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: h.Owner,
Message: fmt.Sprintf("hop %s → %s has TTL %ds (< %d)", h.Owner, h.Target, h.TTL, minTTL),
}, "Raise the CNAME TTL to improve cache efficiency (5-15 minutes is a common floor)."))
}
}
if len(out) == 0 {
return okState(data.Owner, fmt.Sprintf("all chain hops have TTL ≥ %ds", minTTL))
}
return out
}
// targetResolvableRule flags a chain whose final target publishes no A/AAAA.
// Honours the requireResolvableTarget option: when disabled, the finding is a
// warning instead of critical.
type targetResolvableRule struct{}
func (targetResolvableRule) Name() string { return "target_resolvable" }
func (targetResolvableRule) Description() string {
return "Verifies that the final target of the alias chain publishes at least one A or AAAA record."
}
func (targetResolvableRule) Options() sdk.CheckerOptionsDocumentation {
return sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
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: defaultRequireResolvableTarget,
},
},
}
}
func (targetResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if data.ChainTerminated.Reason != TermOK {
return skipped("chain did not terminate normally")
}
if len(data.FinalA) > 0 || len(data.FinalAAAA) > 0 {
return okState(data.FinalTarget, fmt.Sprintf("target %s resolves to %d address(es)", data.FinalTarget, len(data.FinalA)+len(data.FinalAAAA)))
}
status := sdk.StatusWarn
if sdk.GetBoolOption(opts, "requireResolvableTarget", defaultRequireResolvableTarget) {
status = sdk.StatusCrit
}
rcode := data.FinalRcode
if rcode == "" {
rcode = "no A/AAAA"
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: status,
Subject: data.FinalTarget,
Message: fmt.Sprintf("final target %s does not resolve to an address (%s)", data.FinalTarget, rcode),
}, "Point the alias at a name that publishes at least one A or AAAA record, or fix the upstream zone.")}
}
// multipleRecordsRule flags owners that carry more than one CNAME/DNAME in the
// resolution chain (a malformed zone condition).
type multipleRecordsRule struct{}
func (multipleRecordsRule) Name() string { return "multiple_records" }
func (multipleRecordsRule) Description() string {
return "Flags owners that carry more than one CNAME/DNAME record; only one is legal per owner."
}
func (multipleRecordsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
seen := map[string]int{}
for _, h := range data.Chain {
if h.Kind == KindCNAME || h.Kind == KindDNAME {
seen[h.Owner]++
}
}
var out []sdk.CheckState
for owner, n := range seen {
if n > 1 {
out = append(out, withHint(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: owner,
Message: fmt.Sprintf("%s carries %d CNAME/DNAME records in the chain", owner, n),
}, "Keep a single CNAME per name; remove duplicates at the authoritative zone."))
}
}
if len(out) == 0 {
return okState(data.Owner, "every chain owner carries a single CNAME/DNAME")
}
return out
}

View file

@ -0,0 +1,63 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// cnameCoexistenceRule flags sibling RRsets at the CNAME's owner (RFC 1034
// §3.6.2, RFC 2181 §10.1). Severity follows the shared allowApexCNAME option
// when the owner is the apex; A/AAAA siblings at apex are excused when the
// zone publishes ALIAS/ANAME flattening and recognizeApexFlattening is on.
type cnameCoexistenceRule struct{}
func (cnameCoexistenceRule) Name() string { return "cname_coexistence" }
func (cnameCoexistenceRule) Description() string {
return "Flags other RRsets that sit at the same owner as a CNAME (RFC 1034 §3.6.2 / RFC 2181 §10.1). Honours allowApexCNAME and recognizeApexFlattening."
}
func (cnameCoexistenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if !data.OwnerHasCNAME {
return skipped("owner has no CNAME")
}
if len(data.Coexisting) == 0 {
return okState(data.Owner, "CNAME is the only RRset at its owner")
}
isApex := data.OwnerIsApex
recognize := recognizeApexFlattening(opts)
allow := allowApexCNAME(opts)
var out []sdk.CheckState
for _, rr := range data.Coexisting {
// A/AAAA at apex alongside a CNAME is excused when we recognise
// ALIAS/ANAME flattening: two different owners cannot legally
// coexist, but a provider-side flattener can still serve that.
if isApex && recognize && data.ApexFlattening && (rr.Type == "A" || rr.Type == "AAAA") {
continue
}
status := sdk.StatusCrit
if isApex && allow {
status = sdk.StatusWarn
}
out = append(out, withHint(sdk.CheckState{
Status: status,
Subject: data.Owner,
Message: fmt.Sprintf("%s and CNAME both exist at %s (RFC 1034 §3.6.2 / RFC 2181 §10.1)", rr.Type, data.Owner),
Code: rr.Type,
}, "Remove the sibling record or move it under a different label; a name cannot simultaneously carry a CNAME and other data."))
}
if len(out) == 0 {
return okState(data.Owner, "CNAME coexistence exempted by ALIAS/ANAME flattening")
}
return out
}

88
checker/rules_common.go Normal file
View file

@ -0,0 +1,88 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Default values for shared and per-rule options. Kept here so documentation
// (Definition / rule Options()) and runtime reads stay in sync.
const (
defaultMaxChainLength = 8
defaultMinTargetTTL = 60
defaultRequireResolvableTarget = true
defaultAllowApexCNAME = false
defaultRecognizeApexFlattening = true
// hintKey is the CheckState.Meta key that carries a short remediation
// suggestion. The HTML report surfaces it as the "How to fix" block.
hintKey = "hint"
)
// loadAlias reads the primary AliasData observation. When the observation is
// missing or malformed, it returns a single StatusError state ready to be
// returned by the rule.
func loadAlias(ctx context.Context, obs sdk.ObservationGetter) (*AliasData, []sdk.CheckState) {
var data AliasData
if err := obs.Get(ctx, ObservationKeyAlias, &data); err != nil {
return nil, []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to read alias observation: %v", err),
}}
}
return &data, nil
}
// skipped wraps a single StatusInfo state describing why a rule did not fire.
// Rules must always return at least one state; "skipped" is how we encode
// "this rule has nothing to judge right now" without masking the check.
func skipped(reason string) []sdk.CheckState {
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Message: "skipped: " + reason,
}}
}
// okState builds a single StatusOK result for rules whose condition is clean.
func okState(subject, message string) []sdk.CheckState {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: subject,
Message: message,
}}
}
// withHint attaches a remediation hint to a state's Meta. No-op when hint is
// empty so callers can hand through the option unconditionally.
func withHint(s sdk.CheckState, hint string) sdk.CheckState {
if hint == "" {
return s
}
if s.Meta == nil {
s.Meta = map[string]any{}
}
s.Meta[hintKey] = hint
return s
}
// apexKnown is the gate most chain-oriented rules apply: when apex lookup
// failed there is nothing to evaluate. Rules that gate on this return
// skipped("apex lookup failed") uniformly so the UI shows a consistent line.
func apexKnown(data *AliasData) bool {
return data.ApexLookupError == "" && data.Apex != ""
}
// allowApexCNAME reads the shared option governing whether a CNAME at apex is
// downgraded to a warning.
func allowApexCNAME(opts sdk.CheckerOptions) bool {
return sdk.GetBoolOption(opts, "allowApexCNAME", defaultAllowApexCNAME)
}
// recognizeApexFlattening reads the shared option governing whether
// provider-side ALIAS/ANAME flattening is recognised (and A/AAAA alongside a
// CNAME at apex is excused from coexistence).
func recognizeApexFlattening(opts sdk.CheckerOptions) bool {
return sdk.GetBoolOption(opts, "recognizeApexFlattening", defaultRecognizeApexFlattening)
}

43
checker/rules_dnssec.go Normal file
View file

@ -0,0 +1,43 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// cnameDnssecRule flags a CNAME that lacks an RRSIG in a DNSSEC-signed zone.
type cnameDnssecRule struct{}
func (cnameDnssecRule) Name() string { return "cname_dnssec" }
func (cnameDnssecRule) Description() string {
return "Verifies that, in a DNSSEC-signed zone, the CNAME at Owner carries an RRSIG."
}
func (cnameDnssecRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if !data.ZoneSigned {
return skipped("zone not DNSSEC-signed")
}
if !data.OwnerHasCNAME {
return skipped("owner has no CNAME")
}
if !data.CNAMESigCheckDone {
return skipped("DO-bit CNAME probe did not complete")
}
if data.CNAMESigned {
return okState(data.Owner, fmt.Sprintf("CNAME at %s is DNSSEC-signed", data.Owner))
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: data.Owner,
Message: fmt.Sprintf("zone %s is DNSSEC-signed but CNAME at %s has no RRSIG", data.Apex, data.Owner),
}, "Re-sign the zone or verify your signer covers the alias RRset; unsigned answers in a signed zone SERVFAIL at validating resolvers.")}
}

351
checker/rules_test.go Normal file
View file

@ -0,0 +1,351 @@
package checker
import (
"context"
"encoding/json"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// fakeObs satisfies sdk.ObservationGetter by JSON-round-tripping a stored
// *AliasData into the destination. Rules read observation data exactly that
// way in production, so round-tripping keeps tests faithful to runtime.
type fakeObs struct {
data *AliasData
}
func (f fakeObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error {
if f.data == nil {
return nil
}
raw, err := json.Marshal(f.data)
if err != nil {
return err
}
return json.Unmarshal(raw, dest)
}
func (fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
func run(r sdk.CheckRule, data *AliasData, opts sdk.CheckerOptions) []sdk.CheckState {
return r.Evaluate(context.Background(), fakeObs{data: data}, opts)
}
// apexKnownData returns a minimal AliasData whose apex lookup succeeded, used
// as a baseline so non-apex rules can run.
func apexKnownData() *AliasData {
return &AliasData{
Owner: "www.example.com.",
Apex: "example.com.",
}
}
func assertSkipped(t *testing.T, states []sdk.CheckState, wantSubstr string) {
t.Helper()
if len(states) != 1 {
t.Fatalf("want 1 state, got %d: %+v", len(states), states)
}
if states[0].Status != sdk.StatusInfo {
t.Fatalf("want StatusInfo (skipped), got %v", states[0].Status)
}
if !strings.Contains(states[0].Message, "skipped") || !strings.Contains(states[0].Message, wantSubstr) {
t.Fatalf("want skipped message containing %q, got %q", wantSubstr, states[0].Message)
}
}
func assertSingle(t *testing.T, states []sdk.CheckState, want sdk.Status) sdk.CheckState {
t.Helper()
if len(states) != 1 {
t.Fatalf("want 1 state, got %d: %+v", len(states), states)
}
if states[0].Status != want {
t.Fatalf("want status %v, got %v (msg=%q)", want, states[0].Status, states[0].Message)
}
return states[0]
}
func TestApexLookupRule(t *testing.T) {
t.Run("ok", func(t *testing.T) {
s := assertSingle(t, run(apexLookupRule{}, apexKnownData(), nil), sdk.StatusOK)
if s.Subject != "example.com." {
t.Fatalf("want subject=example.com., got %q", s.Subject)
}
})
t.Run("failure", func(t *testing.T) {
data := &AliasData{Owner: "www.nope.invalid.", ApexLookupError: "no SOA"}
s := assertSingle(t, run(apexLookupRule{}, data, nil), sdk.StatusCrit)
if s.Meta[hintKey] == nil {
t.Fatalf("want hint, got none")
}
})
}
func TestChainLoopRule(t *testing.T) {
t.Run("ok", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated.Reason = TermOK
assertSingle(t, run(chainLoopRule{}, d, nil), sdk.StatusOK)
})
t.Run("loop", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated = ChainTermination{Reason: TermLoop, Subject: "a.example.com."}
s := assertSingle(t, run(chainLoopRule{}, d, nil), sdk.StatusCrit)
if s.Subject != "a.example.com." {
t.Fatalf("want subject to be loop offender, got %q", s.Subject)
}
})
t.Run("skip when apex unknown", func(t *testing.T) {
d := &AliasData{Owner: "x.", ApexLookupError: "boom"}
assertSkipped(t, run(chainLoopRule{}, d, nil), "apex")
})
}
func TestChainLengthRule(t *testing.T) {
t.Run("ok", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated.Reason = TermOK
assertSingle(t, run(chainLengthRule{}, d, nil), sdk.StatusOK)
})
t.Run("too long", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated = ChainTermination{Reason: TermTooLong, Subject: "deep.example.com."}
assertSingle(t, run(chainLengthRule{}, d, sdk.CheckerOptions{"maxChainLength": float64(3)}), sdk.StatusCrit)
})
}
func TestChainQueryErrorRule(t *testing.T) {
t.Run("ok", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated.Reason = TermOK
assertSingle(t, run(chainQueryErrorRule{}, d, nil), sdk.StatusOK)
})
t.Run("query err", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated = ChainTermination{Reason: TermQueryErr, Subject: "broken.example.com.", Detail: "timeout"}
assertSingle(t, run(chainQueryErrorRule{}, d, nil), sdk.StatusWarn)
})
}
func TestChainRcodeRule(t *testing.T) {
t.Run("ok", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated.Reason = TermOK
assertSingle(t, run(chainRcodeRule{}, d, nil), sdk.StatusOK)
})
t.Run("mid-chain NXDOMAIN", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated = ChainTermination{Reason: TermRcode, Subject: "gone.example.com.", Rcode: "NXDOMAIN"}
assertSingle(t, run(chainRcodeRule{}, d, nil), sdk.StatusCrit)
})
t.Run("final rcode", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated.Reason = TermOK
d.FinalTarget = "target.example."
d.FinalRcode = "SERVFAIL"
states := run(chainRcodeRule{}, d, nil)
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
t.Fatalf("want single WARN, got %+v", states)
}
})
}
func TestHopTTLRule(t *testing.T) {
t.Run("ok", func(t *testing.T) {
d := apexKnownData()
d.Chain = []ChainHop{{Owner: "a.", Kind: KindCNAME, Target: "b.", TTL: 300}}
d.ChainTerminated.Reason = TermOK
assertSingle(t, run(hopTTLRule{}, d, nil), sdk.StatusOK)
})
t.Run("multi-subject low TTL", func(t *testing.T) {
d := apexKnownData()
d.Chain = []ChainHop{
{Owner: "a.", Kind: KindCNAME, Target: "b.", TTL: 10},
{Owner: "b.", Kind: KindCNAME, Target: "c.", TTL: 20},
{Owner: "c.", Kind: KindTarget},
}
states := run(hopTTLRule{}, d, sdk.CheckerOptions{"minTargetTTL": float64(60)})
if len(states) != 2 {
t.Fatalf("want 2 states (one per low-TTL hop), got %d: %+v", len(states), states)
}
for _, s := range states {
if s.Status != sdk.StatusWarn {
t.Fatalf("want WARN, got %v", s.Status)
}
}
})
t.Run("skip empty chain", func(t *testing.T) {
d := apexKnownData()
assertSkipped(t, run(hopTTLRule{}, d, nil), "chain is empty")
})
}
func TestCnameAtApexRule(t *testing.T) {
t.Run("ok when no cname at apex", func(t *testing.T) {
d := apexKnownData()
d.OwnerIsApex = true
d.Owner = "example.com."
assertSingle(t, run(cnameAtApexRule{}, d, nil), sdk.StatusOK)
})
t.Run("crit when apex has cname", func(t *testing.T) {
d := apexKnownData()
d.OwnerIsApex = true
d.ApexHasCNAME = true
d.Owner = "example.com."
assertSingle(t, run(cnameAtApexRule{}, d, nil), sdk.StatusCrit)
})
t.Run("warn when allowApexCNAME", func(t *testing.T) {
d := apexKnownData()
d.OwnerIsApex = true
d.ApexHasCNAME = true
d.Owner = "example.com."
assertSingle(t, run(cnameAtApexRule{}, d, sdk.CheckerOptions{"allowApexCNAME": true}), sdk.StatusWarn)
})
t.Run("skip when not apex", func(t *testing.T) {
d := apexKnownData()
assertSkipped(t, run(cnameAtApexRule{}, d, nil), "apex")
})
}
func TestApexFlatteningRule(t *testing.T) {
t.Run("ok when no flattening", func(t *testing.T) {
d := apexKnownData()
d.OwnerIsApex = true
assertSingle(t, run(apexFlatteningRule{}, d, nil), sdk.StatusOK)
})
t.Run("info when flattening recognized", func(t *testing.T) {
d := apexKnownData()
d.OwnerIsApex = true
d.ApexFlattening = true
assertSingle(t, run(apexFlatteningRule{}, d, nil), sdk.StatusInfo)
})
t.Run("skip when recognizeApexFlattening=false", func(t *testing.T) {
d := apexKnownData()
d.OwnerIsApex = true
d.ApexFlattening = true
assertSkipped(t, run(apexFlatteningRule{}, d, sdk.CheckerOptions{"recognizeApexFlattening": false}), "recognizeApexFlattening")
})
}
func TestCnameCoexistenceRule(t *testing.T) {
t.Run("ok", func(t *testing.T) {
d := apexKnownData()
d.OwnerHasCNAME = true
assertSingle(t, run(cnameCoexistenceRule{}, d, nil), sdk.StatusOK)
})
t.Run("multi-subject crit", func(t *testing.T) {
d := apexKnownData()
d.OwnerHasCNAME = true
d.Coexisting = []CoexistingRRset{{Type: "MX"}, {Type: "TXT"}}
states := run(cnameCoexistenceRule{}, d, nil)
if len(states) != 2 {
t.Fatalf("want 2 states, got %d", len(states))
}
})
t.Run("apex A/AAAA excused by flattening", func(t *testing.T) {
d := apexKnownData()
d.Owner = "example.com."
d.OwnerIsApex = true
d.OwnerHasCNAME = true
d.ApexFlattening = true
d.Coexisting = []CoexistingRRset{{Type: "A"}, {Type: "AAAA"}, {Type: "MX"}}
states := run(cnameCoexistenceRule{}, d, nil)
// Only MX remains, A/AAAA excused.
if len(states) != 1 {
t.Fatalf("want 1 state (MX only), got %d: %+v", len(states), states)
}
if states[0].Code != "MX" {
t.Fatalf("want code=MX, got %q", states[0].Code)
}
})
t.Run("skip without cname", func(t *testing.T) {
d := apexKnownData()
assertSkipped(t, run(cnameCoexistenceRule{}, d, nil), "owner has no CNAME")
})
}
func TestCnameDnssecRule(t *testing.T) {
t.Run("skip unsigned zone", func(t *testing.T) {
d := apexKnownData()
d.OwnerHasCNAME = true
assertSkipped(t, run(cnameDnssecRule{}, d, nil), "zone not DNSSEC")
})
t.Run("ok when signed", func(t *testing.T) {
d := apexKnownData()
d.ZoneSigned = true
d.OwnerHasCNAME = true
d.CNAMESigCheckDone = true
d.CNAMESigned = true
assertSingle(t, run(cnameDnssecRule{}, d, nil), sdk.StatusOK)
})
t.Run("crit when unsigned cname", func(t *testing.T) {
d := apexKnownData()
d.ZoneSigned = true
d.OwnerHasCNAME = true
d.CNAMESigCheckDone = true
assertSingle(t, run(cnameDnssecRule{}, d, nil), sdk.StatusCrit)
})
}
func TestTargetResolvableRule(t *testing.T) {
t.Run("ok", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated.Reason = TermOK
d.FinalTarget = "target."
d.FinalA = []string{"1.2.3.4"}
assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusOK)
})
t.Run("crit by default", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated.Reason = TermOK
d.FinalTarget = "target."
assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusCrit)
})
t.Run("warn when requireResolvableTarget=false", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated.Reason = TermOK
d.FinalTarget = "target."
assertSingle(t, run(targetResolvableRule{}, d, sdk.CheckerOptions{"requireResolvableTarget": false}), sdk.StatusWarn)
})
t.Run("skip when chain did not terminate normally", func(t *testing.T) {
d := apexKnownData()
d.ChainTerminated.Reason = TermLoop
assertSkipped(t, run(targetResolvableRule{}, d, nil), "chain did not terminate normally")
})
}
func TestMultipleRecordsRule(t *testing.T) {
t.Run("ok", func(t *testing.T) {
d := apexKnownData()
d.Chain = []ChainHop{{Owner: "a.", Kind: KindCNAME, Target: "b."}}
assertSingle(t, run(multipleRecordsRule{}, d, nil), sdk.StatusOK)
})
t.Run("duplicate owner", func(t *testing.T) {
d := apexKnownData()
d.Chain = []ChainHop{
{Owner: "dup.", Kind: KindCNAME, Target: "b."},
{Owner: "dup.", Kind: KindCNAME, Target: "c."},
}
states := run(multipleRecordsRule{}, d, nil)
if len(states) != 1 || states[0].Status != sdk.StatusCrit {
t.Fatalf("want 1 CRIT, got %+v", states)
}
})
}
// Sanity: every rule registered in the Definition returns at least one state
// even when asked to judge a blank AliasData (apex lookup failed). This guards
// against a rule slipping through with an empty-slice return path that would
// be replaced by the SDK with StatusUnknown.
func TestAllRulesAlwaysReturnAtLeastOneState(t *testing.T) {
blank := &AliasData{ApexLookupError: "no apex"}
for _, r := range Definition().Rules {
got := run(r, blank, nil)
if len(got) == 0 {
t.Fatalf("rule %s returned no states", r.Name())
}
}
}

148
checker/types.go Normal file
View file

@ -0,0 +1,148 @@
package checker
import (
"encoding/json"
"github.com/miekg/dns"
)
// ObservationKeyAlias is the observation key for alias data.
const ObservationKeyAlias = "alias"
// 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"`
}
// 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"`
}
// TerminationReason classifies why a chain walk stopped.
type TerminationReason string
const (
TermOK TerminationReason = "ok"
TermLoop TerminationReason = "loop"
TermTooLong TerminationReason = "too_long"
TermQueryErr TerminationReason = "query_error"
TermRcode TerminationReason = "rcode"
)
// ChainTermination records why the chain walk stopped. It is always populated
// once the walk returns; rules key off Reason to decide whether they fire.
type ChainTermination struct {
Reason TerminationReason `json:"reason"`
Subject string `json:"subject,omitempty"`
Detail string `json:"detail,omitempty"`
// Rcode is populated only when Reason == TermRcode.
Rcode string `json:"rcode,omitempty"`
}
// AliasData is the observation payload persisted by the checker. It carries
// raw facts only: rules are the sole judges.
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). Empty iff the
// apex lookup failed; ApexLookupError explains why.
Apex string `json:"apex,omitempty"`
// ApexLookupError, when non-empty, captures why findApex failed.
ApexLookupError string `json:"apex_lookup_error,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"`
// ChainTerminated records why the walk stopped.
ChainTerminated ChainTermination `json:"chain_terminated"`
// 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"`
// FinalRcode is the textual rcode of the final A/AAAA lookups (e.g.
// "NOERROR", "NXDOMAIN", "SERVFAIL"); empty when not applicable.
FinalRcode string `json:"final_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"`
// OwnerHasCNAME is true when a CNAME record exists at Owner.
OwnerHasCNAME bool `json:"owner_has_cname,omitempty"`
// ApexHasA / ApexHasAAAA record the presence of A/AAAA at the apex
// (populated only when OwnerIsApex).
ApexHasA bool `json:"apex_has_a,omitempty"`
ApexHasAAAA bool `json:"apex_has_aaaa,omitempty"`
// ApexHasCNAME is true when a CNAME literally sits at the apex.
ApexHasCNAME bool `json:"apex_has_cname,omitempty"`
// ApexFlattening is true when the apex returns A/AAAA alongside SOA/NS
// without a CNAME (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"`
// CNAMESigCheckDone is true when the DO-bit probe that verifies the
// CNAME's RRSIG actually ran (i.e. the zone was signed and a CNAME was
// present to probe).
CNAMESigCheckDone bool `json:"cname_sig_check_done,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"`
}
// 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"`
}