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