commit 34ce70272af9c75769b7a9752b142a7770169d97 Author: Pierre-Olivier Mercier Date: Thu Apr 23 19:37:14 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f9bf98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-alias +checker-alias.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..df2e5cf --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d7c8804 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..28f8ddf --- /dev/null +++ b/README.md @@ -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`). diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..42f01ea --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,561 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +package checker + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "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} + + // 1. Find apex and authoritative servers. + apex, servers, err := findApex(ctx, owner) + 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: systemResolver(), + 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, maxChain, minTTL, 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, current, 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) + sev := SeverityCrit + c.data.Findings = append(c.data.Findings, AliasFinding{ + Code: "alias_rcode", + Severity: sev, + 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 (5–15 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 { + ns, err := resolveZoneNSAddrs(ctx, findZoneOf(ctx, target)) + 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, name string, 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 r *dns.Msg + var err error + + q := dns.Question{Name: dns.Fqdn(name), Qtype: dns.TypeA, Qclass: dns.ClassINET} + 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 { + c.data.Rcode = rcodeText(r.Rcode) + } + for _, rr := range r.Answer { + if a, ok := rr.(*dns.A); ok { + c.data.FinalA = append(c.data.FinalA, a.A.String()) + } + } + } + + q.Qtype = dns.TypeAAAA + 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 { + c.data.FinalAAAA = append(c.data.FinalAAAA, aaaa.AAAA.String()) + } + } + } +} + +// findZoneOf walks the labels of name until the system resolver gives it an +// SOA; used to locate authoritative servers for foreign chain targets. +func findZoneOf(ctx context.Context, name string) string { + apex, _, err := findApex(ctx, name) + if err != nil { + return "" + } + return apex +} + +// 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 { + var out []ChainHop + labels := dns.SplitDomainName(owner) + apexLabels := dns.SplitDomainName(apex) + stop := len(labels) - len(apexLabels) + if stop < 0 { + stop = 0 + } + for i := 0; i < stop; i++ { + 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 { + continue + } + for _, rr := range r.Answer { + if d, ok := rr.(*dns.DNAME); ok { + out = append(out, ChainHop{ + Owner: lowerFQDN(d.Hdr.Name), + Kind: KindDNAME, + Target: lowerFQDN(d.Target), + TTL: d.Hdr.Ttl, + Server: server, + }) + } + } + } + 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. + hasA := false + for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} { + q := dns.Question{Name: apex, Qtype: qt, Qclass: dns.ClassINET} + r, _, err := queryAtAuth(ctx, servers, q) + if err != nil || r == nil { + continue + } + for _, rr := range r.Answer { + switch rr.(type) { + case *dns.A, *dns.AAAA: + hasA = true + } + } + } + + // 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{} + for _, qt := range siblings { + q := dns.Question{Name: owner, Qtype: qt, Qclass: dns.ClassINET} + r, _, err := queryAtAuth(ctx, servers, q) + if err != nil || r == nil { + continue + } + // 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 + } + seen[dns.TypeToString[qt]] = rr.Header().Ttl + break + } + } + + // 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 == false { + 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 := queryAtAuthTCP(ctx, 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 = queryAtAuthTCP(ctx, 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, maxChain int, minTTL uint32, 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.", + }) + } + } +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..1f65bb4 --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,107 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +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 8–16.", + 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 +} diff --git a/checker/dns.go b/checker/dns.go new file mode 100644 index 0000000..3c33828 --- /dev/null +++ b/checker/dns.go @@ -0,0 +1,221 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +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, 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 = false + 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) { + client := dns.Client{Timeout: dnsTimeout} + + m := new(dns.Msg) + m.Id = dns.Id() + m.Question = []dns.Question{q} + m.RecursionDesired = true + 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 +} + +// 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", correctly bracketing IPv6 literals and +// stripping the trailing dot from FQDNs. +func hostPort(host, port string) string { + if ip := net.ParseIP(host); ip != nil && ip.To4() == nil { + return "[" + host + "]:" + port + } + return 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 string) (apex string, servers []string, err error) { + resolver := systemResolver() + labels := dns.SplitDomainName(fqdn) + for i := 0; i < len(labels); i++ { + 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 +} + +// pickServer returns the first usable server from list (helper for deterministic picking). +func pickServer(list []string) string { + if len(list) == 0 { + return "" + } + return list[0] +} + +// queryAtAuth sends a query to the first reachable server of list. +func queryAtAuth(ctx context.Context, servers []string, q dns.Question) (*dns.Msg, string, error) { + var lastErr error + for _, s := range servers { + r, err := dnsExchange(ctx, "", s, q, true) + if err != nil { + lastErr = err + continue + } + return r, s, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("no servers provided") + } + return nil, "", lastErr +} + +// queryAtAuthTCP sends a query over TCP to the first reachable server. +func queryAtAuthTCP(ctx context.Context, servers []string, q dns.Question) (*dns.Msg, string, error) { + var lastErr error + for _, s := range servers { + r, err := dnsExchange(ctx, "tcp", s, q, 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) +} + +// parentOf returns the parent zone of name (one label up), or "." for TLDs. +func parentOf(name string) string { + labels := dns.SplitDomainName(name) + if len(labels) <= 1 { + return "." + } + return dns.Fqdn(strings.Join(labels[1:], ".")) +} + +// lowerFQDN returns the canonical lowercase FQDN form of name. +func lowerFQDN(name string) string { + return strings.ToLower(dns.Fqdn(name)) +} diff --git a/checker/evaluate.go b/checker/evaluate.go new file mode 100644 index 0000000..aa8138b --- /dev/null +++ b/checker/evaluate.go @@ -0,0 +1,67 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +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 +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..db953e8 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,55 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +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 +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..7e64c11 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,26 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +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() +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..9bb8dd6 --- /dev/null +++ b/checker/report.go @@ -0,0 +1,425 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +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 = ` + + + +Alias chain report — {{.Owner}} + + + +
+
+
{{.OverallStatusText}}
+
for {{.Owner}}
+
+
+ {{if .FinalTarget}}final: {{.FinalTarget}}{{end}} +
+
+ +
+
Owner
{{.Owner}}
+
Apex
{{if .Apex}}{{.Apex}}{{else}}—{{end}}
+
Final target
{{if .FinalTarget}}{{.FinalTarget}}{{else}}—{{end}}
+
Final addresses
+
{{if .FinalAddresses}}{{range .FinalAddresses}}{{.}}
{{end}}{{else}}none{{end}}
+
+
DNSSEC
+
+ {{if .ZoneSigned}}signed zone{{else}}unsigned{{end}} + {{if .ZoneSigned}}{{if .CNAMESigned}}CNAME signed{{else}}CNAME unsigned{{end}}{{end}} +
+
+
Apex flattening (ALIAS/ANAME)
+
{{if .ApexFlattening}}detected{{else}}not detected{{end}}
+
+
+ + {{if .TopFailures}} +

Fix these first

+ {{range .TopFailures}} +
+

{{.Title}} {{.Severity}}

+ + {{if .Hint}}
How to fix{{.Hint}}
{{end}} +
+ {{end}} + {{end}} + + {{if .ChainSteps}} +

Resolution chain

+
+ {{range .ChainSteps}} +
+ #{{.Index}} + {{.Kind}} + {{.Owner}} + {{if .Target}}{{.Target}}{{end}} + + {{if .TTL}}TTL {{.TTL}}s{{end}} + {{if .Server}} · {{.Server}}{{end}} + +
+ {{end}} +
+ {{end}} + + {{if .DNAMEs}} +

DNAME substitutions

+ + + + {{range .DNAMEs}} + + + + + + + {{end}} + +
OwnerTargetTTLServer
{{.Owner}}{{.Target}}{{.TTL}}{{.Server}}
+ {{end}} + + {{if .Coexisting}} +

Records coexisting with CNAME

+ + + + {{range .Coexisting}} + + {{end}} + +
TypeTTL
{{.Type}}{{.TTL}}
+ {{end}} + + {{if .OtherFindings}} +

Additional findings

+ + + + {{range .OtherFindings}} + + + + + + + {{end}} + +
SeverityCodeSubjectMessage
{{.Severity}}{{.Code}}{{.Subject}}{{.Message}}{{if .Hint}}
{{.Hint}}{{end}}
+ {{end}} + + {{if .RawJSON}} +

Raw observation

+
Show raw JSON
{{.RawJSON}}
+ {{end}} + +` diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..3d47371 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,50 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +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) +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..584a818 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,140 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +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"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bd97c18 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d64ca7c --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d8afbc0 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..3586ddf --- /dev/null +++ b/plugin/plugin.go @@ -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 +}