commit eea7e4e459a5d67a6ee0775937cd1d73b8abb19f 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..2dea10c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-alias . + +FROM scratch +COPY --from=builder /checker-alias /checker-alias +USER 65534:65534 +EXPOSE 8080 +ENTRYPOINT ["/checker-alias"] 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..b9084d3 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +CHECKER_NAME := checker-alias +CHECKER_IMAGE := happydomain/$(CHECKER_NAME) +CHECKER_VERSION ?= custom-build + +CHECKER_SOURCES := main.go $(wildcard checker/*.go) + +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +.PHONY: all plugin docker test clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +test: + go test -tags standalone ./... + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/README.md b/README.md new file mode 100644 index 0000000..80d5b0a --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# checker-alias + +CNAME / DNAME / ALIAS chain checker for [happyDomain](https://www.happydomain.org/). + +Walks the alias chain of a name, validates hop count, TTLs, target +resolvability, apex coexistence (RFC 1912 §2.4, RFC 1034 §3.6.2, +RFC 2181 §10.1), DNAME substitutions, and DNSSEC signing of the CNAME +RRset. + +## Usage + +### Standalone HTTP server + +```bash +# Build and run +make +./checker-alias -listen :8080 +``` + +The server exposes: + +- `GET /health`: health check +- `POST /collect`: collect alias observations (happyDomain external checker protocol) + +### Docker + +```bash +make docker +docker run -p 8080:8080 happydomain/checker-alias +``` + +### happyDomain plugin + +```bash +make plugin +# produces checker-alias.so, loadable by happyDomain as a Go plugin +``` + +The plugin exposes a `NewCheckerPlugin` symbol returning the checker +definition and observation provider, which happyDomain registers in its +global registries at load time. + +### Versioning + +The binary, plugin, and Docker image embed a version string overridable +at build time: + +```bash +make CHECKER_VERSION=1.2.3 +make plugin CHECKER_VERSION=1.2.3 +make docker CHECKER_VERSION=1.2.3 +``` + +### happyDomain remote endpoint + +Set the `endpoint` admin option for the alias checker to the URL of the +running checker-alias server (e.g., `http://checker-alias:8080`). +happyDomain will delegate observation collection to this endpoint. + +## Options + +| Id | Type | Default | Description | +|---------------------------|------|---------|-----------------------------------------------------------------------------| +| `maxChainLength` | uint | `8` | Above this number of hops the chain is reported as critical. | +| `minTargetTTL` | uint | `60` | Hops with a TTL below this threshold are flagged as a warning. | +| `requireResolvableTarget` | bool | `true` | When enabled, a final target with no A/AAAA is critical (otherwise warning).| +| `allowApexCNAME` | bool | `false` | When enabled, a CNAME at apex is only a warning (RFC 1912 forbids it). | +| `recognizeApexFlattening` | bool | `true` | Recognize provider-side ALIAS/ANAME flattening as informational. | + +## Rules + +Each rule emits a finding code. Severity can be affected by the options above. + +| Code | Default severity | Condition | +|------|-----------------|-----------| +| `apex_lookup` | critical | Zone apex (SOA) cannot be located for the queried name. | +| `chain_loop` | critical | A CNAME/DNAME cycle is detected in the resolution chain. | +| `chain_length` | critical | The chain exceeds `maxChainLength` hops. | +| `chain_query_error` | warning | A DNS query fails while walking the chain (network error, timeout). | +| `chain_rcode` | critical (mid-chain) / warning (final) | A non-NOERROR response code is encountered during chain resolution or the final A/AAAA lookup. | +| `hop_ttl` | warning | A CNAME/DNAME hop has a TTL below `minTargetTTL`. | +| `cname_at_apex` | critical / warning with `allowApexCNAME` | A CNAME exists at the zone apex, conflicting with SOA/NS (RFC 1912 §2.4). | +| `apex_flattening` | info | A/AAAA records coexist with SOA/NS at the apex without a CNAME, provider-side ALIAS/ANAME flattening. Only reported when `recognizeApexFlattening` is enabled. | +| `cname_coexistence` | critical / warning with `allowApexCNAME` at apex | Other RRsets (beyond A/AAAA) coexist at a CNAME owner, violating RFC 1034 §3.6.2 / RFC 2181 §10.1. | +| `cname_dnssec` | critical | The zone is DNSSEC-signed but the CNAME RRset at the queried name lacks an RRSIG. | +| `target_resolvable` | critical / warning with `requireResolvableTarget=false` | The final target of the chain has no A or AAAA record. | +| `multiple_records` | critical | An owner in the chain carries more than one CNAME/DNAME record (malformed). | + +## License + +Licensed under the **MIT License** (see `LICENSE`). diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..426dbc4 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,497 @@ +package checker + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func (p *aliasProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + owner, err := resolveOwner(opts) + if err != nil { + return nil, err + } + + maxChain := sdk.GetIntOption(opts, "maxChainLength", defaultMaxChainLength) + + data := &AliasData{Owner: owner} + resolver := systemResolver() + + apex, servers, err := findApex(ctx, owner, resolver) + if err != nil { + data.ApexLookupError = err.Error() + return data, nil + } + data.Apex = apex + data.AuthServers = servers + data.OwnerIsApex = lowerFQDN(owner) == lowerFQDN(apex) + + data.DNAMESubstitutions = collectDNAMEs(ctx, servers, owner, apex) + + chainCtx := &chainCtx{ + data: data, + maxLen: maxChain, + servers: servers, + apex: apex, + seenOwners: map[string]bool{}, + recFallback: resolver, + } + chainCtx.walk(ctx, owner) + + if data.OwnerIsApex { + observeApex(ctx, data, servers, apex) + } + + observeCoexistence(ctx, data, servers, owner) + observeDNSSEC(ctx, data, servers, apex, owner) + + return data, nil +} + +// resolveOwner prefers the "service" option because its dns.CNAME owner is +// authoritative; subdomain + domain_name is the fallback for ad-hoc forms. +func resolveOwner(opts sdk.CheckerOptions) (string, error) { + if svcMsg, ok := sdk.GetOption[serviceMessage](opts, "service"); ok && len(svcMsg.Service) > 0 { + var c cnameService + if err := json.Unmarshal(svcMsg.Service, &c); err == nil && c.Record != nil && c.Record.Hdr.Name != "" { + return lowerFQDN(c.Record.Hdr.Name), nil + } + } + + parent, _ := sdk.GetOption[string](opts, "domain_name") + sub, _ := sdk.GetOption[string](opts, "subdomain") + if parent == "" { + return "", fmt.Errorf("missing 'domain_name' option") + } + parent = strings.TrimSuffix(parent, ".") + if sub == "" || sub == "@" { + return lowerFQDN(parent), nil + } + sub = strings.TrimSuffix(sub, ".") + return lowerFQDN(sub + "." + parent), nil +} + +type chainCtx struct { + data *AliasData + maxLen int + servers []string + apex string + seenOwners map[string]bool + recFallback string +} + +func (c *chainCtx) walk(ctx context.Context, name string) { + current := lowerFQDN(name) + currentServers := c.servers + currentZone := c.apex + + for i := 0; i <= c.maxLen+1; i++ { + if c.seenOwners[current] { + c.data.ChainTerminated = ChainTermination{ + Reason: TermLoop, + Subject: current, + Detail: fmt.Sprintf("chain loops back to %s", current), + } + c.data.FinalTarget = current + return + } + c.seenOwners[current] = true + + if i > c.maxLen { + c.data.ChainTerminated = ChainTermination{ + Reason: TermTooLong, + Subject: current, + Detail: fmt.Sprintf("chain exceeds %d hops at %s", c.maxLen, current), + } + c.data.FinalTarget = current + return + } + + q := dns.Question{Name: current, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET} + r, server, err := c.queryFor(ctx, currentServers, q) + if err != nil { + c.data.ChainTerminated = ChainTermination{ + Reason: TermQueryErr, + Subject: current, + Detail: err.Error(), + } + c.data.FinalTarget = current + return + } + + if r.Rcode != dns.RcodeSuccess { + rcode := rcodeText(r.Rcode) + c.data.ChainTerminated = ChainTermination{ + Reason: TermRcode, + Subject: current, + Rcode: rcode, + Detail: fmt.Sprintf("server answered %s for %s", rcode, current), + } + c.data.FinalTarget = current + return + } + + cname, synthesizedFromDNAME, ttl := extractCNAME(r, current) + if cname == "" { + // A NOERROR with NS in Authority is a referral to a child zone: + // re-anchor on that zone and re-query before declaring a target. + if isReferral(r, current) { + zone, ns, zerr := c.reanchor(ctx, current) + if zerr == nil && len(ns) > 0 && zone != currentZone { + currentZone = zone + currentServers = ns + continue + } + } + c.data.Chain = append(c.data.Chain, ChainHop{ + Owner: current, + Kind: KindTarget, + Server: server, + }) + c.data.FinalTarget = current + c.data.ChainTerminated = ChainTermination{Reason: TermOK} + c.resolveFinal(ctx, current, currentServers) + return + } + + if current == c.data.Owner && !synthesizedFromDNAME { + c.data.OwnerHasCNAME = true + } + + target := lowerFQDN(cname) + kind := KindCNAME + if synthesizedFromDNAME { + kind = KindDNAME + } + c.data.Chain = append(c.data.Chain, ChainHop{ + Owner: current, + Kind: kind, + Target: target, + TTL: ttl, + Server: server, + Synthesized: synthesizedFromDNAME, + }) + + // Re-anchor for the next hop. Even within the original apex, the + // target may live in a delegated child zone whose CNAMEs are not + // answered by the parent's auth set. + zone, ns, zerr := c.reanchor(ctx, target) + if zerr != nil { + c.data.ChainTerminated = ChainTermination{ + Reason: TermQueryErr, + Subject: target, + Detail: fmt.Sprintf("re-anchor for %s failed: %v", target, zerr), + } + c.data.FinalTarget = target + return + } + if len(ns) == 0 { + currentServers = []string{c.recFallback} + } else { + currentServers = ns + } + currentZone = zone + current = target + } +} + +// reanchor finds the apex of name and resolves its NS addresses. Errors are +// returned so the caller can record them rather than masking with the resolver. +func (c *chainCtx) reanchor(ctx context.Context, name string) (string, []string, error) { + zone, _, err := findApex(ctx, name, c.recFallback) + if err != nil { + return "", nil, err + } + ns, err := resolveZoneNSAddrs(ctx, zone) + if err != nil { + return zone, nil, err + } + return zone, ns, nil +} + +// isReferral detects "NOERROR + no Answer for owner + NS in Authority": the +// shape of a delegation response from a parent auth. +func isReferral(r *dns.Msg, owner string) bool { + if r == nil || r.Rcode != dns.RcodeSuccess || len(r.Answer) > 0 { + return false + } + target := lowerFQDN(owner) + for _, rr := range r.Ns { + if ns, ok := rr.(*dns.NS); ok { + zone := lowerFQDN(ns.Hdr.Name) + if target == zone || strings.HasSuffix(target, "."+zone) { + return true + } + } + } + return false +} + +func (c *chainCtx) queryFor(ctx context.Context, servers []string, q dns.Question) (*dns.Msg, string, error) { + if len(servers) == 0 { + r, err := recursiveExchange(ctx, c.recFallback, q) + return r, c.recFallback, err + } + return queryAtAuth(ctx, "", servers, q, false) +} + +// extractCNAME also reports DNAME synthesis so the walker can tag the hop: +// a synthesized CNAME is not itself a zone-published CNAME. +func extractCNAME(r *dns.Msg, owner string) (target string, fromDNAME bool, ttl uint32) { + for _, rr := range r.Answer { + if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(dns.Fqdn(c.Hdr.Name), dns.Fqdn(owner)) { + target = c.Target + ttl = c.Hdr.Ttl + break + } + } + if target == "" { + return "", false, 0 + } + for _, rr := range r.Answer { + if _, ok := rr.(*dns.DNAME); ok { + fromDNAME = true + break + } + } + return +} + +func (c *chainCtx) resolveFinal(ctx context.Context, name string, servers []string) { + type result struct { + addrs []string + rcode string + } + + query := func(qtype uint16) result { + q := dns.Question{Name: dns.Fqdn(name), Qtype: qtype, Qclass: dns.ClassINET} + var ( + r *dns.Msg + err error + ) + if len(servers) > 0 { + r, _, err = queryAtAuth(ctx, "", servers, q, false) + } else { + r, err = recursiveExchange(ctx, c.recFallback, q) + } + if err != nil || r == nil { + return result{} + } + var res result + if r.Rcode != dns.RcodeSuccess { + res.rcode = rcodeText(r.Rcode) + } + for _, rr := range r.Answer { + switch v := rr.(type) { + case *dns.A: + if qtype == dns.TypeA { + res.addrs = append(res.addrs, v.A.String()) + } + case *dns.AAAA: + if qtype == dns.TypeAAAA { + res.addrs = append(res.addrs, v.AAAA.String()) + } + } + } + return res + } + + var wg sync.WaitGroup + var aRes, aaaaRes result + wg.Add(2) + go func() { defer wg.Done(); aRes = query(dns.TypeA) }() + go func() { defer wg.Done(); aaaaRes = query(dns.TypeAAAA) }() + wg.Wait() + + c.data.FinalA = append(c.data.FinalA, aRes.addrs...) + c.data.FinalAAAA = append(c.data.FinalAAAA, aaaaRes.addrs...) + + // Surface either rcode; A wins when both fail because A is the more common + // resolver-driven lookup and operators usually act on it first. + switch { + case aRes.rcode != "": + c.data.FinalRcode = aRes.rcode + case aaaaRes.rcode != "": + c.data.FinalRcode = aaaaRes.rcode + } +} + +func collectDNAMEs(ctx context.Context, servers []string, owner, apex string) []ChainHop { + labels := dns.SplitDomainName(owner) + apexLabels := dns.SplitDomainName(apex) + stop := max(len(labels)-len(apexLabels), 0) + + results := make([][]ChainHop, stop) + var wg sync.WaitGroup + wg.Add(stop) + for i := range stop { + go func() { + defer wg.Done() + name := dns.Fqdn(strings.Join(labels[i:], ".")) + q := dns.Question{Name: name, Qtype: dns.TypeDNAME, Qclass: dns.ClassINET} + r, server, err := queryAtAuth(ctx, "", servers, q, false) + if err != nil || r == nil || r.Rcode != dns.RcodeSuccess { + return + } + for _, rr := range r.Answer { + if d, ok := rr.(*dns.DNAME); ok { + results[i] = append(results[i], ChainHop{ + Owner: lowerFQDN(d.Hdr.Name), + Kind: KindDNAME, + Target: lowerFQDN(d.Target), + TTL: d.Hdr.Ttl, + Server: server, + }) + } + } + }() + } + wg.Wait() + + var out []ChainHop + for _, hops := range results { + out = append(out, hops...) + } + return out +} + +func observeApex(ctx context.Context, data *AliasData, servers []string, apex string) { + hasRR := func(qtype uint16) bool { + q := dns.Question{Name: apex, Qtype: qtype, Qclass: dns.ClassINET} + r, _, err := queryAtAuth(ctx, "", servers, q, false) + if err != nil || r == nil { + return false + } + for _, rr := range r.Answer { + if rr.Header().Rrtype == qtype { + return true + } + } + return false + } + + var hasA, hasAAAA bool + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); hasA = hasRR(dns.TypeA) }() + go func() { defer wg.Done(); hasAAAA = hasRR(dns.TypeAAAA) }() + wg.Wait() + + data.ApexHasA = hasA + data.ApexHasAAAA = hasAAAA + + for _, h := range data.Chain { + if h.Kind == KindCNAME && h.Owner == lowerFQDN(apex) { + data.ApexHasCNAME = true + break + } + } + + if (hasA || hasAAAA) && !data.ApexHasCNAME { + data.ApexFlattening = true + // Synthesize a pseudo-hop so the report's chain view shows the ALIAS + // indirection that would otherwise be invisible from the wire. + data.Chain = append(data.Chain, ChainHop{ + Owner: lowerFQDN(apex), + Kind: KindALIAS, + }) + } +} + +func observeCoexistence(ctx context.Context, data *AliasData, servers []string, owner string) { + if !data.OwnerHasCNAME { + return + } + + siblings := []uint16{ + dns.TypeA, dns.TypeAAAA, dns.TypeMX, dns.TypeTXT, + dns.TypeNS, dns.TypeSRV, dns.TypeCAA, + } + seen := map[string]uint32{} + var mu sync.Mutex + var wg sync.WaitGroup + wg.Add(len(siblings)) + for _, qt := range siblings { + go func() { + defer wg.Done() + q := dns.Question{Name: owner, Qtype: qt, Qclass: dns.ClassINET} + r, _, err := queryAtAuth(ctx, "", servers, q, false) + if err != nil || r == nil { + return + } + // Filter on owner+type because a DNAME-synthesized CNAME would + // otherwise count as a sibling of every queried type. + for _, rr := range r.Answer { + if rr.Header().Rrtype != qt { + continue + } + if !strings.EqualFold(dns.Fqdn(rr.Header().Name), dns.Fqdn(owner)) { + continue + } + mu.Lock() + seen[dns.TypeToString[qt]] = rr.Header().Ttl + mu.Unlock() + break + } + }() + } + wg.Wait() + + for t, ttl := range seen { + data.Coexisting = append(data.Coexisting, CoexistingRRset{Type: t, TTL: ttl}) + } +} + +func observeDNSSEC(ctx context.Context, data *AliasData, servers []string, apex, owner string) { + qk := dns.Question{Name: apex, Qtype: dns.TypeDNSKEY, Qclass: dns.ClassINET} + r, _, err := queryAtAuth(ctx, "", servers, qk, true) + // DNSKEY responses can exceed the UDP buffer; retry over TCP on truncation. + if err == nil && r != nil && r.Truncated { + r, _, err = queryAtAuth(ctx, "tcp", servers, qk, true) + } + if err != nil || r == nil || r.Rcode != dns.RcodeSuccess { + return + } + signed := false + for _, rr := range r.Answer { + if _, ok := rr.(*dns.DNSKEY); ok { + signed = true + break + } + } + data.ZoneSigned = signed + if !signed { + return + } + + q := dns.Question{Name: owner, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET} + r, _, err = queryAtAuth(ctx, "", servers, q, true) + if err == nil && r != nil && r.Truncated { + r, _, err = queryAtAuth(ctx, "tcp", servers, q, true) + } + if err != nil || r == nil { + return + } + sawCNAME := false + sawSig := false + for _, rr := range r.Answer { + switch v := rr.(type) { + case *dns.CNAME: + sawCNAME = true + case *dns.RRSIG: + if v.TypeCovered == dns.TypeCNAME { + sawSig = true + } + } + } + if sawCNAME { + data.CNAMESigCheckDone = true + data.CNAMESigned = sawSig + } +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..cd036ef --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,117 @@ +package checker + +import ( + "fmt" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is overridden at build time via -ldflags by main.go and plugin.go. +var Version = "built-in" + +func Definition() *sdk.CheckerDefinition { + def := &sdk.CheckerDefinition{ + ID: "alias", + Name: "CNAME / DNAME / ALIAS chain", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToService: true, + ApplyToDomain: true, + ApplyToZone: true, + LimitToServices: []string{ + "svcs.CNAME", + "svcs.SpecialCNAME", + }, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKeyAlias}, + Options: sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "allowApexCNAME", + Type: "bool", + Label: "Allow CNAME at apex", + Description: "Shared by cname_at_apex and cname_coexistence: when enabled, a CNAME at a zone apex (and its coexistence violations) are downgraded to warnings. RFC 1912 forbids this, so leaving it off is strongly recommended.", + Default: defaultAllowApexCNAME, + }, + { + Id: "recognizeApexFlattening", + Type: "bool", + Label: "Recognize ALIAS/ANAME flattening", + Description: "Shared by apex_flattening and cname_coexistence: when enabled, providers that serve A/AAAA at the apex (ALIAS/ANAME pseudo-records) are recognised as intentional and excused from coexistence violations.", + Default: defaultRecognizeApexFlattening, + }, + }, + DomainOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain_name", + Label: "Parent domain name", + AutoFill: sdk.AutoFillDomainName, + }, + { + Id: "subdomain", + Label: "Subdomain", + AutoFill: sdk.AutoFillSubdomain, + }, + }, + ServiceOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "service_type", + Label: "Service type", + AutoFill: sdk.AutoFillServiceType, + }, + { + Id: "service", + Label: "Service", + AutoFill: sdk.AutoFillService, + }, + }, + }, + Rules: []sdk.CheckRule{ + apexLookupRule{}, + chainLoopRule{}, + chainLengthRule{}, + chainQueryErrorRule{}, + chainRcodeRule{}, + hopTTLRule{}, + cnameAtApexRule{}, + apexFlatteningRule{}, + cnameCoexistenceRule{}, + cnameDnssecRule{}, + targetResolvableRule{}, + multipleRecordsRule{}, + }, + HasHTMLReport: true, + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 24 * time.Hour, + Default: 1 * time.Hour, + }, + } + def.BuildRulesInfo() + return def +} + +// ValidateOptions runs on the host before Collect so bad options are rejected +// up-front rather than producing an unhelpful runtime error mid-walk. +func (p *aliasProvider) ValidateOptions(opts sdk.CheckerOptions) error { + if v, ok := opts["maxChainLength"]; ok { + f, ok := v.(float64) + if !ok { + return fmt.Errorf("maxChainLength must be a number") + } + if f < 1 { + return fmt.Errorf("maxChainLength must be >= 1") + } + } + if v, ok := opts["minTargetTTL"]; ok { + f, ok := v.(float64) + if !ok { + return fmt.Errorf("minTargetTTL must be a number") + } + if f < 0 { + return fmt.Errorf("minTargetTTL must be >= 0") + } + } + return nil +} diff --git a/checker/dns.go b/checker/dns.go new file mode 100644 index 0000000..a31195d --- /dev/null +++ b/checker/dns.go @@ -0,0 +1,155 @@ +package checker + +import ( + "context" + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/miekg/dns" +) + +const dnsTimeout = 5 * time.Second + +// dnsExchange sends a single query. dnssec=true requests DNSSEC RRs (DO bit); +// pass false for plain chain walks to keep responses small. +func dnsExchange(ctx context.Context, proto, server string, q dns.Question, rd, dnssec bool) (*dns.Msg, error) { + client := dns.Client{Net: proto, Timeout: dnsTimeout} + + m := new(dns.Msg) + m.Id = dns.Id() + m.Question = []dns.Question{q} + m.RecursionDesired = rd + m.SetEdns0(4096, dnssec) + + if deadline, ok := ctx.Deadline(); ok { + if d := time.Until(deadline); d > 0 && d < client.Timeout { + client.Timeout = d + } + } + + r, _, err := client.Exchange(m, server) + if err != nil { + return nil, err + } + if r == nil { + return nil, fmt.Errorf("nil response from %s", server) + } + return r, nil +} + +func recursiveExchange(ctx context.Context, server string, q dns.Question) (*dns.Msg, error) { + return dnsExchange(ctx, "", server, q, true, false) +} + +// systemResolver reads /etc/resolv.conf, falling back to 1.1.1.1 in scratch +// containers where the file is absent. The fallback leaks queries to +// Cloudflare; operators that care should mount a resolv.conf. +func systemResolver() string { + cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil || len(cfg.Servers) == 0 { + return net.JoinHostPort("1.1.1.1", "53") + } + return net.JoinHostPort(cfg.Servers[0], cfg.Port) +} + +func hostPort(host, port string) string { + return net.JoinHostPort(strings.TrimSuffix(host, "."), port) +} + +func findApex(ctx context.Context, fqdn, resolver string) (apex string, servers []string, err error) { + labels := dns.SplitDomainName(fqdn) + for i := range labels { + candidate := dns.Fqdn(strings.Join(labels[i:], ".")) + q := dns.Question{Name: candidate, Qtype: dns.TypeSOA, Qclass: dns.ClassINET} + r, rerr := recursiveExchange(ctx, resolver, q) + if rerr != nil { + continue + } + if r.Rcode != dns.RcodeSuccess { + continue + } + hasSOA := false + for _, rr := range r.Answer { + if _, ok := rr.(*dns.SOA); ok { + hasSOA = true + break + } + } + if !hasSOA { + continue + } + apex = candidate + servers, err = resolveZoneNSAddrs(ctx, apex) + if err != nil { + return "", nil, err + } + if len(servers) == 0 { + return "", nil, fmt.Errorf("apex %s has no resolvable NS", apex) + } + return apex, servers, nil + } + return "", nil, fmt.Errorf("could not locate apex of %s", fqdn) +} + +func resolveZoneNSAddrs(ctx context.Context, zone string) ([]string, error) { + var resolver net.Resolver + nss, err := resolver.LookupNS(ctx, strings.TrimSuffix(zone, ".")) + if err != nil { + return nil, err + } + results := make([][]string, len(nss)) + var wg sync.WaitGroup + wg.Add(len(nss)) + for i, ns := range nss { + go func() { + defer wg.Done() + addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(ns.Host, ".")) + if err != nil || len(addrs) == 0 { + return + } + r := make([]string, len(addrs)) + for j, a := range addrs { + r[j] = hostPort(a, "53") + } + results[i] = r + }() + } + wg.Wait() + var out []string + for _, r := range results { + out = append(out, r...) + } + return out, nil +} + +// queryAtAuth tries each server in order and returns the first usable answer. +// dnssec=true sets the DO bit; only the DNSSEC probes need it. +func queryAtAuth(ctx context.Context, proto string, servers []string, q dns.Question, dnssec bool) (*dns.Msg, string, error) { + var lastErr error + for _, s := range servers { + r, err := dnsExchange(ctx, proto, s, q, false, dnssec) + if err != nil { + lastErr = err + continue + } + return r, s, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("no servers provided") + } + return nil, "", lastErr +} + +func rcodeText(r int) string { + if s, ok := dns.RcodeToString[r]; ok { + return s + } + return fmt.Sprintf("RCODE(%d)", r) +} + +func lowerFQDN(name string) string { + return strings.ToLower(dns.Fqdn(name)) +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..f3e3998 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,49 @@ +//go:build standalone + +package checker + +import ( + "errors" + "net/http" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func (p *aliasProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "name", + Type: "string", + Label: "Domain name to check", + Placeholder: "alias.example.com", + Required: true, + Description: "Fully-qualified name carrying (or suspected of carrying) a CNAME / DNAME / ALIAS record.", + }, + } +} + +func (p *aliasProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + return nil, errors.New("name is required") + } + name = strings.TrimSuffix(name, ".") + + // Split into subdomain + domain_name so rules autoscoping on domain_name + // still work; the collector accepts either representation. + parts := strings.SplitN(name, ".", 2) + sub := parts[0] + parent := "" + if len(parts) == 2 { + parent = parts[1] + } else { + parent = name + sub = "" + } + + return sdk.CheckerOptions{ + "domain_name": parent, + "subdomain": sub, + }, nil +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..1660085 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,21 @@ +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func Provider() sdk.ObservationProvider { + return &aliasProvider{} +} + +type aliasProvider struct{} + +func (p *aliasProvider) Key() sdk.ObservationKey { + return ObservationKeyAlias +} + +// Definition satisfies sdk.CheckerDefinitionProvider so the SDK server can +// expose /definition without the caller wiring it manually. +func (p *aliasProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..b8c58e7 --- /dev/null +++ b/checker/report.go @@ -0,0 +1,458 @@ +package checker + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// GetHTMLReport degrades gracefully when ctx.States() is empty (older hosts, +// ad-hoc reporters): the data sections still render, the verdict ones do not. +func (p *aliasProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { + var data AliasData + if raw := ctx.Data(); len(raw) > 0 { + if err := json.Unmarshal(raw, &data); err != nil { + return "", fmt.Errorf("parse alias data: %w", err) + } + } + + view := buildReportView(&data, ctx.States()) + + buf := &bytes.Buffer{} + if err := reportTmpl.Execute(buf, view); err != nil { + return "", err + } + return buf.String(), nil +} + +// topFailureRules drives the visual order of the "fix these first" cards. +var topFailureRules = []string{ + "cname_at_apex", + "cname_coexistence", + "chain_loop", + "chain_length", + "target_resolvable", + "chain_rcode", + "cname_dnssec", + "multiple_records", +} + +type reportView struct { + Owner string + Apex string + FinalTarget string + FinalAddresses []string + OverallStatus string + OverallStatusText string + OverallClass string + ChainSteps []chainStep + DNAMEs []ChainHop + Coexisting []CoexistingRRset + ApexFlattening bool + ZoneSigned bool + CNAMESigned bool + HasStates bool + TopFailures []topFailure + OtherFindings []otherFinding +} + +type chainStep struct { + Index int + Owner string + Kind string + Target string + TTL uint32 + Server string + IsLast bool + CSSKind string +} + +type topFailure struct { + RuleName string + Title string + Severity string + Messages []string + Hint string + Subject string +} + +type otherFinding struct { + Severity string + RuleName string + Subject string + Message string + Hint string +} + +func buildReportView(data *AliasData, states []sdk.CheckState) *reportView { + v := &reportView{ + Owner: data.Owner, + Apex: data.Apex, + FinalTarget: data.FinalTarget, + DNAMEs: data.DNAMESubstitutions, + Coexisting: data.Coexisting, + ApexFlattening: data.ApexFlattening, + ZoneSigned: data.ZoneSigned, + CNAMESigned: data.CNAMESigned, + HasStates: len(states) > 0, + } + + v.FinalAddresses = append(v.FinalAddresses, data.FinalA...) + v.FinalAddresses = append(v.FinalAddresses, data.FinalAAAA...) + + for i, h := range data.Chain { + step := chainStep{ + Index: i + 1, + Owner: h.Owner, + Kind: string(h.Kind), + Target: h.Target, + TTL: h.TTL, + Server: h.Server, + IsLast: i == len(data.Chain)-1, + } + switch h.Kind { + case KindCNAME: + step.CSSKind = "kind-cname" + case KindDNAME: + step.CSSKind = "kind-dname" + case KindALIAS: + step.CSSKind = "kind-alias" + case KindTarget: + step.CSSKind = "kind-target" + } + v.ChainSteps = append(v.ChainSteps, step) + } + + if v.HasStates { + worst := worstStatus(states) + v.OverallStatus, v.OverallStatusText, v.OverallClass = statusLabel(worst) + + topIndex := map[string]int{} + for i, r := range topFailureRules { + topIndex[r] = i + } + topMap := map[string]*topFailure{} + for _, s := range states { + if s.Status == sdk.StatusOK || s.Status == sdk.StatusUnknown { + continue + } + if _, isTop := topIndex[s.RuleName]; isTop && (s.Status == sdk.StatusWarn || s.Status == sdk.StatusCrit) { + tf, ok := topMap[s.RuleName] + if !ok { + tf = &topFailure{ + RuleName: s.RuleName, + Title: titleFor(s.RuleName), + Severity: severityClass(s.Status), + Hint: hintOf(s), + Subject: s.Subject, + } + topMap[s.RuleName] = tf + } + tf.Messages = append(tf.Messages, s.Message) + if tf.Hint == "" { + tf.Hint = hintOf(s) + } + if statusRank(s.Status) > severityRankClass(tf.Severity) { + tf.Severity = severityClass(s.Status) + } + continue + } + v.OtherFindings = append(v.OtherFindings, otherFinding{ + Severity: severityClass(s.Status), + RuleName: s.RuleName, + Subject: s.Subject, + Message: s.Message, + Hint: hintOf(s), + }) + } + for _, ruleName := range topFailureRules { + if tf, ok := topMap[ruleName]; ok { + v.TopFailures = append(v.TopFailures, *tf) + } + } + } else { + v.OverallStatus = "unknown" + v.OverallStatusText = "Rule output not provided" + v.OverallClass = "status-info" + } + + return v +} + +func worstStatus(states []sdk.CheckState) sdk.Status { + worst := sdk.StatusOK + for _, s := range states { + if statusRank(s.Status) > statusRank(worst) { + worst = s.Status + } + } + return worst +} + +func statusLabel(s sdk.Status) (status, text, class string) { + switch s { + case sdk.StatusCrit: + return "crit", "Critical issues detected", "status-crit" + case sdk.StatusWarn: + return "warn", "Warnings detected", "status-warn" + case sdk.StatusInfo: + return "info", "Informational notes", "status-info" + default: + return "ok", "Alias chain healthy", "status-ok" + } +} + +func severityClass(s sdk.Status) string { + switch s { + case sdk.StatusCrit: + return "crit" + case sdk.StatusWarn: + return "warn" + case sdk.StatusInfo: + return "info" + case sdk.StatusError: + return "crit" + default: + return "ok" + } +} + +func statusRank(s sdk.Status) int { + switch s { + case sdk.StatusCrit, sdk.StatusError: + return 4 + case sdk.StatusWarn: + return 3 + case sdk.StatusInfo: + return 2 + case sdk.StatusOK: + return 1 + } + return 0 +} + +func severityRankClass(c string) int { + switch c { + case "crit": + return 4 + case "warn": + return 3 + case "info": + return 2 + case "ok": + return 1 + } + return 0 +} + +func hintOf(s sdk.CheckState) string { + if s.Meta == nil { + return "" + } + h, _ := s.Meta[hintKey].(string) + return h +} + +func titleFor(rule string) string { + switch rule { + case "cname_at_apex": + return "CNAME at zone apex" + case "cname_coexistence": + return "CNAME coexists with other records" + case "chain_loop": + return "Alias chain loops" + case "chain_length": + return "Alias chain too long" + case "target_resolvable": + return "Target does not resolve" + case "chain_rcode": + return "Alias lookup error" + case "cname_dnssec": + return "CNAME not DNSSEC-signed" + case "multiple_records": + return "Multiple CNAME records at the same name" + } + return strings.ReplaceAll(rule, "_", " ") +} + +var reportTmpl = template.Must(template.New("alias-report").Parse(reportTemplate)) + +// Inlined styles so the report embeds in an iframe with no asset dependencies. +const reportTemplate = ` + + + +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}} + +
SeverityRuleSubjectMessage
{{.Severity}}{{.RuleName}}{{.Subject}}{{.Message}}{{if .Hint}}
{{.Hint}}{{end}}
+ {{end}} + + +` diff --git a/checker/rules_apex.go b/checker/rules_apex.go new file mode 100644 index 0000000..3ff7319 --- /dev/null +++ b/checker/rules_apex.go @@ -0,0 +1,93 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type apexLookupRule struct{} + +func (apexLookupRule) Name() string { return "apex_lookup" } +func (apexLookupRule) Description() string { + return "Verifies the zone apex (SOA) of the checked name can be located." +} + +func (apexLookupRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if data.Apex != "" { + return okState(data.Apex, fmt.Sprintf("apex %s located", data.Apex)) + } + return []sdk.CheckState{withHint(sdk.CheckState{ + Status: sdk.StatusCrit, + Subject: data.Owner, + Message: fmt.Sprintf("could not locate zone apex: %s", data.ApexLookupError), + }, "Check that the parent delegation exists and that the zone is published.")} +} + +type cnameAtApexRule struct{} + +func (cnameAtApexRule) Name() string { return "cname_at_apex" } +func (cnameAtApexRule) Description() string { + return "Flags a CNAME at the zone apex, which conflicts with the SOA/NS records the apex must carry (RFC 1912 §2.4). Honours the shared allowApexCNAME option." +} + +func (cnameAtApexRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + if !data.OwnerIsApex { + return skipped("owner is not the zone apex") + } + if !data.ApexHasCNAME { + return okState(data.Apex, "no CNAME at apex") + } + status := sdk.StatusCrit + if allowApexCNAME(opts) { + status = sdk.StatusWarn + } + return []sdk.CheckState{withHint(sdk.CheckState{ + Status: status, + Subject: data.Apex, + Message: fmt.Sprintf("CNAME at apex %s conflicts with SOA/NS (RFC 1912 §2.4)", data.Apex), + }, "Use the provider's ALIAS/ANAME flattening, an HTTP redirect, or move content to a sub-label such as www.")} +} + +type apexFlatteningRule struct{} + +func (apexFlatteningRule) Name() string { return "apex_flattening" } +func (apexFlatteningRule) Description() string { + return "Notes ALIAS/ANAME provider-side flattening (A/AAAA served at apex alongside SOA/NS)." +} + +func (apexFlatteningRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + if !data.OwnerIsApex { + return skipped("owner is not the zone apex") + } + if !data.ApexFlattening { + return okState(data.Apex, "no ALIAS/ANAME flattening detected") + } + if !recognizeApexFlattening(opts) { + return skipped("recognizeApexFlattening disabled") + } + return []sdk.CheckState{withHint(sdk.CheckState{ + Status: sdk.StatusInfo, + Subject: data.Apex, + Message: fmt.Sprintf("apex %s serves A/AAAA directly (provider-side ALIAS/ANAME flattening)", data.Apex), + }, "Keep the upstream target's TTL in mind: apex A/AAAA will only update as fast as the provider re-flattens.")} +} diff --git a/checker/rules_chain.go b/checker/rules_chain.go new file mode 100644 index 0000000..12e91b6 --- /dev/null +++ b/checker/rules_chain.go @@ -0,0 +1,273 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type chainLoopRule struct{} + +func (chainLoopRule) Name() string { return "chain_loop" } +func (chainLoopRule) Description() string { + return "Detects CNAME/DNAME cycles in the resolution chain." +} + +func (chainLoopRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + if data.ChainTerminated.Reason != TermLoop { + return okState(data.Owner, "no loop in the alias chain") + } + return []sdk.CheckState{withHint(sdk.CheckState{ + Status: sdk.StatusCrit, + Subject: data.ChainTerminated.Subject, + Message: fmt.Sprintf("chain loops back to %s", data.ChainTerminated.Subject), + }, "Break the loop by pointing the last CNAME at an A/AAAA-bearing name.")} +} + +type chainLengthRule struct{} + +func (chainLengthRule) Name() string { return "chain_length" } +func (chainLengthRule) Description() string { + return "Flags alias chains longer than the configured maximum (most resolvers give up around 8-16 hops)." +} + +func (chainLengthRule) Options() sdk.CheckerOptionsDocumentation { + return sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "maxChainLength", + Type: "uint", + Label: "Maximum chain length", + Description: "Above this number of hops the chain is reported as critical.", + Default: float64(defaultMaxChainLength), + }, + }, + } +} + +func (chainLengthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + maxLen := sdk.GetIntOption(opts, "maxChainLength", defaultMaxChainLength) + if data.ChainTerminated.Reason != TermTooLong { + return okState(data.Owner, fmt.Sprintf("chain has %d hop(s), within limit of %d", len(data.Chain), maxLen)) + } + return []sdk.CheckState{withHint(sdk.CheckState{ + Status: sdk.StatusCrit, + Subject: data.ChainTerminated.Subject, + Message: fmt.Sprintf("chain exceeds %d hops; many resolvers will give up", maxLen), + }, "Flatten intermediate CNAMEs so that the chain is at most a few hops long.")} +} + +type chainQueryErrorRule struct{} + +func (chainQueryErrorRule) Name() string { return "chain_query_error" } +func (chainQueryErrorRule) Description() string { + return "Flags DNS query failures encountered while walking the alias chain." +} + +func (chainQueryErrorRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + if data.ChainTerminated.Reason != TermQueryErr { + return okState(data.Owner, "all chain queries succeeded") + } + return []sdk.CheckState{withHint(sdk.CheckState{ + Status: sdk.StatusWarn, + Subject: data.ChainTerminated.Subject, + Message: fmt.Sprintf("CNAME query for %s failed: %s", data.ChainTerminated.Subject, data.ChainTerminated.Detail), + }, "Check authoritative-server reachability and firewall rules; the alias is unusable while queries fail.")} +} + +type chainRcodeRule struct{} + +func (chainRcodeRule) Name() string { return "chain_rcode" } +func (chainRcodeRule) Description() string { + return "Flags NXDOMAIN/SERVFAIL/other rcodes encountered mid-chain or on the final target lookup." +} + +func (chainRcodeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + var out []sdk.CheckState + if data.ChainTerminated.Reason == TermRcode { + out = append(out, withHint(sdk.CheckState{ + Status: sdk.StatusCrit, + Subject: data.ChainTerminated.Subject, + Message: fmt.Sprintf("server answered %s mid-chain", data.ChainTerminated.Rcode), + }, "Ensure the zone publishes the expected record; NXDOMAIN/SERVFAIL mid-chain breaks the alias.")) + } + if data.FinalRcode != "" && data.FinalRcode != "NOERROR" { + out = append(out, withHint(sdk.CheckState{ + Status: sdk.StatusWarn, + Subject: data.FinalTarget, + Message: fmt.Sprintf("final A lookup for %s returned %s", data.FinalTarget, data.FinalRcode), + }, "Check the upstream zone's A/AAAA publication.")) + } + if len(out) == 0 { + return okState(data.Owner, "all chain and final lookups returned NOERROR") + } + return out +} + +type hopTTLRule struct{} + +func (hopTTLRule) Name() string { return "hop_ttl" } +func (hopTTLRule) Description() string { + return "Flags chain hops whose TTL is below the configured minimum." +} + +func (hopTTLRule) Options() sdk.CheckerOptionsDocumentation { + return sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "minTargetTTL", + Type: "uint", + Label: "Minimum TTL (seconds)", + Description: "Hops with a TTL below this threshold are flagged as a warning.", + Default: float64(defaultMinTargetTTL), + }, + }, + } +} + +func (hopTTLRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + if len(data.Chain) == 0 { + return skipped("chain is empty") + } + minTTL := uint32(sdk.GetIntOption(opts, "minTargetTTL", defaultMinTargetTTL)) + var out []sdk.CheckState + for _, h := range data.Chain { + if h.Kind == KindTarget || h.TTL == 0 { + continue + } + if h.TTL < minTTL { + out = append(out, withHint(sdk.CheckState{ + Status: sdk.StatusWarn, + Subject: h.Owner, + Message: fmt.Sprintf("hop %s → %s has TTL %ds (< %d)", h.Owner, h.Target, h.TTL, minTTL), + }, "Raise the CNAME TTL to improve cache efficiency (5-15 minutes is a common floor).")) + } + } + if len(out) == 0 { + return okState(data.Owner, fmt.Sprintf("all chain hops have TTL ≥ %ds", minTTL)) + } + return out +} + +type targetResolvableRule struct{} + +func (targetResolvableRule) Name() string { return "target_resolvable" } +func (targetResolvableRule) Description() string { + return "Verifies that the final target of the alias chain publishes at least one A or AAAA record." +} + +func (targetResolvableRule) Options() sdk.CheckerOptionsDocumentation { + return sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "requireResolvableTarget", + Type: "bool", + Label: "Require resolvable target", + Description: "When enabled, a chain whose final target returns no A/AAAA is reported as critical (otherwise a warning).", + Default: defaultRequireResolvableTarget, + }, + }, + } +} + +func (targetResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + if data.ChainTerminated.Reason != TermOK { + return skipped("chain did not terminate normally") + } + if len(data.FinalA) > 0 || len(data.FinalAAAA) > 0 { + return okState(data.FinalTarget, fmt.Sprintf("target %s resolves to %d address(es)", data.FinalTarget, len(data.FinalA)+len(data.FinalAAAA))) + } + status := sdk.StatusWarn + if sdk.GetBoolOption(opts, "requireResolvableTarget", defaultRequireResolvableTarget) { + status = sdk.StatusCrit + } + rcode := data.FinalRcode + if rcode == "" { + rcode = "no A/AAAA" + } + return []sdk.CheckState{withHint(sdk.CheckState{ + Status: status, + Subject: data.FinalTarget, + Message: fmt.Sprintf("final target %s does not resolve to an address (%s)", data.FinalTarget, rcode), + }, "Point the alias at a name that publishes at least one A or AAAA record, or fix the upstream zone.")} +} + +type multipleRecordsRule struct{} + +func (multipleRecordsRule) Name() string { return "multiple_records" } +func (multipleRecordsRule) Description() string { + return "Flags owners that carry more than one CNAME/DNAME record; only one is legal per owner." +} + +func (multipleRecordsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + seen := map[string]int{} + for _, h := range data.Chain { + if h.Kind == KindCNAME || h.Kind == KindDNAME { + seen[h.Owner]++ + } + } + var out []sdk.CheckState + for owner, n := range seen { + if n > 1 { + out = append(out, withHint(sdk.CheckState{ + Status: sdk.StatusCrit, + Subject: owner, + Message: fmt.Sprintf("%s carries %d CNAME/DNAME records in the chain", owner, n), + }, "Keep a single CNAME per name; remove duplicates at the authoritative zone.")) + } + } + if len(out) == 0 { + return okState(data.Owner, "every chain owner carries a single CNAME/DNAME") + } + return out +} diff --git a/checker/rules_coexistence.go b/checker/rules_coexistence.go new file mode 100644 index 0000000..252c987 --- /dev/null +++ b/checker/rules_coexistence.go @@ -0,0 +1,58 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type cnameCoexistenceRule struct{} + +func (cnameCoexistenceRule) Name() string { return "cname_coexistence" } +func (cnameCoexistenceRule) Description() string { + return "Flags other RRsets that sit at the same owner as a CNAME (RFC 1034 §3.6.2 / RFC 2181 §10.1). Honours allowApexCNAME and recognizeApexFlattening." +} + +func (cnameCoexistenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + if !data.OwnerHasCNAME { + return skipped("owner has no CNAME") + } + if len(data.Coexisting) == 0 { + return okState(data.Owner, "CNAME is the only RRset at its owner") + } + + isApex := data.OwnerIsApex + recognize := recognizeApexFlattening(opts) + allow := allowApexCNAME(opts) + + var out []sdk.CheckState + for _, rr := range data.Coexisting { + // Provider-side flattening serves apex A/AAAA on a synthetic owner, + // so the wire-level coexistence is intentional, not a zone bug. + if isApex && recognize && data.ApexFlattening && (rr.Type == "A" || rr.Type == "AAAA") { + continue + } + status := sdk.StatusCrit + if isApex && allow { + status = sdk.StatusWarn + } + out = append(out, withHint(sdk.CheckState{ + Status: status, + Subject: data.Owner, + Message: fmt.Sprintf("%s and CNAME both exist at %s (RFC 1034 §3.6.2 / RFC 2181 §10.1)", rr.Type, data.Owner), + Code: rr.Type, + }, "Remove the sibling record or move it under a different label; a name cannot simultaneously carry a CNAME and other data.")) + } + if len(out) == 0 { + return okState(data.Owner, "CNAME coexistence exempted by ALIAS/ANAME flattening") + } + return out +} diff --git a/checker/rules_common.go b/checker/rules_common.go new file mode 100644 index 0000000..b267fba --- /dev/null +++ b/checker/rules_common.go @@ -0,0 +1,75 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Defaults are centralised so Definition's docs and runtime reads cannot drift. +const ( + defaultMaxChainLength = 8 + defaultMinTargetTTL = 60 + defaultRequireResolvableTarget = true + defaultAllowApexCNAME = false + defaultRecognizeApexFlattening = true + + // hintKey is the CheckState.Meta key the HTML report reads to render the + // "How to fix" block: keep them in sync. + hintKey = "hint" +) + +func loadAlias(ctx context.Context, obs sdk.ObservationGetter) (*AliasData, []sdk.CheckState) { + var data AliasData + if err := obs.Get(ctx, ObservationKeyAlias, &data); err != nil { + return nil, []sdk.CheckState{{ + Status: sdk.StatusError, + Message: fmt.Sprintf("failed to read alias observation: %v", err), + }} + } + return &data, nil +} + +// skipped is the "nothing to judge right now" return: rules must produce at +// least one state, otherwise the SDK substitutes StatusUnknown. +func skipped(reason string) []sdk.CheckState { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Message: "skipped: " + reason, + }} +} + +func okState(subject, message string) []sdk.CheckState { + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Subject: subject, + Message: message, + }} +} + +// withHint is a no-op when hint is empty so callers can pass through unchecked. +func withHint(s sdk.CheckState, hint string) sdk.CheckState { + if hint == "" { + return s + } + if s.Meta == nil { + s.Meta = map[string]any{} + } + s.Meta[hintKey] = hint + return s +} + +// apexKnown gates chain-oriented rules with a uniform "apex lookup failed" +// skip line so the UI stays consistent across rules. +func apexKnown(data *AliasData) bool { + return data.ApexLookupError == "" && data.Apex != "" +} + +func allowApexCNAME(opts sdk.CheckerOptions) bool { + return sdk.GetBoolOption(opts, "allowApexCNAME", defaultAllowApexCNAME) +} + +func recognizeApexFlattening(opts sdk.CheckerOptions) bool { + return sdk.GetBoolOption(opts, "recognizeApexFlattening", defaultRecognizeApexFlattening) +} diff --git a/checker/rules_dnssec.go b/checker/rules_dnssec.go new file mode 100644 index 0000000..6ec16dd --- /dev/null +++ b/checker/rules_dnssec.go @@ -0,0 +1,42 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type cnameDnssecRule struct{} + +func (cnameDnssecRule) Name() string { return "cname_dnssec" } +func (cnameDnssecRule) Description() string { + return "Verifies that, in a DNSSEC-signed zone, the CNAME at Owner carries an RRSIG." +} + +func (cnameDnssecRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errState := loadAlias(ctx, obs) + if errState != nil { + return errState + } + if !apexKnown(data) { + return skipped("apex lookup failed") + } + if !data.ZoneSigned { + return skipped("zone not DNSSEC-signed") + } + if !data.OwnerHasCNAME { + return skipped("owner has no CNAME") + } + if !data.CNAMESigCheckDone { + return skipped("DO-bit CNAME probe did not complete") + } + if data.CNAMESigned { + return okState(data.Owner, fmt.Sprintf("CNAME at %s is DNSSEC-signed", data.Owner)) + } + return []sdk.CheckState{withHint(sdk.CheckState{ + Status: sdk.StatusCrit, + Subject: data.Owner, + Message: fmt.Sprintf("zone %s is DNSSEC-signed but CNAME at %s has no RRSIG", data.Apex, data.Owner), + }, "Re-sign the zone or verify your signer covers the alias RRset; unsigned answers in a signed zone SERVFAIL at validating resolvers.")} +} diff --git a/checker/rules_test.go b/checker/rules_test.go new file mode 100644 index 0000000..95a1c28 --- /dev/null +++ b/checker/rules_test.go @@ -0,0 +1,350 @@ +package checker + +import ( + "context" + "encoding/json" + "strings" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// JSON-round-tripping mirrors the production read path so tests catch tag +// drift between AliasData fields and rule expectations. +type fakeObs struct { + data *AliasData +} + +func (f fakeObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error { + if f.data == nil { + return nil + } + raw, err := json.Marshal(f.data) + if err != nil { + return err + } + return json.Unmarshal(raw, dest) +} + +func (fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) { + return nil, nil +} + +func run(r sdk.CheckRule, data *AliasData, opts sdk.CheckerOptions) []sdk.CheckState { + return r.Evaluate(context.Background(), fakeObs{data: data}, opts) +} + +// apexKnownData returns a minimal AliasData whose apex lookup succeeded, used +// as a baseline so non-apex rules can run. +func apexKnownData() *AliasData { + return &AliasData{ + Owner: "www.example.com.", + Apex: "example.com.", + } +} + +func assertSkipped(t *testing.T, states []sdk.CheckState, wantSubstr string) { + t.Helper() + if len(states) != 1 { + t.Fatalf("want 1 state, got %d: %+v", len(states), states) + } + if states[0].Status != sdk.StatusUnknown { + t.Fatalf("want StatusUnknown (skipped), got %v", states[0].Status) + } + if !strings.Contains(states[0].Message, "skipped") || !strings.Contains(states[0].Message, wantSubstr) { + t.Fatalf("want skipped message containing %q, got %q", wantSubstr, states[0].Message) + } +} + +func assertSingle(t *testing.T, states []sdk.CheckState, want sdk.Status) sdk.CheckState { + t.Helper() + if len(states) != 1 { + t.Fatalf("want 1 state, got %d: %+v", len(states), states) + } + if states[0].Status != want { + t.Fatalf("want status %v, got %v (msg=%q)", want, states[0].Status, states[0].Message) + } + return states[0] +} + +func TestApexLookupRule(t *testing.T) { + t.Run("ok", func(t *testing.T) { + s := assertSingle(t, run(apexLookupRule{}, apexKnownData(), nil), sdk.StatusOK) + if s.Subject != "example.com." { + t.Fatalf("want subject=example.com., got %q", s.Subject) + } + }) + t.Run("failure", func(t *testing.T) { + data := &AliasData{Owner: "www.nope.invalid.", ApexLookupError: "no SOA"} + s := assertSingle(t, run(apexLookupRule{}, data, nil), sdk.StatusCrit) + if s.Meta[hintKey] == nil { + t.Fatalf("want hint, got none") + } + }) +} + +func TestChainLoopRule(t *testing.T) { + t.Run("ok", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated.Reason = TermOK + assertSingle(t, run(chainLoopRule{}, d, nil), sdk.StatusOK) + }) + t.Run("loop", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated = ChainTermination{Reason: TermLoop, Subject: "a.example.com."} + s := assertSingle(t, run(chainLoopRule{}, d, nil), sdk.StatusCrit) + if s.Subject != "a.example.com." { + t.Fatalf("want subject to be loop offender, got %q", s.Subject) + } + }) + t.Run("skip when apex unknown", func(t *testing.T) { + d := &AliasData{Owner: "x.", ApexLookupError: "boom"} + assertSkipped(t, run(chainLoopRule{}, d, nil), "apex") + }) +} + +func TestChainLengthRule(t *testing.T) { + t.Run("ok", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated.Reason = TermOK + assertSingle(t, run(chainLengthRule{}, d, nil), sdk.StatusOK) + }) + t.Run("too long", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated = ChainTermination{Reason: TermTooLong, Subject: "deep.example.com."} + assertSingle(t, run(chainLengthRule{}, d, sdk.CheckerOptions{"maxChainLength": float64(3)}), sdk.StatusCrit) + }) +} + +func TestChainQueryErrorRule(t *testing.T) { + t.Run("ok", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated.Reason = TermOK + assertSingle(t, run(chainQueryErrorRule{}, d, nil), sdk.StatusOK) + }) + t.Run("query err", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated = ChainTermination{Reason: TermQueryErr, Subject: "broken.example.com.", Detail: "timeout"} + assertSingle(t, run(chainQueryErrorRule{}, d, nil), sdk.StatusWarn) + }) +} + +func TestChainRcodeRule(t *testing.T) { + t.Run("ok", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated.Reason = TermOK + assertSingle(t, run(chainRcodeRule{}, d, nil), sdk.StatusOK) + }) + t.Run("mid-chain NXDOMAIN", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated = ChainTermination{Reason: TermRcode, Subject: "gone.example.com.", Rcode: "NXDOMAIN"} + assertSingle(t, run(chainRcodeRule{}, d, nil), sdk.StatusCrit) + }) + t.Run("final rcode", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated.Reason = TermOK + d.FinalTarget = "target.example." + d.FinalRcode = "SERVFAIL" + states := run(chainRcodeRule{}, d, nil) + if len(states) != 1 || states[0].Status != sdk.StatusWarn { + t.Fatalf("want single WARN, got %+v", states) + } + }) +} + +func TestHopTTLRule(t *testing.T) { + t.Run("ok", func(t *testing.T) { + d := apexKnownData() + d.Chain = []ChainHop{{Owner: "a.", Kind: KindCNAME, Target: "b.", TTL: 300}} + d.ChainTerminated.Reason = TermOK + assertSingle(t, run(hopTTLRule{}, d, nil), sdk.StatusOK) + }) + t.Run("multi-subject low TTL", func(t *testing.T) { + d := apexKnownData() + d.Chain = []ChainHop{ + {Owner: "a.", Kind: KindCNAME, Target: "b.", TTL: 10}, + {Owner: "b.", Kind: KindCNAME, Target: "c.", TTL: 20}, + {Owner: "c.", Kind: KindTarget}, + } + states := run(hopTTLRule{}, d, sdk.CheckerOptions{"minTargetTTL": float64(60)}) + if len(states) != 2 { + t.Fatalf("want 2 states (one per low-TTL hop), got %d: %+v", len(states), states) + } + for _, s := range states { + if s.Status != sdk.StatusWarn { + t.Fatalf("want WARN, got %v", s.Status) + } + } + }) + t.Run("skip empty chain", func(t *testing.T) { + d := apexKnownData() + assertSkipped(t, run(hopTTLRule{}, d, nil), "chain is empty") + }) +} + +func TestCnameAtApexRule(t *testing.T) { + t.Run("ok when no cname at apex", func(t *testing.T) { + d := apexKnownData() + d.OwnerIsApex = true + d.Owner = "example.com." + assertSingle(t, run(cnameAtApexRule{}, d, nil), sdk.StatusOK) + }) + t.Run("crit when apex has cname", func(t *testing.T) { + d := apexKnownData() + d.OwnerIsApex = true + d.ApexHasCNAME = true + d.Owner = "example.com." + assertSingle(t, run(cnameAtApexRule{}, d, nil), sdk.StatusCrit) + }) + t.Run("warn when allowApexCNAME", func(t *testing.T) { + d := apexKnownData() + d.OwnerIsApex = true + d.ApexHasCNAME = true + d.Owner = "example.com." + assertSingle(t, run(cnameAtApexRule{}, d, sdk.CheckerOptions{"allowApexCNAME": true}), sdk.StatusWarn) + }) + t.Run("skip when not apex", func(t *testing.T) { + d := apexKnownData() + assertSkipped(t, run(cnameAtApexRule{}, d, nil), "apex") + }) +} + +func TestApexFlatteningRule(t *testing.T) { + t.Run("ok when no flattening", func(t *testing.T) { + d := apexKnownData() + d.OwnerIsApex = true + assertSingle(t, run(apexFlatteningRule{}, d, nil), sdk.StatusOK) + }) + t.Run("info when flattening recognized", func(t *testing.T) { + d := apexKnownData() + d.OwnerIsApex = true + d.ApexFlattening = true + assertSingle(t, run(apexFlatteningRule{}, d, nil), sdk.StatusInfo) + }) + t.Run("skip when recognizeApexFlattening=false", func(t *testing.T) { + d := apexKnownData() + d.OwnerIsApex = true + d.ApexFlattening = true + assertSkipped(t, run(apexFlatteningRule{}, d, sdk.CheckerOptions{"recognizeApexFlattening": false}), "recognizeApexFlattening") + }) +} + +func TestCnameCoexistenceRule(t *testing.T) { + t.Run("ok", func(t *testing.T) { + d := apexKnownData() + d.OwnerHasCNAME = true + assertSingle(t, run(cnameCoexistenceRule{}, d, nil), sdk.StatusOK) + }) + t.Run("multi-subject crit", func(t *testing.T) { + d := apexKnownData() + d.OwnerHasCNAME = true + d.Coexisting = []CoexistingRRset{{Type: "MX"}, {Type: "TXT"}} + states := run(cnameCoexistenceRule{}, d, nil) + if len(states) != 2 { + t.Fatalf("want 2 states, got %d", len(states)) + } + }) + t.Run("apex A/AAAA excused by flattening", func(t *testing.T) { + d := apexKnownData() + d.Owner = "example.com." + d.OwnerIsApex = true + d.OwnerHasCNAME = true + d.ApexFlattening = true + d.Coexisting = []CoexistingRRset{{Type: "A"}, {Type: "AAAA"}, {Type: "MX"}} + states := run(cnameCoexistenceRule{}, d, nil) + // Only MX remains, A/AAAA excused. + if len(states) != 1 { + t.Fatalf("want 1 state (MX only), got %d: %+v", len(states), states) + } + if states[0].Code != "MX" { + t.Fatalf("want code=MX, got %q", states[0].Code) + } + }) + t.Run("skip without cname", func(t *testing.T) { + d := apexKnownData() + assertSkipped(t, run(cnameCoexistenceRule{}, d, nil), "owner has no CNAME") + }) +} + +func TestCnameDnssecRule(t *testing.T) { + t.Run("skip unsigned zone", func(t *testing.T) { + d := apexKnownData() + d.OwnerHasCNAME = true + assertSkipped(t, run(cnameDnssecRule{}, d, nil), "zone not DNSSEC") + }) + t.Run("ok when signed", func(t *testing.T) { + d := apexKnownData() + d.ZoneSigned = true + d.OwnerHasCNAME = true + d.CNAMESigCheckDone = true + d.CNAMESigned = true + assertSingle(t, run(cnameDnssecRule{}, d, nil), sdk.StatusOK) + }) + t.Run("crit when unsigned cname", func(t *testing.T) { + d := apexKnownData() + d.ZoneSigned = true + d.OwnerHasCNAME = true + d.CNAMESigCheckDone = true + assertSingle(t, run(cnameDnssecRule{}, d, nil), sdk.StatusCrit) + }) +} + +func TestTargetResolvableRule(t *testing.T) { + t.Run("ok", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated.Reason = TermOK + d.FinalTarget = "target." + d.FinalA = []string{"1.2.3.4"} + assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusOK) + }) + t.Run("crit by default", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated.Reason = TermOK + d.FinalTarget = "target." + assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusCrit) + }) + t.Run("warn when requireResolvableTarget=false", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated.Reason = TermOK + d.FinalTarget = "target." + assertSingle(t, run(targetResolvableRule{}, d, sdk.CheckerOptions{"requireResolvableTarget": false}), sdk.StatusWarn) + }) + t.Run("skip when chain did not terminate normally", func(t *testing.T) { + d := apexKnownData() + d.ChainTerminated.Reason = TermLoop + assertSkipped(t, run(targetResolvableRule{}, d, nil), "chain did not terminate normally") + }) +} + +func TestMultipleRecordsRule(t *testing.T) { + t.Run("ok", func(t *testing.T) { + d := apexKnownData() + d.Chain = []ChainHop{{Owner: "a.", Kind: KindCNAME, Target: "b."}} + assertSingle(t, run(multipleRecordsRule{}, d, nil), sdk.StatusOK) + }) + t.Run("duplicate owner", func(t *testing.T) { + d := apexKnownData() + d.Chain = []ChainHop{ + {Owner: "dup.", Kind: KindCNAME, Target: "b."}, + {Owner: "dup.", Kind: KindCNAME, Target: "c."}, + } + states := run(multipleRecordsRule{}, d, nil) + if len(states) != 1 || states[0].Status != sdk.StatusCrit { + t.Fatalf("want 1 CRIT, got %+v", states) + } + }) +} + +// Sanity: every rule registered in the Definition returns at least one state +// even when asked to judge a blank AliasData (apex lookup failed). This guards +// against a rule slipping through with an empty-slice return path that would +// be replaced by the SDK with StatusUnknown. +func TestAllRulesAlwaysReturnAtLeastOneState(t *testing.T) { + blank := &AliasData{ApexLookupError: "no apex"} + for _, r := range Definition().Rules { + got := run(r, blank, nil) + if len(got) == 0 { + t.Fatalf("rule %s returned no states", r.Name()) + } + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..724ec27 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,102 @@ +package checker + +import ( + "encoding/json" + + "github.com/miekg/dns" +) + +const ObservationKeyAlias = "alias" + +type AliasKind string + +const ( + KindCNAME AliasKind = "CNAME" + KindDNAME AliasKind = "DNAME" + KindALIAS AliasKind = "ALIAS" // provider-flattened apex alias, no real RR on the wire + KindTarget AliasKind = "TARGET" +) + +type ChainHop struct { + Owner string `json:"owner"` + Kind AliasKind `json:"kind"` + Target string `json:"target,omitempty"` + TTL uint32 `json:"ttl,omitempty"` + Server string `json:"server,omitempty"` + Synthesized bool `json:"synthesized,omitempty"` // CNAME synthesized from DNAME +} + +type CoexistingRRset struct { + Type string `json:"type"` + TTL uint32 `json:"ttl,omitempty"` +} + +type TerminationReason string + +const ( + TermOK TerminationReason = "ok" + TermLoop TerminationReason = "loop" + TermTooLong TerminationReason = "too_long" + TermQueryErr TerminationReason = "query_error" + TermRcode TerminationReason = "rcode" +) + +// ChainTermination is always populated after a walk; rules key off Reason. +type ChainTermination struct { + Reason TerminationReason `json:"reason"` + Subject string `json:"subject,omitempty"` + Detail string `json:"detail,omitempty"` + Rcode string `json:"rcode,omitempty"` // only with TermRcode +} + +// AliasData carries raw facts only; judgement is delegated to the rules. +type AliasData struct { + Owner string `json:"owner"` + + // Apex is empty iff the apex lookup failed; ApexLookupError explains why. + Apex string `json:"apex,omitempty"` + ApexLookupError string `json:"apex_lookup_error,omitempty"` + AuthServers []string `json:"auth_servers,omitempty"` + + Chain []ChainHop `json:"chain,omitempty"` + ChainTerminated ChainTermination `json:"chain_terminated"` + + // FinalTarget is the last name in the chain, equal to Owner when there is + // no indirection. + FinalTarget string `json:"final_target,omitempty"` + FinalA []string `json:"final_a,omitempty"` + FinalAAAA []string `json:"final_aaaa,omitempty"` + FinalRcode string `json:"final_rcode,omitempty"` + + // Coexisting is populated only when Owner has a CNAME. + Coexisting []CoexistingRRset `json:"coexisting,omitempty"` + + OwnerIsApex bool `json:"owner_is_apex,omitempty"` + OwnerHasCNAME bool `json:"owner_has_cname,omitempty"` + + // Apex* fields are populated only when OwnerIsApex. + ApexHasA bool `json:"apex_has_a,omitempty"` + ApexHasAAAA bool `json:"apex_has_aaaa,omitempty"` + ApexHasCNAME bool `json:"apex_has_cname,omitempty"` + ApexFlattening bool `json:"apex_flattening,omitempty"` + + ZoneSigned bool `json:"zone_signed,omitempty"` + // CNAMESigCheckDone gates CNAMESigned: a false here means we never probed + // (zone unsigned or no CNAME), so CNAMESigned must not be interpreted. + CNAMESigCheckDone bool `json:"cname_sig_check_done,omitempty"` + CNAMESigned bool `json:"cname_signed,omitempty"` + + DNAMESubstitutions []ChainHop `json:"dname_substitutions,omitempty"` +} + +// cnameService mirrors happyDomain's svcs.CNAME / svcs.SpecialCNAME wire shape. +type cnameService struct { + Record *dns.CNAME `json:"cname"` +} + +// serviceMessage mirrors happyDomain's ServiceMessage envelope. +type serviceMessage struct { + Type string `json:"_svctype"` + Domain string `json:"_domain"` + Service json.RawMessage `json:"Service"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c0bd4cd --- /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.4.0 + github.com/miekg/dns v1.1.72 +) + +require ( + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/tools v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fc3939b --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs= +git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f702700 --- /dev/null +++ b/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "flag" + "log" + + alias "git.happydns.org/checker-alias/checker" + "git.happydns.org/checker-sdk-go/checker/server" +) + +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + + alias.Version = Version + + srv := server.New(alias.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} 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 +}