Initial commit
This commit is contained in:
commit
6e24d04ce6
18 changed files with 1804 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
checker-alias
|
||||||
|
checker-alias.so
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
ARG CHECKER_VERSION=custom-build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-alias .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /checker-alias /checker-alias
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/checker-alias"]
|
||||||
21
LICENSE
Normal file
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
|
||||||
79
README.md
Normal file
79
README.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# checker-alias
|
||||||
|
|
||||||
|
CNAME / DNAME / ALIAS chain checker for [happyDomain](https://www.happydomain.org/).
|
||||||
|
|
||||||
|
Walks the alias chain of a name, validates hop count, TTLs, target
|
||||||
|
resolvability, apex coexistence (RFC 1912 §2.4, RFC 1034 §3.6.2,
|
||||||
|
RFC 2181 §10.1), DNAME substitutions, and DNSSEC signing of the CNAME
|
||||||
|
RRset.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Standalone HTTP server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and run
|
||||||
|
make
|
||||||
|
./checker-alias -listen :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
The server exposes:
|
||||||
|
|
||||||
|
- `GET /health`: health check
|
||||||
|
- `POST /collect`: collect alias observations (happyDomain external checker protocol)
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker
|
||||||
|
docker run -p 8080:8080 happydomain/checker-alias
|
||||||
|
```
|
||||||
|
|
||||||
|
### happyDomain plugin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make plugin
|
||||||
|
# produces checker-alias.so, loadable by happyDomain as a Go plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin exposes a `NewCheckerPlugin` symbol returning the checker
|
||||||
|
definition and observation provider, which happyDomain registers in its
|
||||||
|
global registries at load time.
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
The binary, plugin, and Docker image embed a version string overridable
|
||||||
|
at build time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make CHECKER_VERSION=1.2.3
|
||||||
|
make plugin CHECKER_VERSION=1.2.3
|
||||||
|
make docker CHECKER_VERSION=1.2.3
|
||||||
|
```
|
||||||
|
|
||||||
|
### happyDomain remote endpoint
|
||||||
|
|
||||||
|
Set the `endpoint` admin option for the alias checker to the URL of the
|
||||||
|
running checker-alias server (e.g., `http://checker-alias:8080`).
|
||||||
|
happyDomain will delegate observation collection to this endpoint.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
| Id | Type | Default | Description |
|
||||||
|
|---------------------------|------|---------|-----------------------------------------------------------------------------|
|
||||||
|
| `maxChainLength` | uint | `8` | Above this number of hops the chain is reported as critical. |
|
||||||
|
| `minTargetTTL` | uint | `60` | Hops with a TTL below this threshold are flagged as a warning. |
|
||||||
|
| `requireResolvableTarget` | bool | `true` | When enabled, a final target with no A/AAAA is critical (otherwise warning).|
|
||||||
|
| `allowApexCNAME` | bool | `false` | When enabled, a CNAME at apex is only a warning (RFC 1912 forbids it). |
|
||||||
|
| `recognizeApexFlattening` | bool | `true` | Recognize provider-side ALIAS/ANAME flattening as informational. |
|
||||||
|
|
||||||
|
Finding codes emitted by the checker include: `alias_no_apex`,
|
||||||
|
`alias_loop`, `alias_chain_too_long`, `alias_query_failed`,
|
||||||
|
`alias_rcode`, `alias_low_ttl`, `alias_cname_at_apex`,
|
||||||
|
`alias_apex_flattening`, `alias_coexisting_rrset`,
|
||||||
|
`alias_cname_not_signed`, `alias_target_unresolvable`,
|
||||||
|
`alias_multiple_records`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under the **MIT License** (see `LICENSE`).
|
||||||
595
checker/collect.go
Normal file
595
checker/collect.go
Normal file
|
|
@ -0,0 +1,595 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect runs the alias testsuite and returns an *AliasData populated with
|
||||||
|
// findings, a resolution chain, and optional coexistence / DNSSEC observations.
|
||||||
|
func (p *aliasProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||||
|
owner, err := resolveOwner(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
maxChain := sdk.GetIntOption(opts, "maxChainLength", 8)
|
||||||
|
minTTL := uint32(sdk.GetIntOption(opts, "minTargetTTL", 60))
|
||||||
|
requireTarget := sdk.GetBoolOption(opts, "requireResolvableTarget", true)
|
||||||
|
allowApexCNAME := sdk.GetBoolOption(opts, "allowApexCNAME", false)
|
||||||
|
recognizeApex := sdk.GetBoolOption(opts, "recognizeApexFlattening", true)
|
||||||
|
|
||||||
|
data := &AliasData{Owner: owner}
|
||||||
|
|
||||||
|
resolver := systemResolver()
|
||||||
|
|
||||||
|
// 1. Find apex and authoritative servers.
|
||||||
|
apex, servers, err := findApex(ctx, owner, resolver)
|
||||||
|
if err != nil {
|
||||||
|
data.Findings = append(data.Findings, AliasFinding{
|
||||||
|
Code: "alias_no_apex",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("could not locate zone apex of %s: %v", owner, err),
|
||||||
|
Subject: owner,
|
||||||
|
Hint: "Check that the parent delegation exists and that the zone is published.",
|
||||||
|
})
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
data.Apex = apex
|
||||||
|
data.AuthServers = servers
|
||||||
|
data.OwnerIsApex = lowerFQDN(owner) == lowerFQDN(apex)
|
||||||
|
|
||||||
|
// 2. Detect DNAME substitutions from owner up to apex (exclusive of apex).
|
||||||
|
data.DNAMESubstitutions = collectDNAMEs(ctx, servers, owner, apex)
|
||||||
|
|
||||||
|
// 3. Walk the CNAME/DNAME chain.
|
||||||
|
chainCtx := &chainCtx{
|
||||||
|
data: data,
|
||||||
|
maxLen: maxChain,
|
||||||
|
minTTL: minTTL,
|
||||||
|
servers: servers,
|
||||||
|
apex: apex,
|
||||||
|
seenOwners: map[string]bool{},
|
||||||
|
recFallback: resolver,
|
||||||
|
followTarget: requireTarget,
|
||||||
|
}
|
||||||
|
chainCtx.walk(ctx, owner)
|
||||||
|
|
||||||
|
// 4. Apex checks (flattening, CNAME-at-apex coexistence).
|
||||||
|
if data.OwnerIsApex {
|
||||||
|
checkApex(ctx, data, servers, apex, allowApexCNAME, recognizeApex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Coexistence at owner (applies at any level, not just apex).
|
||||||
|
checkCoexistence(ctx, data, servers, owner, allowApexCNAME, recognizeApex)
|
||||||
|
|
||||||
|
// 6. DNSSEC checks.
|
||||||
|
checkDNSSEC(ctx, data, servers, apex, owner)
|
||||||
|
|
||||||
|
// 7. Chain-level validations (loops, length, TTL, target resolvability).
|
||||||
|
validateChain(data, requireTarget)
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOwner derives the FQDN to check from the auto-filled options. The
|
||||||
|
// "service" option takes precedence (it carries a dns.CNAME whose owner is
|
||||||
|
// authoritative); otherwise we fall back to subdomain + domain_name.
|
||||||
|
func resolveOwner(opts sdk.CheckerOptions) (string, error) {
|
||||||
|
if svcMsg, ok := sdk.GetOption[serviceMessage](opts, "service"); ok && len(svcMsg.Service) > 0 {
|
||||||
|
var c cnameService
|
||||||
|
if err := json.Unmarshal(svcMsg.Service, &c); err == nil && c.Record != nil && c.Record.Hdr.Name != "" {
|
||||||
|
return lowerFQDN(c.Record.Hdr.Name), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parent, _ := sdk.GetOption[string](opts, "domain_name")
|
||||||
|
sub, _ := sdk.GetOption[string](opts, "subdomain")
|
||||||
|
if parent == "" {
|
||||||
|
return "", fmt.Errorf("missing 'domain_name' option")
|
||||||
|
}
|
||||||
|
parent = strings.TrimSuffix(parent, ".")
|
||||||
|
if sub == "" || sub == "@" {
|
||||||
|
return lowerFQDN(parent), nil
|
||||||
|
}
|
||||||
|
sub = strings.TrimSuffix(sub, ".")
|
||||||
|
return lowerFQDN(sub + "." + parent), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// chainCtx carries the mutable state of a chain walk.
|
||||||
|
type chainCtx struct {
|
||||||
|
data *AliasData
|
||||||
|
maxLen int
|
||||||
|
minTTL uint32
|
||||||
|
servers []string
|
||||||
|
apex string
|
||||||
|
seenOwners map[string]bool
|
||||||
|
recFallback string
|
||||||
|
followTarget bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// walk follows CNAME/DNAME hops starting from name. It writes hops into
|
||||||
|
// data.Chain and may add findings.
|
||||||
|
func (c *chainCtx) walk(ctx context.Context, name string) {
|
||||||
|
current := lowerFQDN(name)
|
||||||
|
currentServers := c.servers
|
||||||
|
|
||||||
|
for i := 0; i <= c.maxLen+1; i++ {
|
||||||
|
if c.seenOwners[current] {
|
||||||
|
c.data.Findings = append(c.data.Findings, AliasFinding{
|
||||||
|
Code: "alias_loop",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("chain loops back to %s", current),
|
||||||
|
Subject: current,
|
||||||
|
Hint: "Break the loop by pointing the last CNAME at an A/AAAA-bearing name.",
|
||||||
|
})
|
||||||
|
c.data.FinalTarget = current
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.seenOwners[current] = true
|
||||||
|
|
||||||
|
if i > c.maxLen {
|
||||||
|
c.data.Findings = append(c.data.Findings, AliasFinding{
|
||||||
|
Code: "alias_chain_too_long",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("chain exceeds %d hops at %s; many resolvers will give up", c.maxLen, current),
|
||||||
|
Subject: current,
|
||||||
|
Hint: "Flatten intermediate CNAMEs so that the chain is at most a few hops long.",
|
||||||
|
})
|
||||||
|
c.data.FinalTarget = current
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dns.Question{Name: current, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET}
|
||||||
|
r, server, err := c.queryFor(ctx, currentServers, q)
|
||||||
|
if err != nil {
|
||||||
|
c.data.Findings = append(c.data.Findings, AliasFinding{
|
||||||
|
Code: "alias_query_failed",
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: fmt.Sprintf("CNAME query for %s failed: %v", current, err),
|
||||||
|
Subject: current,
|
||||||
|
})
|
||||||
|
c.data.FinalTarget = current
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Rcode != dns.RcodeSuccess {
|
||||||
|
c.data.Rcode = rcodeText(r.Rcode)
|
||||||
|
c.data.Findings = append(c.data.Findings, AliasFinding{
|
||||||
|
Code: "alias_rcode",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("server answered %s for %s", c.data.Rcode, current),
|
||||||
|
Subject: current,
|
||||||
|
Hint: "Ensure the zone publishes the expected record; NXDOMAIN/SERVFAIL mid-chain breaks the alias.",
|
||||||
|
})
|
||||||
|
c.data.FinalTarget = current
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cname, synthesizedFromDNAME, ttl := extractCNAME(r, current)
|
||||||
|
if cname == "" {
|
||||||
|
// No CNAME at this name: terminal hop, resolve A/AAAA.
|
||||||
|
c.data.Chain = append(c.data.Chain, ChainHop{
|
||||||
|
Owner: current,
|
||||||
|
Kind: KindTarget,
|
||||||
|
Server: server,
|
||||||
|
})
|
||||||
|
c.data.FinalTarget = current
|
||||||
|
c.resolveFinal(ctx, current, currentServers)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target := lowerFQDN(cname)
|
||||||
|
kind := KindCNAME
|
||||||
|
if synthesizedFromDNAME {
|
||||||
|
kind = KindDNAME
|
||||||
|
}
|
||||||
|
c.data.Chain = append(c.data.Chain, ChainHop{
|
||||||
|
Owner: current,
|
||||||
|
Kind: kind,
|
||||||
|
Target: target,
|
||||||
|
TTL: ttl,
|
||||||
|
Server: server,
|
||||||
|
Synthesized: synthesizedFromDNAME,
|
||||||
|
})
|
||||||
|
if ttl < c.minTTL {
|
||||||
|
c.data.Findings = append(c.data.Findings, AliasFinding{
|
||||||
|
Code: "alias_low_ttl",
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: fmt.Sprintf("hop %s → %s has TTL %ds (< %d)", current, target, ttl, c.minTTL),
|
||||||
|
Subject: current,
|
||||||
|
Hint: "Raise the CNAME TTL to improve cache efficiency (5-15 minutes is a common floor).",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-evaluate servers for the next hop: if target leaves the apex,
|
||||||
|
// we need its own authoritative servers. Out-of-zone targets are
|
||||||
|
// resolved via the system resolver (recursive path).
|
||||||
|
if isSubdomain(target, c.apex) {
|
||||||
|
currentServers = c.servers
|
||||||
|
} else {
|
||||||
|
zone, _, _ := findApex(ctx, target, c.recFallback)
|
||||||
|
ns, err := resolveZoneNSAddrs(ctx, zone)
|
||||||
|
if err != nil || len(ns) == 0 {
|
||||||
|
currentServers = []string{c.recFallback}
|
||||||
|
} else {
|
||||||
|
currentServers = ns
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryFor sends q, retrying via the recursive resolver if the authoritative
|
||||||
|
// set is empty (useful for foreign targets).
|
||||||
|
func (c *chainCtx) queryFor(ctx context.Context, servers []string, q dns.Question) (*dns.Msg, string, error) {
|
||||||
|
if len(servers) == 0 {
|
||||||
|
r, err := recursiveExchange(ctx, c.recFallback, q)
|
||||||
|
return r, c.recFallback, err
|
||||||
|
}
|
||||||
|
return queryAtAuth(ctx, "", servers, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCNAME returns the first CNAME target matched for owner, and reports
|
||||||
|
// whether it was synthesized from a DNAME present in the same response.
|
||||||
|
func extractCNAME(r *dns.Msg, owner string) (target string, fromDNAME bool, ttl uint32) {
|
||||||
|
for _, rr := range r.Answer {
|
||||||
|
if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(dns.Fqdn(c.Hdr.Name), dns.Fqdn(owner)) {
|
||||||
|
target = c.Target
|
||||||
|
ttl = c.Hdr.Ttl
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
return "", false, 0
|
||||||
|
}
|
||||||
|
for _, rr := range r.Answer {
|
||||||
|
if _, ok := rr.(*dns.DNAME); ok {
|
||||||
|
fromDNAME = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFinal fetches A/AAAA of the final target and records them.
|
||||||
|
func (c *chainCtx) resolveFinal(ctx context.Context, name string, servers []string) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var finalA, finalAAAA []string
|
||||||
|
var rcode string
|
||||||
|
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
q := dns.Question{Name: dns.Fqdn(name), Qtype: dns.TypeA, Qclass: dns.ClassINET}
|
||||||
|
var r *dns.Msg
|
||||||
|
var err error
|
||||||
|
if len(servers) > 0 {
|
||||||
|
r, _, err = queryAtAuth(ctx, "", servers, q)
|
||||||
|
} else {
|
||||||
|
r, err = recursiveExchange(ctx, c.recFallback, q)
|
||||||
|
}
|
||||||
|
if err == nil && r != nil {
|
||||||
|
if r.Rcode != dns.RcodeSuccess {
|
||||||
|
rcode = rcodeText(r.Rcode)
|
||||||
|
}
|
||||||
|
for _, rr := range r.Answer {
|
||||||
|
if a, ok := rr.(*dns.A); ok {
|
||||||
|
finalA = append(finalA, a.A.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
q := dns.Question{Name: dns.Fqdn(name), Qtype: dns.TypeAAAA, Qclass: dns.ClassINET}
|
||||||
|
var r *dns.Msg
|
||||||
|
var err error
|
||||||
|
if len(servers) > 0 {
|
||||||
|
r, _, err = queryAtAuth(ctx, "", servers, q)
|
||||||
|
} else {
|
||||||
|
r, err = recursiveExchange(ctx, c.recFallback, q)
|
||||||
|
}
|
||||||
|
if err == nil && r != nil {
|
||||||
|
for _, rr := range r.Answer {
|
||||||
|
if aaaa, ok := rr.(*dns.AAAA); ok {
|
||||||
|
finalAAAA = append(finalAAAA, aaaa.AAAA.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
c.data.FinalA = append(c.data.FinalA, finalA...)
|
||||||
|
c.data.FinalAAAA = append(c.data.FinalAAAA, finalAAAA...)
|
||||||
|
if rcode != "" {
|
||||||
|
c.data.Rcode = rcode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectDNAMEs queries every label from owner up to (but excluding) apex for
|
||||||
|
// a DNAME record, returning any substitutions found.
|
||||||
|
func collectDNAMEs(ctx context.Context, servers []string, owner, apex string) []ChainHop {
|
||||||
|
labels := dns.SplitDomainName(owner)
|
||||||
|
apexLabels := dns.SplitDomainName(apex)
|
||||||
|
stop := max(len(labels)-len(apexLabels), 0)
|
||||||
|
|
||||||
|
results := make([][]ChainHop, stop)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(stop)
|
||||||
|
for i := range stop {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
name := dns.Fqdn(strings.Join(labels[i:], "."))
|
||||||
|
q := dns.Question{Name: name, Qtype: dns.TypeDNAME, Qclass: dns.ClassINET}
|
||||||
|
r, server, err := queryAtAuth(ctx, "", servers, q)
|
||||||
|
if err != nil || r == nil || r.Rcode != dns.RcodeSuccess {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, rr := range r.Answer {
|
||||||
|
if d, ok := rr.(*dns.DNAME); ok {
|
||||||
|
results[i] = append(results[i], ChainHop{
|
||||||
|
Owner: lowerFQDN(d.Hdr.Name),
|
||||||
|
Kind: KindDNAME,
|
||||||
|
Target: lowerFQDN(d.Target),
|
||||||
|
TTL: d.Hdr.Ttl,
|
||||||
|
Server: server,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
var out []ChainHop
|
||||||
|
for _, hops := range results {
|
||||||
|
out = append(out, hops...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkApex verifies that a CNAME at apex does not break SOA/NS, and
|
||||||
|
// detects ALIAS/ANAME provider-side flattening.
|
||||||
|
func checkApex(ctx context.Context, data *AliasData, servers []string, apex string, allowApexCNAME, recognizeApex bool) {
|
||||||
|
// Collect A/AAAA at apex.
|
||||||
|
var hasA bool
|
||||||
|
var hasAMu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
q := dns.Question{Name: apex, Qtype: qt, Qclass: dns.ClassINET}
|
||||||
|
r, _, err := queryAtAuth(ctx, "", servers, q)
|
||||||
|
if err != nil || r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, rr := range r.Answer {
|
||||||
|
switch rr.(type) {
|
||||||
|
case *dns.A, *dns.AAAA:
|
||||||
|
hasAMu.Lock()
|
||||||
|
hasA = true
|
||||||
|
hasAMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// CNAME at apex?
|
||||||
|
hasCNAME := false
|
||||||
|
for _, h := range data.Chain {
|
||||||
|
if h.Kind == KindCNAME && lowerFQDN(h.Owner) == lowerFQDN(apex) {
|
||||||
|
hasCNAME = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasCNAME {
|
||||||
|
sev := SeverityCrit
|
||||||
|
if allowApexCNAME {
|
||||||
|
sev = SeverityWarn
|
||||||
|
}
|
||||||
|
data.Findings = append(data.Findings, AliasFinding{
|
||||||
|
Code: "alias_cname_at_apex",
|
||||||
|
Severity: sev,
|
||||||
|
Message: fmt.Sprintf("CNAME at apex %s conflicts with the SOA/NS records a zone apex must carry (RFC 1912 §2.4)", apex),
|
||||||
|
Subject: apex,
|
||||||
|
Hint: "Use the provider's ALIAS/ANAME flattening, an HTTP redirect, or move content to a sub-label such as www.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasA && !hasCNAME {
|
||||||
|
// A present at apex alongside SOA/NS, classic ALIAS/ANAME flattening.
|
||||||
|
data.ApexFlattening = true
|
||||||
|
if recognizeApex {
|
||||||
|
data.Findings = append(data.Findings, AliasFinding{
|
||||||
|
Code: "alias_apex_flattening",
|
||||||
|
Severity: SeverityInfo,
|
||||||
|
Message: fmt.Sprintf("apex %s serves A/AAAA directly (provider-side ALIAS/ANAME flattening)", apex),
|
||||||
|
Subject: apex,
|
||||||
|
Hint: "Keep the upstream target's TTL in mind: apex A/AAAA will only update as fast as the provider re-flattens.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCoexistence verifies that a CNAME at owner is the only record type
|
||||||
|
// present (RFC 1034 §3.6.2, RFC 2181 §10.1).
|
||||||
|
func checkCoexistence(ctx context.Context, data *AliasData, servers []string, owner string, allowApexCNAME, recognizeApex bool) {
|
||||||
|
hasCNAME := false
|
||||||
|
for _, h := range data.Chain {
|
||||||
|
if h.Kind == KindCNAME && lowerFQDN(h.Owner) == lowerFQDN(owner) {
|
||||||
|
hasCNAME = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasCNAME {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query a handful of common sibling types at owner.
|
||||||
|
siblings := []uint16{
|
||||||
|
dns.TypeA, dns.TypeAAAA, dns.TypeMX, dns.TypeTXT,
|
||||||
|
dns.TypeNS, dns.TypeSRV, dns.TypeCAA,
|
||||||
|
}
|
||||||
|
seen := map[string]uint32{}
|
||||||
|
var seenMu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(len(siblings))
|
||||||
|
for _, qt := range siblings {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
q := dns.Question{Name: owner, Qtype: qt, Qclass: dns.ClassINET}
|
||||||
|
r, _, err := queryAtAuth(ctx, "", servers, q)
|
||||||
|
if err != nil || r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A synthesized CNAME from DNAME will be present in Answer for any
|
||||||
|
// type; only count answers whose owner matches and whose type is qt.
|
||||||
|
for _, rr := range r.Answer {
|
||||||
|
if rr.Header().Rrtype != qt {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(dns.Fqdn(rr.Header().Name), dns.Fqdn(owner)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenMu.Lock()
|
||||||
|
seen[dns.TypeToString[qt]] = rr.Header().Ttl
|
||||||
|
seenMu.Unlock()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Apex with ALIAS/ANAME flattening is a known exception when requested.
|
||||||
|
isApex := lowerFQDN(owner) == lowerFQDN(data.Apex)
|
||||||
|
|
||||||
|
for t, ttl := range seen {
|
||||||
|
// A/AAAA at apex alongside a CNAME is impossible in a standard zone;
|
||||||
|
// a provider may still serve it through flattening. Still report it
|
||||||
|
// as critical, two different owners cannot legally exist.
|
||||||
|
if isApex && (t == "A" || t == "AAAA") && recognizeApex && data.ApexFlattening {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sev := SeverityCrit
|
||||||
|
if isApex && allowApexCNAME {
|
||||||
|
sev = SeverityWarn
|
||||||
|
}
|
||||||
|
data.Coexisting = append(data.Coexisting, CoexistingRRset{Type: t, TTL: ttl})
|
||||||
|
data.Findings = append(data.Findings, AliasFinding{
|
||||||
|
Code: "alias_coexisting_rrset",
|
||||||
|
Severity: sev,
|
||||||
|
Message: fmt.Sprintf("%s and CNAME both exist at %s (RFC 1034 §3.6.2 / RFC 2181 §10.1)", t, owner),
|
||||||
|
Subject: owner,
|
||||||
|
Hint: "Remove the sibling record or move it under a different label; a name cannot simultaneously carry a CNAME and other data.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDNSSEC verifies that, if the zone is signed, the CNAME at owner is
|
||||||
|
// properly signed (RRSIG covers it).
|
||||||
|
func checkDNSSEC(ctx context.Context, data *AliasData, servers []string, apex, owner string) {
|
||||||
|
qk := dns.Question{Name: apex, Qtype: dns.TypeDNSKEY, Qclass: dns.ClassINET}
|
||||||
|
r, _, err := queryAtAuth(ctx, "tcp", servers, qk)
|
||||||
|
if err != nil || r == nil || r.Rcode != dns.RcodeSuccess {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signed := false
|
||||||
|
for _, rr := range r.Answer {
|
||||||
|
if _, ok := rr.(*dns.DNSKEY); ok {
|
||||||
|
signed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.ZoneSigned = signed
|
||||||
|
if !signed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query CNAME with DO; check for an RRSIG covering it.
|
||||||
|
q := dns.Question{Name: owner, Qtype: dns.TypeCNAME, Qclass: dns.ClassINET}
|
||||||
|
r, _, err = queryAtAuth(ctx, "tcp", servers, q)
|
||||||
|
if err != nil || r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sawCNAME := false
|
||||||
|
sawSig := false
|
||||||
|
for _, rr := range r.Answer {
|
||||||
|
switch v := rr.(type) {
|
||||||
|
case *dns.CNAME:
|
||||||
|
sawCNAME = true
|
||||||
|
case *dns.RRSIG:
|
||||||
|
if v.TypeCovered == dns.TypeCNAME {
|
||||||
|
sawSig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sawCNAME {
|
||||||
|
data.CNAMESigned = sawSig
|
||||||
|
if !sawSig {
|
||||||
|
data.Findings = append(data.Findings, AliasFinding{
|
||||||
|
Code: "alias_cname_not_signed",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("zone %s is DNSSEC-signed but CNAME at %s has no RRSIG", apex, owner),
|
||||||
|
Subject: owner,
|
||||||
|
Hint: "Re-sign the zone or verify your signer covers the alias RRset; unsigned answers in a signed zone SERVFAIL at validating resolvers.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateChain enforces global chain invariants.
|
||||||
|
func validateChain(data *AliasData, requireTarget bool) {
|
||||||
|
if len(data.Chain) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target resolvability.
|
||||||
|
if last := data.Chain[len(data.Chain)-1]; last.Kind == KindTarget {
|
||||||
|
if len(data.FinalA) == 0 && len(data.FinalAAAA) == 0 {
|
||||||
|
sev := SeverityWarn
|
||||||
|
if requireTarget {
|
||||||
|
sev = SeverityCrit
|
||||||
|
}
|
||||||
|
rcode := data.Rcode
|
||||||
|
if rcode == "" {
|
||||||
|
rcode = "no A/AAAA"
|
||||||
|
}
|
||||||
|
data.Findings = append(data.Findings, AliasFinding{
|
||||||
|
Code: "alias_target_unresolvable",
|
||||||
|
Severity: sev,
|
||||||
|
Message: fmt.Sprintf("final target %s does not resolve to an address (%s)", last.Owner, rcode),
|
||||||
|
Subject: last.Owner,
|
||||||
|
Hint: "Point the alias at a name that publishes at least one A or AAAA record, or fix the upstream zone.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple CNAME/DNAME kinds with same owner (malformed zone).
|
||||||
|
seen := map[string]int{}
|
||||||
|
for _, h := range data.Chain {
|
||||||
|
if h.Kind == KindCNAME || h.Kind == KindDNAME {
|
||||||
|
seen[h.Owner]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for o, n := range seen {
|
||||||
|
if n > 1 {
|
||||||
|
data.Findings = append(data.Findings, AliasFinding{
|
||||||
|
Code: "alias_multiple_records",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("%s carries %d CNAME/DNAME records in the chain; only one is legal per owner", o, n),
|
||||||
|
Subject: o,
|
||||||
|
Hint: "Keep a single CNAME per name; remove duplicates at the authoritative zone.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
checker/definition.go
Normal file
103
checker/definition.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the checker version reported in CheckerDefinition.Version.
|
||||||
|
var Version = "built-in"
|
||||||
|
|
||||||
|
// Definition returns the CheckerDefinition for the alias checker.
|
||||||
|
func Definition() *sdk.CheckerDefinition {
|
||||||
|
def := &sdk.CheckerDefinition{
|
||||||
|
ID: "alias",
|
||||||
|
Name: "CNAME / DNAME / ALIAS chain",
|
||||||
|
Version: Version,
|
||||||
|
Availability: sdk.CheckerAvailability{
|
||||||
|
ApplyToService: true,
|
||||||
|
ApplyToDomain: true,
|
||||||
|
ApplyToZone: true,
|
||||||
|
LimitToServices: []string{
|
||||||
|
"svcs.CNAME",
|
||||||
|
"svcs.SpecialCNAME",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ObservationKeys: []sdk.ObservationKey{ObservationKeyAlias},
|
||||||
|
Options: sdk.CheckerOptionsDocumentation{
|
||||||
|
UserOpts: []sdk.CheckerOptionDocumentation{
|
||||||
|
{
|
||||||
|
Id: "maxChainLength",
|
||||||
|
Type: "uint",
|
||||||
|
Label: "Maximum chain length",
|
||||||
|
Description: "Above this number of hops the chain is reported as critical. Most resolvers give up around 8-16.",
|
||||||
|
Default: float64(8),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "minTargetTTL",
|
||||||
|
Type: "uint",
|
||||||
|
Label: "Minimum TTL (seconds)",
|
||||||
|
Description: "Hops with a TTL below this threshold are flagged as a warning. Very short TTLs degrade cache performance.",
|
||||||
|
Default: float64(60),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "requireResolvableTarget",
|
||||||
|
Type: "bool",
|
||||||
|
Label: "Require resolvable target",
|
||||||
|
Description: "When enabled, a chain whose final target returns no A/AAAA is reported as critical (otherwise a warning).",
|
||||||
|
Default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "allowApexCNAME",
|
||||||
|
Type: "bool",
|
||||||
|
Label: "Allow CNAME at apex",
|
||||||
|
Description: "When enabled, a CNAME at a zone apex is only reported as warning. RFC 1912 forbids this, so leaving it off is strongly recommended.",
|
||||||
|
Default: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "recognizeApexFlattening",
|
||||||
|
Type: "bool",
|
||||||
|
Label: "Recognize ALIAS/ANAME flattening",
|
||||||
|
Description: "When enabled, providers that serve A/AAAA at the apex (ALIAS/ANAME pseudo-records) are reported as informational instead of a coexistence violation.",
|
||||||
|
Default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DomainOpts: []sdk.CheckerOptionDocumentation{
|
||||||
|
{
|
||||||
|
Id: "domain_name",
|
||||||
|
Label: "Parent domain name",
|
||||||
|
AutoFill: sdk.AutoFillDomainName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "subdomain",
|
||||||
|
Label: "Subdomain",
|
||||||
|
AutoFill: sdk.AutoFillSubdomain,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ServiceOpts: []sdk.CheckerOptionDocumentation{
|
||||||
|
{
|
||||||
|
Id: "service_type",
|
||||||
|
Label: "Service type",
|
||||||
|
AutoFill: sdk.AutoFillServiceType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "service",
|
||||||
|
Label: "Service",
|
||||||
|
AutoFill: sdk.AutoFillService,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Rules: []sdk.CheckRule{
|
||||||
|
Rule(),
|
||||||
|
},
|
||||||
|
HasHTMLReport: true,
|
||||||
|
Interval: &sdk.CheckIntervalSpec{
|
||||||
|
Min: 5 * time.Minute,
|
||||||
|
Max: 24 * time.Hour,
|
||||||
|
Default: 1 * time.Hour,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
def.BuildRulesInfo()
|
||||||
|
return def
|
||||||
|
}
|
||||||
157
checker/dns.go
Normal file
157
checker/dns.go
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
const dnsTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// dnsExchange sends a single query to an authoritative server (no RD).
|
||||||
|
func dnsExchange(ctx context.Context, proto, server string, q dns.Question, rd, edns bool) (*dns.Msg, error) {
|
||||||
|
client := dns.Client{Net: proto, Timeout: dnsTimeout}
|
||||||
|
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.Id = dns.Id()
|
||||||
|
m.Question = []dns.Question{q}
|
||||||
|
m.RecursionDesired = rd
|
||||||
|
if edns {
|
||||||
|
m.SetEdns0(4096, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
if d := time.Until(deadline); d > 0 && d < client.Timeout {
|
||||||
|
client.Timeout = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r, _, err := client.Exchange(m, server)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("nil response from %s", server)
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recursiveExchange sends a query via a recursive resolver (RD=1). Used for
|
||||||
|
// fallbacks: resolving NS addresses, following chains across foreign zones.
|
||||||
|
func recursiveExchange(ctx context.Context, server string, q dns.Question) (*dns.Msg, error) {
|
||||||
|
return dnsExchange(ctx, "", server, q, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemResolver returns the first configured resolver of the local system,
|
||||||
|
// falling back to a public one if none is configured.
|
||||||
|
func systemResolver() string {
|
||||||
|
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
|
||||||
|
if err != nil || len(cfg.Servers) == 0 {
|
||||||
|
return net.JoinHostPort("1.1.1.1", "53")
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(cfg.Servers[0], cfg.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostPort returns "host:port", stripping the trailing dot from FQDNs.
|
||||||
|
func hostPort(host, port string) string {
|
||||||
|
return net.JoinHostPort(strings.TrimSuffix(host, "."), port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findApex walks up the labels of fqdn until it finds a zone cut (SOA), using
|
||||||
|
// the system resolver. Returns the apex FQDN and the list of "host:53"
|
||||||
|
// authoritative servers for that zone.
|
||||||
|
func findApex(ctx context.Context, fqdn, resolver string) (apex string, servers []string, err error) {
|
||||||
|
labels := dns.SplitDomainName(fqdn)
|
||||||
|
for i := range labels {
|
||||||
|
candidate := dns.Fqdn(strings.Join(labels[i:], "."))
|
||||||
|
q := dns.Question{Name: candidate, Qtype: dns.TypeSOA, Qclass: dns.ClassINET}
|
||||||
|
r, rerr := recursiveExchange(ctx, resolver, q)
|
||||||
|
if rerr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Rcode != dns.RcodeSuccess {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasSOA := false
|
||||||
|
for _, rr := range r.Answer {
|
||||||
|
if _, ok := rr.(*dns.SOA); ok {
|
||||||
|
hasSOA = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasSOA {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
apex = candidate
|
||||||
|
servers, err = resolveZoneNSAddrs(ctx, apex)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if len(servers) == 0 {
|
||||||
|
return "", nil, fmt.Errorf("apex %s has no resolvable NS", apex)
|
||||||
|
}
|
||||||
|
return apex, servers, nil
|
||||||
|
}
|
||||||
|
return "", nil, fmt.Errorf("could not locate apex of %s", fqdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveZoneNSAddrs returns "host:53" entries for every NS of the zone.
|
||||||
|
func resolveZoneNSAddrs(ctx context.Context, zone string) ([]string, error) {
|
||||||
|
var resolver net.Resolver
|
||||||
|
nss, err := resolver.LookupNS(ctx, strings.TrimSuffix(zone, "."))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, ns := range nss {
|
||||||
|
addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(ns.Host, "."))
|
||||||
|
if err != nil || len(addrs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
out = append(out, hostPort(a, "53"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryAtAuth sends a query to the first reachable server of list.
|
||||||
|
func queryAtAuth(ctx context.Context, proto string, servers []string, q dns.Question) (*dns.Msg, string, error) {
|
||||||
|
var lastErr error
|
||||||
|
for _, s := range servers {
|
||||||
|
r, err := dnsExchange(ctx, proto, s, q, false, true)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return r, s, nil
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("no servers provided")
|
||||||
|
}
|
||||||
|
return nil, "", lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// rcodeText returns the textual name of an rcode or a fallback string.
|
||||||
|
func rcodeText(r int) string {
|
||||||
|
if s, ok := dns.RcodeToString[r]; ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("RCODE(%d)", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSubdomain reports whether child is equal to or sits under parent.
|
||||||
|
func isSubdomain(child, parent string) bool {
|
||||||
|
child = strings.ToLower(dns.Fqdn(child))
|
||||||
|
parent = strings.ToLower(dns.Fqdn(parent))
|
||||||
|
return child == parent || strings.HasSuffix(child, "."+parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lowerFQDN returns the canonical lowercase FQDN form of name.
|
||||||
|
func lowerFQDN(name string) string {
|
||||||
|
return strings.ToLower(dns.Fqdn(name))
|
||||||
|
}
|
||||||
63
checker/evaluate.go
Normal file
63
checker/evaluate.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Evaluate turns an AliasData into one CheckState per finding. When the run
|
||||||
|
// produced no findings, it returns a single StatusOK state describing the
|
||||||
|
// healthy alias.
|
||||||
|
func Evaluate(data *AliasData) []sdk.CheckState {
|
||||||
|
if len(data.Findings) == 0 {
|
||||||
|
msg := fmt.Sprintf("Alias chain for %s is healthy", data.Owner)
|
||||||
|
if data.FinalTarget != "" && data.FinalTarget != data.Owner {
|
||||||
|
msg = fmt.Sprintf("%s → %s resolves cleanly", data.Owner, data.FinalTarget)
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Message: msg,
|
||||||
|
Subject: data.Owner,
|
||||||
|
Meta: map[string]any{
|
||||||
|
"owner": data.Owner,
|
||||||
|
"apex": data.Apex,
|
||||||
|
"final_target": data.FinalTarget,
|
||||||
|
"final_a": data.FinalA,
|
||||||
|
"final_aaaa": data.FinalAAAA,
|
||||||
|
"chain_length": len(data.Chain),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]sdk.CheckState, 0, len(data.Findings))
|
||||||
|
for _, f := range data.Findings {
|
||||||
|
subject := f.Subject
|
||||||
|
if subject == "" {
|
||||||
|
subject = data.Owner
|
||||||
|
}
|
||||||
|
state := sdk.CheckState{
|
||||||
|
Status: severityToStatus(f.Severity),
|
||||||
|
Code: f.Code,
|
||||||
|
Message: f.Message,
|
||||||
|
Subject: subject,
|
||||||
|
}
|
||||||
|
if f.Hint != "" {
|
||||||
|
state.Meta = map[string]any{"hint": f.Hint}
|
||||||
|
}
|
||||||
|
out = append(out, state)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func severityToStatus(s Severity) sdk.Status {
|
||||||
|
switch s {
|
||||||
|
case SeverityCrit:
|
||||||
|
return sdk.StatusCrit
|
||||||
|
case SeverityWarn:
|
||||||
|
return sdk.StatusWarn
|
||||||
|
case SeverityInfo:
|
||||||
|
return sdk.StatusInfo
|
||||||
|
}
|
||||||
|
return sdk.StatusOK
|
||||||
|
}
|
||||||
53
checker/interactive.go
Normal file
53
checker/interactive.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
//go:build standalone
|
||||||
|
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenderForm exposes a single "name" input for the standalone /check route.
|
||||||
|
func (p *aliasProvider) RenderForm() []sdk.CheckerOptionField {
|
||||||
|
return []sdk.CheckerOptionField{
|
||||||
|
{
|
||||||
|
Id: "name",
|
||||||
|
Type: "string",
|
||||||
|
Label: "Domain name to check",
|
||||||
|
Placeholder: "alias.example.com",
|
||||||
|
Required: true,
|
||||||
|
Description: "Fully-qualified name carrying (or suspected of carrying) a CNAME / DNAME / ALIAS record.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseForm turns the submitted name into a minimal CheckerOptions set. We
|
||||||
|
// let the collector discover the apex on its own.
|
||||||
|
func (p *aliasProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("name is required")
|
||||||
|
}
|
||||||
|
name = strings.TrimSuffix(name, ".")
|
||||||
|
|
||||||
|
// Split into subdomain + parent-like domain_name. The collector accepts
|
||||||
|
// either representation; we hand both keys so rules that autoscope on
|
||||||
|
// domain_name still work.
|
||||||
|
parts := strings.SplitN(name, ".", 2)
|
||||||
|
sub := parts[0]
|
||||||
|
parent := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
parent = parts[1]
|
||||||
|
} else {
|
||||||
|
parent = name
|
||||||
|
sub = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return sdk.CheckerOptions{
|
||||||
|
"domain_name": parent,
|
||||||
|
"subdomain": sub,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
22
checker/provider.go
Normal file
22
checker/provider.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider returns a new alias observation provider.
|
||||||
|
func Provider() sdk.ObservationProvider {
|
||||||
|
return &aliasProvider{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type aliasProvider struct{}
|
||||||
|
|
||||||
|
func (p *aliasProvider) Key() sdk.ObservationKey {
|
||||||
|
return ObservationKeyAlias
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition implements sdk.CheckerDefinitionProvider so the SDK server can
|
||||||
|
// expose /definition without an extra argument.
|
||||||
|
func (p *aliasProvider) Definition() *sdk.CheckerDefinition {
|
||||||
|
return Definition()
|
||||||
|
}
|
||||||
421
checker/report.go
Normal file
421
checker/report.go
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetHTMLReport renders an HTML document summarizing the last alias run.
|
||||||
|
// Critical findings are surfaced in a dedicated top section with fix hints;
|
||||||
|
// the chain is visualized as a stepped list; the full findings list sits
|
||||||
|
// below as a detailed table.
|
||||||
|
func (p *aliasProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||||
|
var data AliasData
|
||||||
|
if raw := ctx.Data(); len(raw) > 0 {
|
||||||
|
if err := json.Unmarshal(raw, &data); err != nil {
|
||||||
|
return "", fmt.Errorf("parse alias data: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view := buildReportView(&data)
|
||||||
|
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
if err := reportTmpl.Execute(buf, view); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// topFailureCodes lists the findings that deserve a dedicated "fix this first"
|
||||||
|
// card at the top of the report. Order matters: it drives the visual order.
|
||||||
|
var topFailureCodes = []string{
|
||||||
|
"alias_cname_at_apex",
|
||||||
|
"alias_coexisting_rrset",
|
||||||
|
"alias_loop",
|
||||||
|
"alias_chain_too_long",
|
||||||
|
"alias_target_unresolvable",
|
||||||
|
"alias_rcode",
|
||||||
|
"alias_cname_not_signed",
|
||||||
|
"alias_multiple_records",
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportView is the template payload. We pre-compute everything the template
|
||||||
|
// needs so the template itself stays dumb.
|
||||||
|
type reportView struct {
|
||||||
|
Owner string
|
||||||
|
Apex string
|
||||||
|
FinalTarget string
|
||||||
|
FinalAddresses []string
|
||||||
|
OverallStatus string
|
||||||
|
OverallStatusText string
|
||||||
|
OverallClass string
|
||||||
|
ChainSteps []chainStep
|
||||||
|
DNAMEs []ChainHop
|
||||||
|
Coexisting []CoexistingRRset
|
||||||
|
ApexFlattening bool
|
||||||
|
ZoneSigned bool
|
||||||
|
CNAMESigned bool
|
||||||
|
TopFailures []topFailure
|
||||||
|
OtherFindings []AliasFinding
|
||||||
|
RawJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
type chainStep struct {
|
||||||
|
Index int
|
||||||
|
Owner string
|
||||||
|
Kind string
|
||||||
|
Target string
|
||||||
|
TTL uint32
|
||||||
|
Server string
|
||||||
|
IsLast bool
|
||||||
|
CSSKind string
|
||||||
|
}
|
||||||
|
|
||||||
|
type topFailure struct {
|
||||||
|
Code string
|
||||||
|
Title string
|
||||||
|
Severity string
|
||||||
|
Messages []string
|
||||||
|
Hint string
|
||||||
|
Subject string
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildReportView(data *AliasData) *reportView {
|
||||||
|
v := &reportView{
|
||||||
|
Owner: data.Owner,
|
||||||
|
Apex: data.Apex,
|
||||||
|
FinalTarget: data.FinalTarget,
|
||||||
|
DNAMEs: data.DNAMESubstitutions,
|
||||||
|
Coexisting: data.Coexisting,
|
||||||
|
ApexFlattening: data.ApexFlattening,
|
||||||
|
ZoneSigned: data.ZoneSigned,
|
||||||
|
CNAMESigned: data.CNAMESigned,
|
||||||
|
}
|
||||||
|
|
||||||
|
v.FinalAddresses = append(v.FinalAddresses, data.FinalA...)
|
||||||
|
v.FinalAddresses = append(v.FinalAddresses, data.FinalAAAA...)
|
||||||
|
|
||||||
|
// Overall status = worst severity among findings.
|
||||||
|
worst := ""
|
||||||
|
for _, f := range data.Findings {
|
||||||
|
switch f.Severity {
|
||||||
|
case SeverityCrit:
|
||||||
|
worst = "crit"
|
||||||
|
case SeverityWarn:
|
||||||
|
if worst != "crit" {
|
||||||
|
worst = "warn"
|
||||||
|
}
|
||||||
|
case SeverityInfo:
|
||||||
|
if worst == "" {
|
||||||
|
worst = "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch worst {
|
||||||
|
case "crit":
|
||||||
|
v.OverallStatus = "crit"
|
||||||
|
v.OverallStatusText = "Critical issues detected"
|
||||||
|
v.OverallClass = "status-crit"
|
||||||
|
case "warn":
|
||||||
|
v.OverallStatus = "warn"
|
||||||
|
v.OverallStatusText = "Warnings detected"
|
||||||
|
v.OverallClass = "status-warn"
|
||||||
|
case "info":
|
||||||
|
v.OverallStatus = "info"
|
||||||
|
v.OverallStatusText = "Informational notes"
|
||||||
|
v.OverallClass = "status-info"
|
||||||
|
default:
|
||||||
|
v.OverallStatus = "ok"
|
||||||
|
v.OverallStatusText = "Alias chain healthy"
|
||||||
|
v.OverallClass = "status-ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain steps.
|
||||||
|
for i, h := range data.Chain {
|
||||||
|
step := chainStep{
|
||||||
|
Index: i + 1,
|
||||||
|
Owner: h.Owner,
|
||||||
|
Kind: string(h.Kind),
|
||||||
|
Target: h.Target,
|
||||||
|
TTL: h.TTL,
|
||||||
|
Server: h.Server,
|
||||||
|
IsLast: i == len(data.Chain)-1,
|
||||||
|
}
|
||||||
|
switch h.Kind {
|
||||||
|
case KindCNAME:
|
||||||
|
step.CSSKind = "kind-cname"
|
||||||
|
case KindDNAME:
|
||||||
|
step.CSSKind = "kind-dname"
|
||||||
|
case KindALIAS:
|
||||||
|
step.CSSKind = "kind-alias"
|
||||||
|
case KindTarget:
|
||||||
|
step.CSSKind = "kind-target"
|
||||||
|
}
|
||||||
|
v.ChainSteps = append(v.ChainSteps, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucket findings: top failures (grouped by code) vs. the rest.
|
||||||
|
topIndex := map[string]int{}
|
||||||
|
for i, c := range topFailureCodes {
|
||||||
|
topIndex[c] = i
|
||||||
|
}
|
||||||
|
topMap := map[string]*topFailure{}
|
||||||
|
for _, f := range data.Findings {
|
||||||
|
if _, isTop := topIndex[f.Code]; isTop {
|
||||||
|
tf, ok := topMap[f.Code]
|
||||||
|
if !ok {
|
||||||
|
tf = &topFailure{
|
||||||
|
Code: f.Code,
|
||||||
|
Title: titleFor(f.Code),
|
||||||
|
Severity: string(f.Severity),
|
||||||
|
Hint: f.Hint,
|
||||||
|
Subject: f.Subject,
|
||||||
|
}
|
||||||
|
topMap[f.Code] = tf
|
||||||
|
}
|
||||||
|
tf.Messages = append(tf.Messages, f.Message)
|
||||||
|
if tf.Hint == "" {
|
||||||
|
tf.Hint = f.Hint
|
||||||
|
}
|
||||||
|
// Escalate severity to the worst among grouped findings.
|
||||||
|
if severityRank(f.Severity) > severityRank(Severity(tf.Severity)) {
|
||||||
|
tf.Severity = string(f.Severity)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v.OtherFindings = append(v.OtherFindings, f)
|
||||||
|
}
|
||||||
|
for _, code := range topFailureCodes {
|
||||||
|
if tf, ok := topMap[code]; ok {
|
||||||
|
v.TopFailures = append(v.TopFailures, *tf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := json.MarshalIndent(data, "", " "); err == nil {
|
||||||
|
v.RawJSON = string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func severityRank(s Severity) int {
|
||||||
|
switch s {
|
||||||
|
case SeverityCrit:
|
||||||
|
return 3
|
||||||
|
case SeverityWarn:
|
||||||
|
return 2
|
||||||
|
case SeverityInfo:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleFor(code string) string {
|
||||||
|
switch code {
|
||||||
|
case "alias_cname_at_apex":
|
||||||
|
return "CNAME at zone apex"
|
||||||
|
case "alias_coexisting_rrset":
|
||||||
|
return "CNAME coexists with other records"
|
||||||
|
case "alias_loop":
|
||||||
|
return "Alias chain loops"
|
||||||
|
case "alias_chain_too_long":
|
||||||
|
return "Alias chain too long"
|
||||||
|
case "alias_target_unresolvable":
|
||||||
|
return "Target does not resolve"
|
||||||
|
case "alias_rcode":
|
||||||
|
return "Alias lookup error"
|
||||||
|
case "alias_cname_not_signed":
|
||||||
|
return "CNAME not DNSSEC-signed"
|
||||||
|
case "alias_multiple_records":
|
||||||
|
return "Multiple CNAME records at the same name"
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(code, "_", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var reportTmpl = template.Must(template.New("alias-report").Parse(reportTemplate))
|
||||||
|
|
||||||
|
// reportTemplate is the single-file HTML report. Styles are inlined so the
|
||||||
|
// report embeds cleanly in an iframe with no asset dependencies.
|
||||||
|
const reportTemplate = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Alias chain report — {{.Owner}}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ok: #1e9e5d;
|
||||||
|
--info: #3b82f6;
|
||||||
|
--warn: #d97706;
|
||||||
|
--crit: #dc2626;
|
||||||
|
--bg: #f7f7f8;
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--text: #111827;
|
||||||
|
--muted: #6b7280;
|
||||||
|
}
|
||||||
|
body { margin: 0; padding: 1.2rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); line-height: 1.45; }
|
||||||
|
h1 { font-size: 1.4rem; margin: 0 0 .3rem 0; }
|
||||||
|
h2 { font-size: 1.05rem; margin: 1.5rem 0 .6rem 0; border-bottom: 1px solid var(--border); padding-bottom: .25rem; }
|
||||||
|
h3 { font-size: .95rem; margin: 0 0 .35rem 0; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.status-banner { display: flex; align-items: center; justify-content: space-between; padding: .8rem 1rem; border-radius: 8px; color: #fff; margin-bottom: 1rem; }
|
||||||
|
.status-ok { background: var(--ok); }
|
||||||
|
.status-info { background: var(--info); }
|
||||||
|
.status-warn { background: var(--warn); }
|
||||||
|
.status-crit { background: var(--crit); }
|
||||||
|
.status-banner .label { font-weight: 600; font-size: 1rem; }
|
||||||
|
.status-banner .sub { opacity: .9; font-size: .85rem; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
|
||||||
|
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; }
|
||||||
|
.card .k { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; }
|
||||||
|
.card .v { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95rem; word-break: break-all; }
|
||||||
|
.top-failure { border-left: 4px solid var(--crit); background: #fef2f2; padding: .8rem 1rem; border-radius: 6px; margin-bottom: .6rem; }
|
||||||
|
.top-failure.severity-warn { border-color: var(--warn); background: #fffbeb; }
|
||||||
|
.top-failure.severity-info { border-color: var(--info); background: #eff6ff; }
|
||||||
|
.top-failure h3 { margin-bottom: .25rem; }
|
||||||
|
.top-failure ul { margin: .25rem 0 .35rem 1.1rem; padding: 0; font-size: .9rem; }
|
||||||
|
.top-failure .fix { background: rgba(0,0,0,.04); padding: .45rem .6rem; border-radius: 4px; font-size: .9rem; }
|
||||||
|
.top-failure .fix strong { display: block; color: var(--text); margin-bottom: .15rem; }
|
||||||
|
.chain { display: flex; flex-direction: column; gap: .4rem; }
|
||||||
|
.hop { display: flex; align-items: center; gap: .6rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .8rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; }
|
||||||
|
.hop .idx { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.hop .kind { padding: .1rem .45rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; }
|
||||||
|
.kind-cname { background: #3b82f6; }
|
||||||
|
.kind-dname { background: #8b5cf6; }
|
||||||
|
.kind-alias { background: #14b8a6; }
|
||||||
|
.kind-target { background: #1e9e5d; }
|
||||||
|
.hop .arrow { color: var(--muted); }
|
||||||
|
.hop .meta { color: var(--muted); font-size: .78rem; margin-left: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: .88rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||||
|
th, td { text-align: left; padding: .45rem .7rem; border-bottom: 1px solid var(--border); }
|
||||||
|
th { background: #f3f4f6; font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
.sev { display: inline-block; padding: .08rem .4rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; }
|
||||||
|
.sev-info { background: var(--info); }
|
||||||
|
.sev-warn { background: var(--warn); }
|
||||||
|
.sev-crit { background: var(--crit); }
|
||||||
|
details { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .8rem; }
|
||||||
|
details pre { max-height: 360px; overflow: auto; font-size: .8rem; }
|
||||||
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
.badge { display: inline-block; background: #e5e7eb; padding: .05rem .4rem; border-radius: 4px; font-size: .75rem; color: var(--text); }
|
||||||
|
.badge.on { background: #dcfce7; color: #14532d; }
|
||||||
|
.badge.off { background: #fee2e2; color: #7f1d1d; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="status-banner {{.OverallClass}}">
|
||||||
|
<div>
|
||||||
|
<div class="label">{{.OverallStatusText}}</div>
|
||||||
|
<div class="sub">for <code>{{.Owner}}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="sub">
|
||||||
|
{{if .FinalTarget}}final: <code>{{.FinalTarget}}</code>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><div class="k">Owner</div><div class="v">{{.Owner}}</div></div>
|
||||||
|
<div class="card"><div class="k">Apex</div><div class="v">{{if .Apex}}{{.Apex}}{{else}}—{{end}}</div></div>
|
||||||
|
<div class="card"><div class="k">Final target</div><div class="v">{{if .FinalTarget}}{{.FinalTarget}}{{else}}—{{end}}</div></div>
|
||||||
|
<div class="card"><div class="k">Final addresses</div>
|
||||||
|
<div class="v">{{if .FinalAddresses}}{{range .FinalAddresses}}{{.}}<br>{{end}}{{else}}<span class="muted">none</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><div class="k">DNSSEC</div>
|
||||||
|
<div class="v">
|
||||||
|
{{if .ZoneSigned}}<span class="badge on">signed zone</span>{{else}}<span class="badge off">unsigned</span>{{end}}
|
||||||
|
{{if .ZoneSigned}}{{if .CNAMESigned}}<span class="badge on">CNAME signed</span>{{else}}<span class="badge off">CNAME unsigned</span>{{end}}{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card"><div class="k">Apex flattening (ALIAS/ANAME)</div>
|
||||||
|
<div class="v">{{if .ApexFlattening}}<span class="badge on">detected</span>{{else}}<span class="muted">not detected</span>{{end}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .TopFailures}}
|
||||||
|
<h2>Fix these first</h2>
|
||||||
|
{{range .TopFailures}}
|
||||||
|
<div class="top-failure severity-{{.Severity}}">
|
||||||
|
<h3>{{.Title}} <span class="sev sev-{{.Severity}}">{{.Severity}}</span></h3>
|
||||||
|
<ul>
|
||||||
|
{{range .Messages}}<li>{{.}}</li>{{end}}
|
||||||
|
</ul>
|
||||||
|
{{if .Hint}}<div class="fix"><strong>How to fix</strong>{{.Hint}}</div>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ChainSteps}}
|
||||||
|
<h2>Resolution chain</h2>
|
||||||
|
<div class="chain">
|
||||||
|
{{range .ChainSteps}}
|
||||||
|
<div class="hop">
|
||||||
|
<span class="idx">#{{.Index}}</span>
|
||||||
|
<span class="kind {{.CSSKind}}">{{.Kind}}</span>
|
||||||
|
<code>{{.Owner}}</code>
|
||||||
|
{{if .Target}}<span class="arrow">→</span><code>{{.Target}}</code>{{end}}
|
||||||
|
<span class="meta">
|
||||||
|
{{if .TTL}}TTL {{.TTL}}s{{end}}
|
||||||
|
{{if .Server}} · {{.Server}}{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .DNAMEs}}
|
||||||
|
<h2>DNAME substitutions</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Owner</th><th>Target</th><th>TTL</th><th>Server</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .DNAMEs}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.Owner}}</code></td>
|
||||||
|
<td><code>{{.Target}}</code></td>
|
||||||
|
<td>{{.TTL}}</td>
|
||||||
|
<td><code>{{.Server}}</code></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Coexisting}}
|
||||||
|
<h2>Records coexisting with CNAME</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Type</th><th>TTL</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Coexisting}}
|
||||||
|
<tr><td><code>{{.Type}}</code></td><td>{{.TTL}}</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .OtherFindings}}
|
||||||
|
<h2>Additional findings</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Severity</th><th>Code</th><th>Subject</th><th>Message</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .OtherFindings}}
|
||||||
|
<tr>
|
||||||
|
<td><span class="sev sev-{{.Severity}}">{{.Severity}}</span></td>
|
||||||
|
<td><code>{{.Code}}</code></td>
|
||||||
|
<td><code>{{.Subject}}</code></td>
|
||||||
|
<td>{{.Message}}{{if .Hint}}<br><span class="muted">{{.Hint}}</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .RawJSON}}
|
||||||
|
<h2>Raw observation</h2>
|
||||||
|
<details><summary class="muted">Show raw JSON</summary><pre>{{.RawJSON}}</pre></details>
|
||||||
|
{{end}}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
46
checker/rule.go
Normal file
46
checker/rule.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule returns the alias check rule.
|
||||||
|
func Rule() sdk.CheckRule {
|
||||||
|
return &aliasRule{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type aliasRule struct{}
|
||||||
|
|
||||||
|
func (r *aliasRule) Name() string { return "alias_check" }
|
||||||
|
|
||||||
|
func (r *aliasRule) Description() string {
|
||||||
|
return "Verifies a CNAME / DNAME / ALIAS chain: coexistence, loops, length, target resolvability, DNSSEC coverage."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *aliasRule) ValidateOptions(opts sdk.CheckerOptions) error {
|
||||||
|
if v, ok := opts["maxChainLength"]; ok {
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("maxChainLength must be a number")
|
||||||
|
}
|
||||||
|
if f < 1 {
|
||||||
|
return fmt.Errorf("maxChainLength must be >= 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *aliasRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
var data AliasData
|
||||||
|
if err := obs.Get(ctx, ObservationKeyAlias, &data); err != nil {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Message: fmt.Sprintf("failed to get alias data: %v", err),
|
||||||
|
Code: "alias_error",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return Evaluate(&data)
|
||||||
|
}
|
||||||
127
checker/types.go
Normal file
127
checker/types.go
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ObservationKeyAlias is the observation key for alias data.
|
||||||
|
const ObservationKeyAlias = "alias"
|
||||||
|
|
||||||
|
// Severity classifies a finding emitted by the alias checker.
|
||||||
|
type Severity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeverityInfo Severity = "info"
|
||||||
|
SeverityWarn Severity = "warn"
|
||||||
|
SeverityCrit Severity = "crit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AliasKind identifies the flavour of indirection involved in a hop.
|
||||||
|
type AliasKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindCNAME AliasKind = "CNAME"
|
||||||
|
KindDNAME AliasKind = "DNAME"
|
||||||
|
KindALIAS AliasKind = "ALIAS" // provider-flattened apex alias (pseudo-record)
|
||||||
|
KindTarget AliasKind = "TARGET"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChainHop represents one step of the resolution chain.
|
||||||
|
type ChainHop struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Kind AliasKind `json:"kind"`
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
TTL uint32 `json:"ttl,omitempty"`
|
||||||
|
// Server is the authoritative server that answered for this hop.
|
||||||
|
Server string `json:"server,omitempty"`
|
||||||
|
// Synthesized is true when this hop is a CNAME synthesized from a DNAME.
|
||||||
|
Synthesized bool `json:"synthesized,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AliasFinding describes a single observation produced while running
|
||||||
|
// the alias testsuite.
|
||||||
|
type AliasFinding struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Severity Severity `json:"severity"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
// Subject names the owner/target the finding applies to.
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
// Hint is a short remediation suggestion, surfaced by the HTML report.
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoexistingRRset records an RRset that sits next to a CNAME at the same owner.
|
||||||
|
type CoexistingRRset struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
TTL uint32 `json:"ttl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AliasData is the observation payload persisted by the checker.
|
||||||
|
type AliasData struct {
|
||||||
|
// Owner is the name we started resolving from (FQDN).
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
|
||||||
|
// Apex is the zone apex of Owner (where SOA lives).
|
||||||
|
Apex string `json:"apex,omitempty"`
|
||||||
|
|
||||||
|
// AuthServers are the authoritative servers of the apex zone.
|
||||||
|
AuthServers []string `json:"auth_servers,omitempty"`
|
||||||
|
|
||||||
|
// Chain is the ordered list of hops from Owner down to the final
|
||||||
|
// resolvable (or unresolvable) target.
|
||||||
|
Chain []ChainHop `json:"chain,omitempty"`
|
||||||
|
|
||||||
|
// FinalTarget is the last name in the chain (possibly Owner itself when
|
||||||
|
// there is no indirection).
|
||||||
|
FinalTarget string `json:"final_target,omitempty"`
|
||||||
|
|
||||||
|
// FinalA / FinalAAAA hold the addresses that the chain ultimately resolves
|
||||||
|
// to. Empty when the target does not produce any address.
|
||||||
|
FinalA []string `json:"final_a,omitempty"`
|
||||||
|
FinalAAAA []string `json:"final_aaaa,omitempty"`
|
||||||
|
|
||||||
|
// Rcode is the textual rcode of the final lookup (e.g. "NOERROR",
|
||||||
|
// "NXDOMAIN", "SERVFAIL"); empty when not applicable.
|
||||||
|
Rcode string `json:"rcode,omitempty"`
|
||||||
|
|
||||||
|
// Coexisting lists RRsets that share the owner with a CNAME. Populated
|
||||||
|
// only when a CNAME is present at Owner.
|
||||||
|
Coexisting []CoexistingRRset `json:"coexisting,omitempty"`
|
||||||
|
|
||||||
|
// OwnerIsApex is true when the queried name is the zone apex.
|
||||||
|
OwnerIsApex bool `json:"owner_is_apex,omitempty"`
|
||||||
|
|
||||||
|
// ApexFlattening is true when the apex returns A/AAAA alongside SOA/NS
|
||||||
|
// (classic ALIAS/ANAME provider-side flattening).
|
||||||
|
ApexFlattening bool `json:"apex_flattening,omitempty"`
|
||||||
|
|
||||||
|
// ZoneSigned reports whether the apex has DNSKEY records (DNSSEC signed).
|
||||||
|
ZoneSigned bool `json:"zone_signed,omitempty"`
|
||||||
|
|
||||||
|
// CNAMESigned reports whether the CNAME hop at Owner carries an RRSIG.
|
||||||
|
CNAMESigned bool `json:"cname_signed,omitempty"`
|
||||||
|
|
||||||
|
// DNAMESubstitutions records any DNAME record encountered above Owner
|
||||||
|
// that rewrote the name during resolution.
|
||||||
|
DNAMESubstitutions []ChainHop `json:"dname_substitutions,omitempty"`
|
||||||
|
|
||||||
|
// Findings is the full list of issues produced by the run.
|
||||||
|
Findings []AliasFinding `json:"findings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// cnameService is the minimal local mirror of happyDomain's `svcs.CNAME` and
|
||||||
|
// `svcs.SpecialCNAME` types. Both carry a single *dns.CNAME under the key
|
||||||
|
// "cname". github.com/miekg/dns marshals it in the shape happyDomain uses.
|
||||||
|
type cnameService struct {
|
||||||
|
Record *dns.CNAME `json:"cname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceMessage is the minimal local mirror of happyDomain's ServiceMessage
|
||||||
|
// envelope.
|
||||||
|
type serviceMessage struct {
|
||||||
|
Type string `json:"_svctype"`
|
||||||
|
Domain string `json:"_domain"`
|
||||||
|
Service json.RawMessage `json:"Service"`
|
||||||
|
}
|
||||||
16
go.mod
Normal file
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.3.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.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A=
|
||||||
|
git.happydns.org/checker-sdk-go v1.3.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