Initial commit

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

2
.gitignore vendored Normal file
View file

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

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
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
USER 65534:65534
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`).

497
checker/collect.go Normal file
View file

@ -0,0 +1,497 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
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", defaultMaxChainLength)
data := &AliasData{Owner: owner}
resolver := systemResolver()
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)
data.DNAMESubstitutions = collectDNAMEs(ctx, servers, owner, apex)
chainCtx := &chainCtx{
data: data,
maxLen: maxChain,
servers: servers,
apex: apex,
seenOwners: map[string]bool{},
recFallback: resolver,
}
chainCtx.walk(ctx, owner)
if data.OwnerIsApex {
observeApex(ctx, data, servers, apex)
}
observeCoexistence(ctx, data, servers, owner)
observeDNSSEC(ctx, data, servers, apex, owner)
return data, nil
}
// resolveOwner prefers the "service" option because its dns.CNAME owner is
// authoritative; subdomain + domain_name is the fallback for ad-hoc forms.
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
}
type chainCtx struct {
data *AliasData
maxLen int
servers []string
apex string
seenOwners map[string]bool
recFallback string
}
func (c *chainCtx) walk(ctx context.Context, name string) {
current := lowerFQDN(name)
currentServers := c.servers
currentZone := c.apex
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 == "" {
// A NOERROR with NS in Authority is a referral to a child zone:
// re-anchor on that zone and re-query before declaring a target.
if isReferral(r, current) {
zone, ns, zerr := c.reanchor(ctx, current)
if zerr == nil && len(ns) > 0 && zone != currentZone {
currentZone = zone
currentServers = ns
continue
}
}
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-anchor for the next hop. Even within the original apex, the
// target may live in a delegated child zone whose CNAMEs are not
// answered by the parent's auth set.
zone, ns, zerr := c.reanchor(ctx, target)
if zerr != nil {
c.data.ChainTerminated = ChainTermination{
Reason: TermQueryErr,
Subject: target,
Detail: fmt.Sprintf("re-anchor for %s failed: %v", target, zerr),
}
c.data.FinalTarget = target
return
}
if len(ns) == 0 {
currentServers = []string{c.recFallback}
} else {
currentServers = ns
}
currentZone = zone
current = target
}
}
// reanchor finds the apex of name and resolves its NS addresses. Errors are
// returned so the caller can record them rather than masking with the resolver.
func (c *chainCtx) reanchor(ctx context.Context, name string) (string, []string, error) {
zone, _, err := findApex(ctx, name, c.recFallback)
if err != nil {
return "", nil, err
}
ns, err := resolveZoneNSAddrs(ctx, zone)
if err != nil {
return zone, nil, err
}
return zone, ns, nil
}
// isReferral detects "NOERROR + no Answer for owner + NS in Authority": the
// shape of a delegation response from a parent auth.
func isReferral(r *dns.Msg, owner string) bool {
if r == nil || r.Rcode != dns.RcodeSuccess || len(r.Answer) > 0 {
return false
}
target := lowerFQDN(owner)
for _, rr := range r.Ns {
if ns, ok := rr.(*dns.NS); ok {
zone := lowerFQDN(ns.Hdr.Name)
if target == zone || strings.HasSuffix(target, "."+zone) {
return true
}
}
}
return false
}
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, false)
}
// extractCNAME also reports DNAME synthesis so the walker can tag the hop:
// a synthesized CNAME is not itself a zone-published CNAME.
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
}
func (c *chainCtx) resolveFinal(ctx context.Context, name string, servers []string) {
type result struct {
addrs []string
rcode string
}
query := func(qtype uint16) result {
q := dns.Question{Name: dns.Fqdn(name), Qtype: qtype, Qclass: dns.ClassINET}
var (
r *dns.Msg
err error
)
if len(servers) > 0 {
r, _, err = queryAtAuth(ctx, "", servers, q, false)
} else {
r, err = recursiveExchange(ctx, c.recFallback, q)
}
if err != nil || r == nil {
return result{}
}
var res result
if r.Rcode != dns.RcodeSuccess {
res.rcode = rcodeText(r.Rcode)
}
for _, rr := range r.Answer {
switch v := rr.(type) {
case *dns.A:
if qtype == dns.TypeA {
res.addrs = append(res.addrs, v.A.String())
}
case *dns.AAAA:
if qtype == dns.TypeAAAA {
res.addrs = append(res.addrs, v.AAAA.String())
}
}
}
return res
}
var wg sync.WaitGroup
var aRes, aaaaRes result
wg.Add(2)
go func() { defer wg.Done(); aRes = query(dns.TypeA) }()
go func() { defer wg.Done(); aaaaRes = query(dns.TypeAAAA) }()
wg.Wait()
c.data.FinalA = append(c.data.FinalA, aRes.addrs...)
c.data.FinalAAAA = append(c.data.FinalAAAA, aaaaRes.addrs...)
// Surface either rcode; A wins when both fail because A is the more common
// resolver-driven lookup and operators usually act on it first.
switch {
case aRes.rcode != "":
c.data.FinalRcode = aRes.rcode
case aaaaRes.rcode != "":
c.data.FinalRcode = aaaaRes.rcode
}
}
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, false)
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
}
func observeApex(ctx context.Context, data *AliasData, servers []string, apex string) {
hasRR := func(qtype uint16) bool {
q := dns.Question{Name: apex, Qtype: qtype, Qclass: dns.ClassINET}
r, _, err := queryAtAuth(ctx, "", servers, q, false)
if err != nil || r == nil {
return false
}
for _, rr := range r.Answer {
if rr.Header().Rrtype == qtype {
return true
}
}
return false
}
var hasA, hasAAAA bool
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); hasA = hasRR(dns.TypeA) }()
go func() { defer wg.Done(); hasAAAA = hasRR(dns.TypeAAAA) }()
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
// Synthesize a pseudo-hop so the report's chain view shows the ALIAS
// indirection that would otherwise be invisible from the wire.
data.Chain = append(data.Chain, ChainHop{
Owner: lowerFQDN(apex),
Kind: KindALIAS,
})
}
}
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, false)
if err != nil || r == nil {
return
}
// Filter on owner+type because a DNAME-synthesized CNAME would
// otherwise count as a sibling of every queried type.
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})
}
}
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, "", servers, qk, true)
// DNSKEY responses can exceed the UDP buffer; retry over TCP on truncation.
if err == nil && r != nil && r.Truncated {
r, _, err = queryAtAuth(ctx, "tcp", servers, qk, true)
}
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
}
q := dns.Question{Name: owner, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET}
r, _, err = queryAtAuth(ctx, "", servers, q, true)
if err == nil && r != nil && r.Truncated {
r, _, err = queryAtAuth(ctx, "tcp", servers, q, true)
}
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
}
}

117
checker/definition.go Normal file
View file

@ -0,0 +1,117 @@
package checker
import (
"fmt"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is overridden at build time via -ldflags by main.go and plugin.go.
var Version = "built-in"
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 runs on the host before Collect so bad options are rejected
// up-front rather than producing an unhelpful runtime error mid-walk.
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
}

155
checker/dns.go Normal file
View file

@ -0,0 +1,155 @@
package checker
import (
"context"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/miekg/dns"
)
const dnsTimeout = 5 * time.Second
// dnsExchange sends a single query. dnssec=true requests DNSSEC RRs (DO bit);
// pass false for plain chain walks to keep responses small.
func dnsExchange(ctx context.Context, proto, server string, q dns.Question, rd, dnssec 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
m.SetEdns0(4096, dnssec)
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
}
func recursiveExchange(ctx context.Context, server string, q dns.Question) (*dns.Msg, error) {
return dnsExchange(ctx, "", server, q, true, false)
}
// systemResolver reads /etc/resolv.conf, falling back to 1.1.1.1 in scratch
// containers where the file is absent. The fallback leaks queries to
// Cloudflare; operators that care should mount a resolv.conf.
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)
}
func hostPort(host, port string) string {
return net.JoinHostPort(strings.TrimSuffix(host, "."), port)
}
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)
}
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 tries each server in order and returns the first usable answer.
// dnssec=true sets the DO bit; only the DNSSEC probes need it.
func queryAtAuth(ctx context.Context, proto string, servers []string, q dns.Question, dnssec bool) (*dns.Msg, string, error) {
var lastErr error
for _, s := range servers {
r, err := dnsExchange(ctx, proto, s, q, false, dnssec)
if err != nil {
lastErr = err
continue
}
return r, s, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no servers provided")
}
return nil, "", lastErr
}
func rcodeText(r int) string {
if s, ok := dns.RcodeToString[r]; ok {
return s
}
return fmt.Sprintf("RCODE(%d)", r)
}
func lowerFQDN(name string) string {
return strings.ToLower(dns.Fqdn(name))
}

49
checker/interactive.go Normal file
View file

@ -0,0 +1,49 @@
//go:build standalone
package checker
import (
"errors"
"net/http"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
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.",
},
}
}
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 + domain_name so rules autoscoping on domain_name
// still work; the collector accepts either representation.
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
}

21
checker/provider.go Normal file
View file

@ -0,0 +1,21 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
func Provider() sdk.ObservationProvider {
return &aliasProvider{}
}
type aliasProvider struct{}
func (p *aliasProvider) Key() sdk.ObservationKey {
return ObservationKeyAlias
}
// Definition satisfies sdk.CheckerDefinitionProvider so the SDK server can
// expose /definition without the caller wiring it manually.
func (p *aliasProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

458
checker/report.go Normal file
View file

@ -0,0 +1,458 @@
package checker
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// GetHTMLReport degrades gracefully when ctx.States() is empty (older hosts,
// ad-hoc reporters): the data sections still render, the verdict ones do not.
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 drives the visual order of the "fix these first" cards.
var topFailureRules = []string{
"cname_at_apex",
"cname_coexistence",
"chain_loop",
"chain_length",
"target_resolvable",
"chain_rcode",
"cname_dnssec",
"multiple_records",
}
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
}
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 {
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"
}
return v
}
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))
// Inlined styles so the report embeds 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}}
</body>
</html>`

93
checker/rules_apex.go Normal file
View file

@ -0,0 +1,93 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
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.")}
}
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.")}
}
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.")}
}

273
checker/rules_chain.go Normal file
View file

@ -0,0 +1,273 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
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.")}
}
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.")}
}
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.")}
}
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
}
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
}
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.")}
}
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,58 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
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 {
// Provider-side flattening serves apex A/AAAA on a synthetic owner,
// so the wire-level coexistence is intentional, not a zone bug.
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
}

75
checker/rules_common.go Normal file
View file

@ -0,0 +1,75 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Defaults are centralised so Definition's docs and runtime reads cannot drift.
const (
defaultMaxChainLength = 8
defaultMinTargetTTL = 60
defaultRequireResolvableTarget = true
defaultAllowApexCNAME = false
defaultRecognizeApexFlattening = true
// hintKey is the CheckState.Meta key the HTML report reads to render the
// "How to fix" block: keep them in sync.
hintKey = "hint"
)
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 is the "nothing to judge right now" return: rules must produce at
// least one state, otherwise the SDK substitutes StatusUnknown.
func skipped(reason string) []sdk.CheckState {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Message: "skipped: " + reason,
}}
}
func okState(subject, message string) []sdk.CheckState {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: subject,
Message: message,
}}
}
// withHint is a no-op when hint is empty so callers can pass through unchecked.
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 gates chain-oriented rules with a uniform "apex lookup failed"
// skip line so the UI stays consistent across rules.
func apexKnown(data *AliasData) bool {
return data.ApexLookupError == "" && data.Apex != ""
}
func allowApexCNAME(opts sdk.CheckerOptions) bool {
return sdk.GetBoolOption(opts, "allowApexCNAME", defaultAllowApexCNAME)
}
func recognizeApexFlattening(opts sdk.CheckerOptions) bool {
return sdk.GetBoolOption(opts, "recognizeApexFlattening", defaultRecognizeApexFlattening)
}

42
checker/rules_dnssec.go Normal file
View file

@ -0,0 +1,42 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
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.")}
}

350
checker/rules_test.go Normal file
View file

@ -0,0 +1,350 @@
package checker
import (
"context"
"encoding/json"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// JSON-round-tripping mirrors the production read path so tests catch tag
// drift between AliasData fields and rule expectations.
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.StatusUnknown {
t.Fatalf("want StatusUnknown (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())
}
}
}

102
checker/types.go Normal file
View file

@ -0,0 +1,102 @@
package checker
import (
"encoding/json"
"github.com/miekg/dns"
)
const ObservationKeyAlias = "alias"
type AliasKind string
const (
KindCNAME AliasKind = "CNAME"
KindDNAME AliasKind = "DNAME"
KindALIAS AliasKind = "ALIAS" // provider-flattened apex alias, no real RR on the wire
KindTarget AliasKind = "TARGET"
)
type ChainHop struct {
Owner string `json:"owner"`
Kind AliasKind `json:"kind"`
Target string `json:"target,omitempty"`
TTL uint32 `json:"ttl,omitempty"`
Server string `json:"server,omitempty"`
Synthesized bool `json:"synthesized,omitempty"` // CNAME synthesized from DNAME
}
type CoexistingRRset struct {
Type string `json:"type"`
TTL uint32 `json:"ttl,omitempty"`
}
type TerminationReason string
const (
TermOK TerminationReason = "ok"
TermLoop TerminationReason = "loop"
TermTooLong TerminationReason = "too_long"
TermQueryErr TerminationReason = "query_error"
TermRcode TerminationReason = "rcode"
)
// ChainTermination is always populated after a walk; rules key off Reason.
type ChainTermination struct {
Reason TerminationReason `json:"reason"`
Subject string `json:"subject,omitempty"`
Detail string `json:"detail,omitempty"`
Rcode string `json:"rcode,omitempty"` // only with TermRcode
}
// AliasData carries raw facts only; judgement is delegated to the rules.
type AliasData struct {
Owner string `json:"owner"`
// Apex is empty iff the apex lookup failed; ApexLookupError explains why.
Apex string `json:"apex,omitempty"`
ApexLookupError string `json:"apex_lookup_error,omitempty"`
AuthServers []string `json:"auth_servers,omitempty"`
Chain []ChainHop `json:"chain,omitempty"`
ChainTerminated ChainTermination `json:"chain_terminated"`
// FinalTarget is the last name in the chain, equal to Owner when there is
// no indirection.
FinalTarget string `json:"final_target,omitempty"`
FinalA []string `json:"final_a,omitempty"`
FinalAAAA []string `json:"final_aaaa,omitempty"`
FinalRcode string `json:"final_rcode,omitempty"`
// Coexisting is populated only when Owner has a CNAME.
Coexisting []CoexistingRRset `json:"coexisting,omitempty"`
OwnerIsApex bool `json:"owner_is_apex,omitempty"`
OwnerHasCNAME bool `json:"owner_has_cname,omitempty"`
// Apex* fields are populated only when OwnerIsApex.
ApexHasA bool `json:"apex_has_a,omitempty"`
ApexHasAAAA bool `json:"apex_has_aaaa,omitempty"`
ApexHasCNAME bool `json:"apex_has_cname,omitempty"`
ApexFlattening bool `json:"apex_flattening,omitempty"`
ZoneSigned bool `json:"zone_signed,omitempty"`
// CNAMESigCheckDone gates CNAMESigned: a false here means we never probed
// (zone unsigned or no CNAME), so CNAMESigned must not be interpreted.
CNAMESigCheckDone bool `json:"cname_sig_check_done,omitempty"`
CNAMESigned bool `json:"cname_signed,omitempty"`
DNAMESubstitutions []ChainHop `json:"dname_substitutions,omitempty"`
}
// cnameService mirrors happyDomain's svcs.CNAME / svcs.SpecialCNAME wire shape.
type cnameService struct {
Record *dns.CNAME `json:"cname"`
}
// serviceMessage mirrors 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.4.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.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs=
git.happydns.org/checker-sdk-go v1.4.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
}