Initial commit

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

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

25
Makefile Normal file
View file

@ -0,0 +1,25 @@
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 clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -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) .
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

79
README.md Normal file
View file

@ -0,0 +1,79 @@
# 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. |
Finding codes emitted by the checker include: `alias_no_apex`,
`alias_loop`, `alias_chain_too_long`, `alias_query_failed`,
`alias_rcode`, `alias_low_ttl`, `alias_cname_at_apex`,
`alias_apex_flattening`, `alias_coexisting_rrset`,
`alias_cname_not_signed`, `alias_target_unresolvable`,
`alias_multiple_records`.
## License
Licensed under the **MIT License** (see `LICENSE`).

595
checker/collect.go Normal file
View file

@ -0,0 +1,595 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Collect runs the alias testsuite and returns an *AliasData populated with
// findings, a resolution chain, and optional coexistence / DNSSEC observations.
func (p *aliasProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
owner, err := resolveOwner(opts)
if err != nil {
return nil, err
}
maxChain := sdk.GetIntOption(opts, "maxChainLength", 8)
minTTL := uint32(sdk.GetIntOption(opts, "minTargetTTL", 60))
requireTarget := sdk.GetBoolOption(opts, "requireResolvableTarget", true)
allowApexCNAME := sdk.GetBoolOption(opts, "allowApexCNAME", false)
recognizeApex := sdk.GetBoolOption(opts, "recognizeApexFlattening", true)
data := &AliasData{Owner: owner}
resolver := systemResolver()
// 1. Find apex and authoritative servers.
apex, servers, err := findApex(ctx, owner, resolver)
if err != nil {
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_no_apex",
Severity: SeverityCrit,
Message: fmt.Sprintf("could not locate zone apex of %s: %v", owner, err),
Subject: owner,
Hint: "Check that the parent delegation exists and that the zone is published.",
})
return data, nil
}
data.Apex = apex
data.AuthServers = servers
data.OwnerIsApex = lowerFQDN(owner) == lowerFQDN(apex)
// 2. Detect DNAME substitutions from owner up to apex (exclusive of apex).
data.DNAMESubstitutions = collectDNAMEs(ctx, servers, owner, apex)
// 3. Walk the CNAME/DNAME chain.
chainCtx := &chainCtx{
data: data,
maxLen: maxChain,
minTTL: minTTL,
servers: servers,
apex: apex,
seenOwners: map[string]bool{},
recFallback: resolver,
followTarget: requireTarget,
}
chainCtx.walk(ctx, owner)
// 4. Apex checks (flattening, CNAME-at-apex coexistence).
if data.OwnerIsApex {
checkApex(ctx, data, servers, apex, allowApexCNAME, recognizeApex)
}
// 5. Coexistence at owner (applies at any level, not just apex).
checkCoexistence(ctx, data, servers, owner, allowApexCNAME, recognizeApex)
// 6. DNSSEC checks.
checkDNSSEC(ctx, data, servers, apex, owner)
// 7. Chain-level validations (loops, length, TTL, target resolvability).
validateChain(data, requireTarget)
return data, nil
}
// resolveOwner derives the FQDN to check from the auto-filled options. The
// "service" option takes precedence (it carries a dns.CNAME whose owner is
// authoritative); otherwise we fall back to subdomain + domain_name.
func resolveOwner(opts sdk.CheckerOptions) (string, error) {
if svcMsg, ok := sdk.GetOption[serviceMessage](opts, "service"); ok && len(svcMsg.Service) > 0 {
var c cnameService
if err := json.Unmarshal(svcMsg.Service, &c); err == nil && c.Record != nil && c.Record.Hdr.Name != "" {
return lowerFQDN(c.Record.Hdr.Name), nil
}
}
parent, _ := sdk.GetOption[string](opts, "domain_name")
sub, _ := sdk.GetOption[string](opts, "subdomain")
if parent == "" {
return "", fmt.Errorf("missing 'domain_name' option")
}
parent = strings.TrimSuffix(parent, ".")
if sub == "" || sub == "@" {
return lowerFQDN(parent), nil
}
sub = strings.TrimSuffix(sub, ".")
return lowerFQDN(sub + "." + parent), nil
}
// chainCtx carries the mutable state of a chain walk.
type chainCtx struct {
data *AliasData
maxLen int
minTTL uint32
servers []string
apex string
seenOwners map[string]bool
recFallback string
followTarget bool
}
// walk follows CNAME/DNAME hops starting from name. It writes hops into
// data.Chain and may add findings.
func (c *chainCtx) walk(ctx context.Context, name string) {
current := lowerFQDN(name)
currentServers := c.servers
for i := 0; i <= c.maxLen+1; i++ {
if c.seenOwners[current] {
c.data.Findings = append(c.data.Findings, AliasFinding{
Code: "alias_loop",
Severity: SeverityCrit,
Message: fmt.Sprintf("chain loops back to %s", current),
Subject: current,
Hint: "Break the loop by pointing the last CNAME at an A/AAAA-bearing name.",
})
c.data.FinalTarget = current
return
}
c.seenOwners[current] = true
if i > c.maxLen {
c.data.Findings = append(c.data.Findings, AliasFinding{
Code: "alias_chain_too_long",
Severity: SeverityCrit,
Message: fmt.Sprintf("chain exceeds %d hops at %s; many resolvers will give up", c.maxLen, current),
Subject: current,
Hint: "Flatten intermediate CNAMEs so that the chain is at most a few hops long.",
})
c.data.FinalTarget = current
return
}
q := dns.Question{Name: current, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET}
r, server, err := c.queryFor(ctx, currentServers, q)
if err != nil {
c.data.Findings = append(c.data.Findings, AliasFinding{
Code: "alias_query_failed",
Severity: SeverityWarn,
Message: fmt.Sprintf("CNAME query for %s failed: %v", current, err),
Subject: current,
})
c.data.FinalTarget = current
return
}
if r.Rcode != dns.RcodeSuccess {
c.data.Rcode = rcodeText(r.Rcode)
c.data.Findings = append(c.data.Findings, AliasFinding{
Code: "alias_rcode",
Severity: SeverityCrit,
Message: fmt.Sprintf("server answered %s for %s", c.data.Rcode, current),
Subject: current,
Hint: "Ensure the zone publishes the expected record; NXDOMAIN/SERVFAIL mid-chain breaks the alias.",
})
c.data.FinalTarget = current
return
}
cname, synthesizedFromDNAME, ttl := extractCNAME(r, current)
if cname == "" {
// No CNAME at this name: terminal hop, resolve A/AAAA.
c.data.Chain = append(c.data.Chain, ChainHop{
Owner: current,
Kind: KindTarget,
Server: server,
})
c.data.FinalTarget = current
c.resolveFinal(ctx, current, currentServers)
return
}
target := lowerFQDN(cname)
kind := KindCNAME
if synthesizedFromDNAME {
kind = KindDNAME
}
c.data.Chain = append(c.data.Chain, ChainHop{
Owner: current,
Kind: kind,
Target: target,
TTL: ttl,
Server: server,
Synthesized: synthesizedFromDNAME,
})
if ttl < c.minTTL {
c.data.Findings = append(c.data.Findings, AliasFinding{
Code: "alias_low_ttl",
Severity: SeverityWarn,
Message: fmt.Sprintf("hop %s → %s has TTL %ds (< %d)", current, target, ttl, c.minTTL),
Subject: current,
Hint: "Raise the CNAME TTL to improve cache efficiency (515 minutes is a common floor).",
})
}
// Re-evaluate servers for the next hop: if target leaves the apex,
// we need its own authoritative servers. Out-of-zone targets are
// resolved via the system resolver (recursive path).
if isSubdomain(target, c.apex) {
currentServers = c.servers
} else {
zone, _, _ := findApex(ctx, target, c.recFallback)
ns, err := resolveZoneNSAddrs(ctx, zone)
if err != nil || len(ns) == 0 {
currentServers = []string{c.recFallback}
} else {
currentServers = ns
}
}
current = target
}
}
// queryFor sends q, retrying via the recursive resolver if the authoritative
// set is empty (useful for foreign targets).
func (c *chainCtx) queryFor(ctx context.Context, servers []string, q dns.Question) (*dns.Msg, string, error) {
if len(servers) == 0 {
r, err := recursiveExchange(ctx, c.recFallback, q)
return r, c.recFallback, err
}
return queryAtAuth(ctx, "", servers, q)
}
// extractCNAME returns the first CNAME target matched for owner, and reports
// whether it was synthesized from a DNAME present in the same response.
func extractCNAME(r *dns.Msg, owner string) (target string, fromDNAME bool, ttl uint32) {
for _, rr := range r.Answer {
if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(dns.Fqdn(c.Hdr.Name), dns.Fqdn(owner)) {
target = c.Target
ttl = c.Hdr.Ttl
break
}
}
if target == "" {
return "", false, 0
}
for _, rr := range r.Answer {
if _, ok := rr.(*dns.DNAME); ok {
fromDNAME = true
break
}
}
return
}
// resolveFinal fetches A/AAAA of the final target and records them.
func (c *chainCtx) resolveFinal(ctx context.Context, name string, servers []string) {
var wg sync.WaitGroup
var finalA, finalAAAA []string
var rcode string
wg.Add(2)
go func() {
defer wg.Done()
q := dns.Question{Name: dns.Fqdn(name), Qtype: dns.TypeA, Qclass: dns.ClassINET}
var r *dns.Msg
var err error
if len(servers) > 0 {
r, _, err = queryAtAuth(ctx, "", servers, q)
} else {
r, err = recursiveExchange(ctx, c.recFallback, q)
}
if err == nil && r != nil {
if r.Rcode != dns.RcodeSuccess {
rcode = rcodeText(r.Rcode)
}
for _, rr := range r.Answer {
if a, ok := rr.(*dns.A); ok {
finalA = append(finalA, a.A.String())
}
}
}
}()
go func() {
defer wg.Done()
q := dns.Question{Name: dns.Fqdn(name), Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}
var r *dns.Msg
var err error
if len(servers) > 0 {
r, _, err = queryAtAuth(ctx, "", servers, q)
} else {
r, err = recursiveExchange(ctx, c.recFallback, q)
}
if err == nil && r != nil {
for _, rr := range r.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
finalAAAA = append(finalAAAA, aaaa.AAAA.String())
}
}
}
}()
wg.Wait()
c.data.FinalA = append(c.data.FinalA, finalA...)
c.data.FinalAAAA = append(c.data.FinalAAAA, finalAAAA...)
if rcode != "" {
c.data.Rcode = rcode
}
}
// collectDNAMEs queries every label from owner up to (but excluding) apex for
// a DNAME record, returning any substitutions found.
func collectDNAMEs(ctx context.Context, servers []string, owner, apex string) []ChainHop {
labels := dns.SplitDomainName(owner)
apexLabels := dns.SplitDomainName(apex)
stop := max(len(labels)-len(apexLabels), 0)
results := make([][]ChainHop, stop)
var wg sync.WaitGroup
wg.Add(stop)
for i := range stop {
go func() {
defer wg.Done()
name := dns.Fqdn(strings.Join(labels[i:], "."))
q := dns.Question{Name: name, Qtype: dns.TypeDNAME, Qclass: dns.ClassINET}
r, server, err := queryAtAuth(ctx, "", servers, q)
if err != nil || r == nil || r.Rcode != dns.RcodeSuccess {
return
}
for _, rr := range r.Answer {
if d, ok := rr.(*dns.DNAME); ok {
results[i] = append(results[i], ChainHop{
Owner: lowerFQDN(d.Hdr.Name),
Kind: KindDNAME,
Target: lowerFQDN(d.Target),
TTL: d.Hdr.Ttl,
Server: server,
})
}
}
}()
}
wg.Wait()
var out []ChainHop
for _, hops := range results {
out = append(out, hops...)
}
return out
}
// checkApex verifies that a CNAME at apex does not break SOA/NS, and
// detects ALIAS/ANAME provider-side flattening.
func checkApex(ctx context.Context, data *AliasData, servers []string, apex string, allowApexCNAME, recognizeApex bool) {
// Collect A/AAAA at apex.
var hasA bool
var hasAMu sync.Mutex
var wg sync.WaitGroup
wg.Add(2)
for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} {
go func() {
defer wg.Done()
q := dns.Question{Name: apex, Qtype: qt, Qclass: dns.ClassINET}
r, _, err := queryAtAuth(ctx, "", servers, q)
if err != nil || r == nil {
return
}
for _, rr := range r.Answer {
switch rr.(type) {
case *dns.A, *dns.AAAA:
hasAMu.Lock()
hasA = true
hasAMu.Unlock()
}
}
}()
}
wg.Wait()
// CNAME at apex?
hasCNAME := false
for _, h := range data.Chain {
if h.Kind == KindCNAME && lowerFQDN(h.Owner) == lowerFQDN(apex) {
hasCNAME = true
break
}
}
if hasCNAME {
sev := SeverityCrit
if allowApexCNAME {
sev = SeverityWarn
}
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_cname_at_apex",
Severity: sev,
Message: fmt.Sprintf("CNAME at apex %s conflicts with the SOA/NS records a zone apex must carry (RFC 1912 §2.4)", apex),
Subject: apex,
Hint: "Use the provider's ALIAS/ANAME flattening, an HTTP redirect, or move content to a sub-label such as www.",
})
}
if hasA && !hasCNAME {
// A present at apex alongside SOA/NS — classic ALIAS/ANAME flattening.
data.ApexFlattening = true
if recognizeApex {
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_apex_flattening",
Severity: SeverityInfo,
Message: fmt.Sprintf("apex %s serves A/AAAA directly (provider-side ALIAS/ANAME flattening)", apex),
Subject: apex,
Hint: "Keep the upstream target's TTL in mind: apex A/AAAA will only update as fast as the provider re-flattens.",
})
}
}
}
// checkCoexistence verifies that a CNAME at owner is the only record type
// present (RFC 1034 §3.6.2, RFC 2181 §10.1).
func checkCoexistence(ctx context.Context, data *AliasData, servers []string, owner string, allowApexCNAME, recognizeApex bool) {
hasCNAME := false
for _, h := range data.Chain {
if h.Kind == KindCNAME && lowerFQDN(h.Owner) == lowerFQDN(owner) {
hasCNAME = true
break
}
}
if !hasCNAME {
return
}
// Query a handful of common sibling types at owner.
siblings := []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeMX, dns.TypeTXT,
dns.TypeNS, dns.TypeSRV, dns.TypeCAA,
}
seen := map[string]uint32{}
var seenMu sync.Mutex
var wg sync.WaitGroup
wg.Add(len(siblings))
for _, qt := range siblings {
go func() {
defer wg.Done()
q := dns.Question{Name: owner, Qtype: qt, Qclass: dns.ClassINET}
r, _, err := queryAtAuth(ctx, "", servers, q)
if err != nil || r == nil {
return
}
// A synthesized CNAME from DNAME will be present in Answer for any
// type; only count answers whose owner matches and whose type is qt.
for _, rr := range r.Answer {
if rr.Header().Rrtype != qt {
continue
}
if !strings.EqualFold(dns.Fqdn(rr.Header().Name), dns.Fqdn(owner)) {
continue
}
seenMu.Lock()
seen[dns.TypeToString[qt]] = rr.Header().Ttl
seenMu.Unlock()
break
}
}()
}
wg.Wait()
// Apex with ALIAS/ANAME flattening is a known exception when requested.
isApex := lowerFQDN(owner) == lowerFQDN(data.Apex)
for t, ttl := range seen {
// A/AAAA at apex alongside a CNAME is impossible in a standard zone;
// a provider may still serve it through flattening. Still report it
// as critical — two different owners cannot legally exist.
if isApex && (t == "A" || t == "AAAA") && recognizeApex && data.ApexFlattening {
continue
}
sev := SeverityCrit
if isApex && allowApexCNAME {
sev = SeverityWarn
}
data.Coexisting = append(data.Coexisting, CoexistingRRset{Type: t, TTL: ttl})
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_coexisting_rrset",
Severity: sev,
Message: fmt.Sprintf("%s and CNAME both exist at %s (RFC 1034 §3.6.2 / RFC 2181 §10.1)", t, owner),
Subject: owner,
Hint: "Remove the sibling record or move it under a different label; a name cannot simultaneously carry a CNAME and other data.",
})
}
}
// checkDNSSEC verifies that, if the zone is signed, the CNAME at owner is
// properly signed (RRSIG covers it).
func checkDNSSEC(ctx context.Context, data *AliasData, servers []string, apex, owner string) {
qk := dns.Question{Name: apex, Qtype: dns.TypeDNSKEY, Qclass: dns.ClassINET}
r, _, err := queryAtAuth(ctx, "tcp", servers, qk)
if err != nil || r == nil || r.Rcode != dns.RcodeSuccess {
return
}
signed := false
for _, rr := range r.Answer {
if _, ok := rr.(*dns.DNSKEY); ok {
signed = true
break
}
}
data.ZoneSigned = signed
if !signed {
return
}
// Query CNAME with DO; check for an RRSIG covering it.
q := dns.Question{Name: owner, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET}
r, _, err = queryAtAuth(ctx, "tcp", servers, q)
if err != nil || r == nil {
return
}
sawCNAME := false
sawSig := false
for _, rr := range r.Answer {
switch v := rr.(type) {
case *dns.CNAME:
sawCNAME = true
case *dns.RRSIG:
if v.TypeCovered == dns.TypeCNAME {
sawSig = true
}
}
}
if sawCNAME {
data.CNAMESigned = sawSig
if !sawSig {
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_cname_not_signed",
Severity: SeverityCrit,
Message: fmt.Sprintf("zone %s is DNSSEC-signed but CNAME at %s has no RRSIG", apex, owner),
Subject: owner,
Hint: "Re-sign the zone or verify your signer covers the alias RRset; unsigned answers in a signed zone SERVFAIL at validating resolvers.",
})
}
}
}
// validateChain enforces global chain invariants.
func validateChain(data *AliasData, requireTarget bool) {
if len(data.Chain) == 0 {
return
}
// Target resolvability.
if last := data.Chain[len(data.Chain)-1]; last.Kind == KindTarget {
if len(data.FinalA) == 0 && len(data.FinalAAAA) == 0 {
sev := SeverityWarn
if requireTarget {
sev = SeverityCrit
}
rcode := data.Rcode
if rcode == "" {
rcode = "no A/AAAA"
}
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_target_unresolvable",
Severity: sev,
Message: fmt.Sprintf("final target %s does not resolve to an address (%s)", last.Owner, rcode),
Subject: last.Owner,
Hint: "Point the alias at a name that publishes at least one A or AAAA record, or fix the upstream zone.",
})
}
}
// Multiple CNAME/DNAME kinds with same owner (malformed zone).
seen := map[string]int{}
for _, h := range data.Chain {
if h.Kind == KindCNAME || h.Kind == KindDNAME {
seen[h.Owner]++
}
}
for o, n := range seen {
if n > 1 {
data.Findings = append(data.Findings, AliasFinding{
Code: "alias_multiple_records",
Severity: SeverityCrit,
Message: fmt.Sprintf("%s carries %d CNAME/DNAME records in the chain; only one is legal per owner", o, n),
Subject: o,
Hint: "Keep a single CNAME per name; remove duplicates at the authoritative zone.",
})
}
}
}

103
checker/definition.go Normal file
View file

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

157
checker/dns.go Normal file
View file

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

63
checker/evaluate.go Normal file
View file

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

51
checker/interactive.go Normal file
View file

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

22
checker/provider.go Normal file
View file

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

421
checker/report.go Normal file
View file

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

46
checker/rule.go Normal file
View file

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

127
checker/types.go Normal file
View file

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

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.2.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.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
git.happydns.org/checker-sdk-go v1.2.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"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
alias.Version = Version
server := sdk.NewServer(alias.Provider())
if err := server.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
}