Initial commit

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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-alias
checker-alias.so

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-alias .
FROM scratch
COPY --from=builder /checker-alias /checker-alias
EXPOSE 8080
ENTRYPOINT ["/checker-alias"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-alias
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker test clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

91
README.md Normal file
View file

@ -0,0 +1,91 @@
# checker-alias
CNAME / DNAME / ALIAS chain checker for [happyDomain](https://www.happydomain.org/).
Walks the alias chain of a name, validates hop count, TTLs, target
resolvability, apex coexistence (RFC 1912 §2.4, RFC 1034 §3.6.2,
RFC 2181 §10.1), DNAME substitutions, and DNSSEC signing of the CNAME
RRset.
## Usage
### Standalone HTTP server
```bash
# Build and run
make
./checker-alias -listen :8080
```
The server exposes:
- `GET /health`: health check
- `POST /collect`: collect alias observations (happyDomain external checker protocol)
### Docker
```bash
make docker
docker run -p 8080:8080 happydomain/checker-alias
```
### happyDomain plugin
```bash
make plugin
# produces checker-alias.so, loadable by happyDomain as a Go plugin
```
The plugin exposes a `NewCheckerPlugin` symbol returning the checker
definition and observation provider, which happyDomain registers in its
global registries at load time.
### Versioning
The binary, plugin, and Docker image embed a version string overridable
at build time:
```bash
make CHECKER_VERSION=1.2.3
make plugin CHECKER_VERSION=1.2.3
make docker CHECKER_VERSION=1.2.3
```
### happyDomain remote endpoint
Set the `endpoint` admin option for the alias checker to the URL of the
running checker-alias server (e.g., `http://checker-alias:8080`).
happyDomain will delegate observation collection to this endpoint.
## Options
| Id | Type | Default | Description |
|---------------------------|------|---------|-----------------------------------------------------------------------------|
| `maxChainLength` | uint | `8` | Above this number of hops the chain is reported as critical. |
| `minTargetTTL` | uint | `60` | Hops with a TTL below this threshold are flagged as a warning. |
| `requireResolvableTarget` | bool | `true` | When enabled, a final target with no A/AAAA is critical (otherwise warning).|
| `allowApexCNAME` | bool | `false` | When enabled, a CNAME at apex is only a warning (RFC 1912 forbids it). |
| `recognizeApexFlattening` | bool | `true` | Recognize provider-side ALIAS/ANAME flattening as informational. |
## Rules
Each rule emits a finding code. Severity can be affected by the options above.
| Code | Default severity | Condition |
|------|-----------------|-----------|
| `apex_lookup` | critical | Zone apex (SOA) cannot be located for the queried name. |
| `chain_loop` | critical | A CNAME/DNAME cycle is detected in the resolution chain. |
| `chain_length` | critical | The chain exceeds `maxChainLength` hops. |
| `chain_query_error` | warning | A DNS query fails while walking the chain (network error, timeout). |
| `chain_rcode` | critical (mid-chain) / warning (final) | A non-NOERROR response code is encountered during chain resolution or the final A/AAAA lookup. |
| `hop_ttl` | warning | A CNAME/DNAME hop has a TTL below `minTargetTTL`. |
| `cname_at_apex` | critical → warning with `allowApexCNAME` | A CNAME exists at the zone apex, conflicting with SOA/NS (RFC 1912 §2.4). |
| `apex_flattening` | info | A/AAAA records coexist with SOA/NS at the apex without a CNAME — provider-side ALIAS/ANAME flattening. Only reported when `recognizeApexFlattening` is enabled. |
| `cname_coexistence` | critical → warning with `allowApexCNAME` at apex | Other RRsets (beyond A/AAAA) coexist at a CNAME owner, violating RFC 1034 §3.6.2 / RFC 2181 §10.1. |
| `cname_dnssec` | critical | The zone is DNSSEC-signed but the CNAME RRset at the queried name lacks an RRSIG. |
| `target_resolvable` | critical → warning with `requireResolvableTarget=false` | The final target of the chain has no A or AAAA record. |
| `multiple_records` | critical | An owner in the chain carries more than one CNAME/DNAME record (malformed). |
## License
Licensed under the **MIT License** (see `LICENSE`).

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

16
go.mod Normal file
View file

@ -0,0 +1,16 @@
module git.happydns.org/checker-alias
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.3.0
github.com/miekg/dns v1.1.72
)
require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)

16
go.sum Normal file
View file

@ -0,0 +1,16 @@
git.happydns.org/checker-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A=
git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=

24
main.go Normal file
View file

@ -0,0 +1,24 @@
package main
import (
"flag"
"log"
alias "git.happydns.org/checker-alias/checker"
"git.happydns.org/checker-sdk-go/checker/server"
)
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
alias.Version = Version
srv := server.New(alias.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

17
plugin/plugin.go Normal file
View file

@ -0,0 +1,17 @@
// Command plugin is the happyDomain plugin entrypoint for the alias checker.
//
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
// runtime by happyDomain.
package main
import (
alias "git.happydns.org/checker-alias/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
alias.Version = Version
return alias.Definition(), alias.Provider(), nil
}