Initial commit
This commit is contained in:
commit
7e0f29075e
21 changed files with 3112 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-delegation
|
||||
*.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-delegation .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-delegation /checker-delegation
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/checker-delegation"]
|
||||
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-delegation
|
||||
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
|
||||
27
NOTICE
Normal file
27
NOTICE
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
checker-delegation
|
||||
Copyright 2024-2026 The happyDomain Authors
|
||||
|
||||
This product is licensed under the MIT License (see LICENSE).
|
||||
|
||||
------------------------------------------------------------------------
|
||||
Third-party notices
|
||||
------------------------------------------------------------------------
|
||||
|
||||
This product depends on checker-sdk-go (https://git.happydns.org/happyDomain/checker-sdk-go),
|
||||
licensed under the Apache License, Version 2.0. The following NOTICE
|
||||
accompanies that dependency and is reproduced here as required by
|
||||
section 4(d) of the Apache License:
|
||||
|
||||
checker-sdk-go
|
||||
Copyright 2020-2026 The happyDomain Authors
|
||||
|
||||
This product includes software developed as part of the happyDomain
|
||||
project (https://happydomain.org).
|
||||
|
||||
Portions of this code were originally written for the happyDomain
|
||||
server (licensed under AGPL-3.0 and a commercial license) and are
|
||||
made available here under the Apache License, Version 2.0 to enable
|
||||
a permissively licensed ecosystem of checker plugins.
|
||||
|
||||
A copy of the Apache License, Version 2.0 is available at:
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
132
README.md
Normal file
132
README.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# checker-delegation
|
||||
|
||||
DNS delegation checker for [happyDomain](https://www.happydomain.org/).
|
||||
|
||||
Audits the delegation of a zone: NS consistency between parent and child,
|
||||
glue correctness, DS / DNSKEY hand-off, TCP reachability, SOA serial drift,
|
||||
and authoritativeness of each delegated server. Applies to services of type
|
||||
`abstract.Delegation`.
|
||||
|
||||
## Usage
|
||||
|
||||
### Standalone HTTP server
|
||||
|
||||
```bash
|
||||
# Build and run
|
||||
make
|
||||
./checker-delegation -listen :8080
|
||||
```
|
||||
|
||||
The server exposes:
|
||||
|
||||
- `GET /health`, health check
|
||||
- `POST /collect`, collect delegation observations (happyDomain external checker protocol)
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
make docker
|
||||
docker run -p 8080:8080 happydomain/checker-delegation
|
||||
```
|
||||
|
||||
### happyDomain plugin
|
||||
|
||||
```bash
|
||||
make plugin
|
||||
# produces checker-delegation.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 delegation checker to the URL of
|
||||
the running checker-delegation server (e.g.,
|
||||
`http://checker-delegation:8080`). happyDomain will delegate observation
|
||||
collection to this endpoint.
|
||||
|
||||
### Deployment
|
||||
|
||||
The `/collect` endpoint has no built-in authentication and will issue
|
||||
DNS queries to whatever name servers (and glue addresses) the parent
|
||||
zone advertises for the target. It is meant to run on a trusted network,
|
||||
reachable only by the happyDomain instance that drives it. Restrict
|
||||
access via a reverse proxy with authentication, a network ACL, or by
|
||||
binding the listener to a private interface; do not expose it directly
|
||||
to the public internet.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---------------------|------|---------|---------------------------------------------------------------------------------------------------|
|
||||
| `requireDS` | bool | `false` | When enabled, missing DS records at the parent are treated as critical (otherwise informational). |
|
||||
| `requireTCP` | bool | `true` | When enabled, name servers that fail to answer over TCP are reported as critical (otherwise warning). |
|
||||
| `minNameServers` | uint | `2` | Below this count, the delegation is reported as a warning (RFC 1034 recommends at least 2). |
|
||||
| `allowGlueMismatch` | bool | `false` | When disabled, glue/address mismatches between parent and child are reported as critical. |
|
||||
|
||||
## Protocol
|
||||
|
||||
### POST /collect
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"key": "delegation",
|
||||
"target": {"userId": "...", "domainId": "..."},
|
||||
"options": {
|
||||
"domain_name": "example.com.",
|
||||
"subdomain": "www",
|
||||
"service": { "_svctype": "abstract.Delegation", "Service": { "ns": [...], "ds": [...] } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"delegated_fqdn": "www.example.com.",
|
||||
"parent_zone": "example.com.",
|
||||
"parent_ns": ["a.iana-servers.net.", "b.iana-servers.net."],
|
||||
"advertised_ns": ["ns1.example.net.", "ns2.example.net."],
|
||||
"advertised_glue": {},
|
||||
"parent_ds": [],
|
||||
"child_serials": {"ns1.example.net.:53": 2026042401},
|
||||
"findings": [
|
||||
{
|
||||
"code": "delegation_ns_mismatch",
|
||||
"severity": "crit",
|
||||
"message": "NS RRset at parent does not match declared service: missing=[ns3.example.net] extra=[]",
|
||||
"server": "a.iana-servers.net.:53"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Findings carry a stable `code` (e.g. `delegation_lame`,
|
||||
`delegation_missing_glue`, `delegation_ds_mismatch`,
|
||||
`delegation_soa_serial_drift`, `delegation_dnskey_no_match`, …) so that
|
||||
downstream rules can match on them deterministically.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **MIT License** (see `LICENSE`), in
|
||||
line with the rest of the happyDomain checker ecosystem.
|
||||
|
||||
The third-party Apache-2.0 attributions for `checker-sdk-go` are recorded
|
||||
in `NOTICE` and must accompany any binary or source redistribution of this
|
||||
project.
|
||||
218
checker/collect.go
Normal file
218
checker/collect.go
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Collect probes the delegation and records raw facts only; judgment lives
|
||||
// in rule.go. Phase B queries delegated servers using only the NS names and
|
||||
// glue learned from the parent: the child zone is never trusted as a
|
||||
// source of truth.
|
||||
func (p *delegationProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
svc, err := loadService(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parentZone, subdomain := loadNames(opts)
|
||||
if parentZone == "" {
|
||||
return nil, fmt.Errorf("missing 'domain_name' option")
|
||||
}
|
||||
|
||||
parent := strings.TrimSuffix(parentZone, ".")
|
||||
sub := strings.TrimSuffix(subdomain, ".")
|
||||
var delegatedFQDN string
|
||||
if sub == "" {
|
||||
delegatedFQDN = dns.Fqdn(parent)
|
||||
} else {
|
||||
delegatedFQDN = dns.Fqdn(sub + "." + parent)
|
||||
}
|
||||
|
||||
data := &DelegationData{
|
||||
DelegatedFQDN: delegatedFQDN,
|
||||
ParentZone: dns.Fqdn(parentZone),
|
||||
DeclaredNS: normalizeNSList(svc.NameServers),
|
||||
}
|
||||
for _, d := range svc.DS {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
data.DeclaredDS = append(data.DeclaredDS, NewDSRecord(d))
|
||||
}
|
||||
|
||||
_, parentServers, err := findParentZone(ctx, delegatedFQDN, parentZone)
|
||||
if err != nil {
|
||||
data.ParentDiscoveryError = err.Error()
|
||||
return data, nil
|
||||
}
|
||||
data.ParentNS = parentServers
|
||||
|
||||
// Phase A: per-parent observations, no judgment.
|
||||
for _, ps := range parentServers {
|
||||
view := ParentView{Server: ps}
|
||||
|
||||
ns, glue, _, qerr := queryDelegation(ctx, ps, delegatedFQDN)
|
||||
if qerr != nil {
|
||||
view.UDPNSError = qerr.Error()
|
||||
} else {
|
||||
view.NS = ns
|
||||
view.Glue = glue
|
||||
}
|
||||
|
||||
if terr := queryDelegationTCP(ctx, ps, delegatedFQDN); terr != nil {
|
||||
view.TCPNSError = terr.Error()
|
||||
}
|
||||
|
||||
dsRRs, sigs, dserr := queryDS(ctx, ps, delegatedFQDN)
|
||||
if dserr != nil {
|
||||
view.DSQueryError = dserr.Error()
|
||||
} else {
|
||||
for _, d := range dsRRs {
|
||||
view.DS = append(view.DS, NewDSRecord(d))
|
||||
}
|
||||
for _, sig := range sigs {
|
||||
view.DSRRSIGs = append(view.DSRRSIGs, DSRRSIGObservation{
|
||||
Inception: sig.Inception,
|
||||
Expiration: sig.Expiration,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
data.ParentViews = append(data.ParentViews, view)
|
||||
}
|
||||
|
||||
// If no parent answered with an NS RRset, skip Phase B; rules flag the gap.
|
||||
var primary *ParentView
|
||||
for i := range data.ParentViews {
|
||||
if data.ParentViews[i].UDPNSError == "" && len(data.ParentViews[i].NS) > 0 {
|
||||
primary = &data.ParentViews[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if primary == nil {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Phase B: per-child observations, seeded only from parent data.
|
||||
for _, nsName := range primary.NS {
|
||||
child := ChildNSView{NSName: nsName}
|
||||
addrs := primary.Glue[nsName]
|
||||
if len(addrs) == 0 {
|
||||
// Out-of-bailiwick: no glue expected, fall back to the system resolver.
|
||||
resolved, rerr := resolveHost(ctx, nsName)
|
||||
if rerr != nil {
|
||||
child.ResolveError = rerr.Error()
|
||||
data.Children = append(data.Children, child)
|
||||
continue
|
||||
}
|
||||
addrs = resolved
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
srv := hostPort(addr, "53")
|
||||
av := ChildAddressView{Address: addr, Server: srv}
|
||||
|
||||
soa, aa, qerr := querySOA(ctx, "", srv, delegatedFQDN)
|
||||
if qerr != nil {
|
||||
av.UDPError = qerr.Error()
|
||||
av.Authoritative = aa
|
||||
child.Addresses = append(child.Addresses, av)
|
||||
continue
|
||||
}
|
||||
av.Authoritative = aa
|
||||
if soa != nil {
|
||||
av.SOASerial = soa.Serial
|
||||
av.SOASerialKnown = true
|
||||
}
|
||||
|
||||
if _, _, terr := querySOA(ctx, "tcp", srv, delegatedFQDN); terr != nil {
|
||||
av.TCPError = terr.Error()
|
||||
}
|
||||
|
||||
childNS, nerr := queryNSAt(ctx, srv, delegatedFQDN)
|
||||
if nerr != nil {
|
||||
av.ChildNSError = nerr.Error()
|
||||
} else {
|
||||
av.ChildNS = childNS
|
||||
}
|
||||
|
||||
if isInBailiwick(nsName, delegatedFQDN) {
|
||||
addrsAt, _ := queryAddrsAt(ctx, srv, nsName)
|
||||
av.ChildGlueAddrs = addrsAt
|
||||
}
|
||||
|
||||
// DNSKEY is only useful when there's a parent DS to match against.
|
||||
parentHasDS := false
|
||||
for _, pv := range data.ParentViews {
|
||||
if len(pv.DS) > 0 {
|
||||
parentHasDS = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if parentHasDS {
|
||||
keys, kerr := queryDNSKEY(ctx, srv, delegatedFQDN)
|
||||
if kerr != nil {
|
||||
av.DNSKEYError = kerr.Error()
|
||||
} else {
|
||||
for _, k := range keys {
|
||||
av.DNSKEYs = append(av.DNSKEYs, NewDNSKEYRecord(k))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.Addresses = append(child.Addresses, av)
|
||||
}
|
||||
|
||||
data.Children = append(data.Children, child)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// queryDelegationTCP only reports reachability; the payload was already
|
||||
// captured over UDP.
|
||||
func queryDelegationTCP(ctx context.Context, parentServer, fqdn string) error {
|
||||
q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET}
|
||||
msg, err := dnsExchange(ctx, "tcp", parentServer, q, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if msg.Rcode != dns.RcodeSuccess {
|
||||
return fmt.Errorf("parent answered %s", dns.RcodeToString[msg.Rcode])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadService decodes the "service" option into a minimal local type to
|
||||
// avoid pulling in the full happyDomain server module.
|
||||
func loadService(opts sdk.CheckerOptions) (*delegationService, error) {
|
||||
svc, ok := sdk.GetOption[serviceMessage](opts, "service")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing 'service' option")
|
||||
}
|
||||
if svc.Type != "" && svc.Type != "abstract.Delegation" {
|
||||
return nil, fmt.Errorf("service is %s, expected abstract.Delegation", svc.Type)
|
||||
}
|
||||
var d delegationService
|
||||
if err := json.Unmarshal(svc.Service, &d); err != nil {
|
||||
return nil, fmt.Errorf("decoding delegation service: %w", err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func loadNames(opts sdk.CheckerOptions) (parentZone, subdomain string) {
|
||||
if v, ok := sdk.GetOption[string](opts, "domain_name"); ok {
|
||||
parentZone = v
|
||||
}
|
||||
if v, ok := sdk.GetOption[string](opts, "subdomain"); ok {
|
||||
subdomain = v
|
||||
}
|
||||
return
|
||||
}
|
||||
82
checker/definition.go
Normal file
82
checker/definition.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is overridden at link-time by CI: -ldflags "-X ...Version=1.2.3".
|
||||
var Version = "built-in"
|
||||
|
||||
func (p *delegationProvider) Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "delegation",
|
||||
Name: "DNS delegation",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.Delegation"},
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyDelegation},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
UserOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "requireDS",
|
||||
Type: "bool",
|
||||
Label: "Require DS at parent",
|
||||
Description: "When enabled, missing DS records at the parent are treated as a critical issue (otherwise informational).",
|
||||
Default: false,
|
||||
},
|
||||
{
|
||||
Id: "requireTCP",
|
||||
Type: "bool",
|
||||
Label: "Require DNS over TCP",
|
||||
Description: "When enabled, name servers that fail to answer over TCP are reported as critical (otherwise as warning).",
|
||||
Default: true,
|
||||
},
|
||||
{
|
||||
Id: "minNameServers",
|
||||
Type: "uint",
|
||||
Label: "Minimum number of name servers",
|
||||
Description: "Below this count, the delegation is reported as a warning (RFC 1034 recommends at least 2).",
|
||||
Default: float64(2),
|
||||
},
|
||||
{
|
||||
Id: "allowGlueMismatch",
|
||||
Type: "bool",
|
||||
Label: "Allow glue mismatches",
|
||||
Description: "When disabled, glue/address mismatches between parent and child are reported as critical.",
|
||||
Default: false,
|
||||
},
|
||||
},
|
||||
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",
|
||||
Label: "Service",
|
||||
AutoFill: sdk.AutoFillService,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: Rules(),
|
||||
HasHTMLReport: true,
|
||||
HasMetrics: true,
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 5 * time.Minute,
|
||||
Max: 24 * time.Hour,
|
||||
Default: 1 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
250
checker/dns.go
Normal file
250
checker/dns.go
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// year68 wraps RRSIG validity periods around 2^32 seconds, matching miekg.
|
||||
const year68 = int64(1 << 31)
|
||||
|
||||
const dnsTimeout = 5 * time.Second
|
||||
|
||||
// dnsExchange forces RecursionDesired off: this checker only talks to
|
||||
// authoritative servers, never recursors.
|
||||
func dnsExchange(ctx context.Context, proto, server string, q dns.Question, edns bool) (*dns.Msg, error) {
|
||||
client := dns.Client{Net: proto, Timeout: dnsTimeout}
|
||||
|
||||
m := new(dns.Msg)
|
||||
m.Id = dns.Id()
|
||||
m.Question = []dns.Question{q}
|
||||
m.RecursionDesired = false
|
||||
if edns {
|
||||
m.SetEdns0(4096, true)
|
||||
}
|
||||
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
if d := time.Until(deadline); d > 0 && d < client.Timeout {
|
||||
client.Timeout = d
|
||||
}
|
||||
}
|
||||
|
||||
r, _, err := client.ExchangeContext(ctx, m, server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("nil response from %s", server)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// hostPort brackets IPv6 literals so net.Dial accepts them.
|
||||
func hostPort(host, port string) string {
|
||||
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
|
||||
return "[" + host + "]:" + port
|
||||
}
|
||||
host = strings.TrimSuffix(host, ".")
|
||||
return host + ":" + port
|
||||
}
|
||||
|
||||
// resolveHost is the fallback path for out-of-bailiwick NS without glue.
|
||||
func resolveHost(ctx context.Context, host string) ([]string, error) {
|
||||
var resolver net.Resolver
|
||||
addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(host, "."))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return addrs, nil
|
||||
}
|
||||
|
||||
// findParentZone returns the parent zone and its authoritative servers.
|
||||
// hintParent skips the label walk: happyDomain already knows the parent.
|
||||
func findParentZone(ctx context.Context, fqdn, hintParent string) (zone string, servers []string, err error) {
|
||||
zone = dns.Fqdn(hintParent)
|
||||
if zone == "" || zone == "." {
|
||||
labels := dns.SplitDomainName(fqdn)
|
||||
if len(labels) == 0 {
|
||||
return "", nil, fmt.Errorf("cannot derive parent of %q", fqdn)
|
||||
}
|
||||
zone = dns.Fqdn(strings.Join(labels[1:], "."))
|
||||
}
|
||||
|
||||
servers, err = resolveZoneNSAddrs(ctx, zone)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("resolving NS of parent zone %q: %w", zone, err)
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
return "", nil, fmt.Errorf("parent zone %q has no resolvable NS", zone)
|
||||
}
|
||||
return zone, servers, nil
|
||||
}
|
||||
|
||||
// resolveZoneNSAddrs returns "host:53" entries for the zone's NS set.
|
||||
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
|
||||
}
|
||||
|
||||
// queryDelegation expects a referral response (no RD) and pulls NS + glue
|
||||
// from every section so misconfigured parents (NS in Answer) still parse.
|
||||
func queryDelegation(ctx context.Context, parentServer, fqdn string) (ns []string, glue map[string][]string, msg *dns.Msg, err error) {
|
||||
q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET}
|
||||
|
||||
msg, err = dnsExchange(ctx, "", parentServer, q, true)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if msg.Rcode != dns.RcodeSuccess {
|
||||
return nil, nil, msg, fmt.Errorf("parent answered %s", dns.RcodeToString[msg.Rcode])
|
||||
}
|
||||
|
||||
glue = map[string][]string{}
|
||||
|
||||
collect := func(records []dns.RR) {
|
||||
for _, rr := range records {
|
||||
switch t := rr.(type) {
|
||||
case *dns.NS:
|
||||
if strings.EqualFold(strings.TrimSuffix(t.Header().Name, "."), strings.TrimSuffix(fqdn, ".")) {
|
||||
ns = append(ns, strings.ToLower(dns.Fqdn(t.Ns)))
|
||||
}
|
||||
case *dns.A:
|
||||
name := strings.ToLower(dns.Fqdn(t.Header().Name))
|
||||
glue[name] = append(glue[name], t.A.String())
|
||||
case *dns.AAAA:
|
||||
name := strings.ToLower(dns.Fqdn(t.Header().Name))
|
||||
glue[name] = append(glue[name], t.AAAA.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(msg.Answer)
|
||||
collect(msg.Ns)
|
||||
collect(msg.Extra)
|
||||
return
|
||||
}
|
||||
|
||||
// queryDS uses TCP because DS+RRSIG answers commonly exceed UDP MTU.
|
||||
func queryDS(ctx context.Context, parentServer, fqdn string) (ds []*dns.DS, sigs []*dns.RRSIG, err error) {
|
||||
q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeDS, Qclass: dns.ClassINET}
|
||||
|
||||
r, err := dnsExchange(ctx, "tcp", parentServer, q, true)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if r.Rcode != dns.RcodeSuccess {
|
||||
return nil, nil, fmt.Errorf("parent answered %s for DS", dns.RcodeToString[r.Rcode])
|
||||
}
|
||||
|
||||
for _, rr := range r.Answer {
|
||||
switch t := rr.(type) {
|
||||
case *dns.DS:
|
||||
ds = append(ds, t)
|
||||
case *dns.RRSIG:
|
||||
sigs = append(sigs, t)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// querySOA also returns the AA flag so callers can detect lame servers.
|
||||
func querySOA(ctx context.Context, proto, server, fqdn string) (soa *dns.SOA, aa bool, err error) {
|
||||
q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeSOA, Qclass: dns.ClassINET}
|
||||
r, err := dnsExchange(ctx, proto, server, q, false)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if r.Rcode != dns.RcodeSuccess {
|
||||
return nil, r.Authoritative, fmt.Errorf("server answered %s", dns.RcodeToString[r.Rcode])
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
if t, ok := rr.(*dns.SOA); ok {
|
||||
return t, r.Authoritative, nil
|
||||
}
|
||||
}
|
||||
return nil, r.Authoritative, fmt.Errorf("no SOA in answer section")
|
||||
}
|
||||
|
||||
func queryNSAt(ctx context.Context, server, fqdn string) ([]string, error) {
|
||||
q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeNS, Qclass: dns.ClassINET}
|
||||
r, err := dnsExchange(ctx, "", server, q, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Rcode != dns.RcodeSuccess {
|
||||
return nil, fmt.Errorf("server answered %s", dns.RcodeToString[r.Rcode])
|
||||
}
|
||||
var out []string
|
||||
for _, rr := range r.Answer {
|
||||
if t, ok := rr.(*dns.NS); ok {
|
||||
out = append(out, strings.ToLower(dns.Fqdn(t.Ns)))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func queryAddrsAt(ctx context.Context, server, host string) ([]string, error) {
|
||||
var out []string
|
||||
for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
||||
r, err := dnsExchange(ctx, "", server, dns.Question{Name: dns.Fqdn(host), Qtype: qt, Qclass: dns.ClassINET}, false)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if r.Rcode != dns.RcodeSuccess {
|
||||
continue
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
switch t := rr.(type) {
|
||||
case *dns.A:
|
||||
out = append(out, t.A.String())
|
||||
case *dns.AAAA:
|
||||
out = append(out, t.AAAA.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func queryDNSKEY(ctx context.Context, server, fqdn string) ([]*dns.DNSKEY, error) {
|
||||
q := dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeDNSKEY, Qclass: dns.ClassINET}
|
||||
r, err := dnsExchange(ctx, "tcp", server, q, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Rcode != dns.RcodeSuccess {
|
||||
return nil, fmt.Errorf("server answered %s for DNSKEY", dns.RcodeToString[r.Rcode])
|
||||
}
|
||||
var out []*dns.DNSKEY
|
||||
for _, rr := range r.Answer {
|
||||
if t, ok := rr.(*dns.DNSKEY); ok {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dsEqual(a, b *dns.DS) bool {
|
||||
return a.KeyTag == b.KeyTag &&
|
||||
a.Algorithm == b.Algorithm &&
|
||||
a.DigestType == b.DigestType &&
|
||||
strings.EqualFold(a.Digest, b.Digest)
|
||||
}
|
||||
108
checker/helpers.go
Normal file
108
checker/helpers.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func normalizeNSList(ns []*dns.NS) []string {
|
||||
out := make([]string, 0, len(ns))
|
||||
for _, n := range ns {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, strings.ToLower(dns.Fqdn(n.Ns)))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func diffStringSets(want, got []string) (missing, extra []string) {
|
||||
w := map[string]bool{}
|
||||
for _, v := range want {
|
||||
w[strings.ToLower(strings.TrimSuffix(v, "."))] = true
|
||||
}
|
||||
g := map[string]bool{}
|
||||
for _, v := range got {
|
||||
g[strings.ToLower(strings.TrimSuffix(v, "."))] = true
|
||||
}
|
||||
for k := range w {
|
||||
if !g[k] {
|
||||
missing = append(missing, k)
|
||||
}
|
||||
}
|
||||
for k := range g {
|
||||
if !w[k] {
|
||||
extra = append(extra, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
return
|
||||
}
|
||||
|
||||
func diffDS(want, got []*dns.DS) (missing, extra []*dns.DS) {
|
||||
for _, w := range want {
|
||||
found := false
|
||||
for _, g := range got {
|
||||
if dsEqual(w, g) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
missing = append(missing, w)
|
||||
}
|
||||
}
|
||||
for _, g := range got {
|
||||
found := false
|
||||
for _, w := range want {
|
||||
if dsEqual(w, g) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
extra = append(extra, g)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func isInBailiwick(host, zone string) bool {
|
||||
host = strings.ToLower(dns.Fqdn(host))
|
||||
zone = strings.ToLower(dns.Fqdn(zone))
|
||||
return host == zone || strings.HasSuffix(host, "."+zone)
|
||||
}
|
||||
|
||||
func dsMatchesAnyKey(ds []*dns.DS, keys []*dns.DNSKEY) bool {
|
||||
for _, k := range keys {
|
||||
for _, d := range ds {
|
||||
expected := k.ToDS(d.DigestType)
|
||||
if expected != nil && dsEqual(expected, d) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dsRecordsToMiekg lets diff/matcher helpers stay miekg-typed.
|
||||
func dsRecordsToMiekg(list []DSRecord) []*dns.DS {
|
||||
out := make([]*dns.DS, 0, len(list))
|
||||
for _, d := range list {
|
||||
out = append(out, d.ToMiekg())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dnskeysToMiekg restores miekg form so k.ToDS is callable.
|
||||
func dnskeysToMiekg(list []DNSKEYRecord) []*dns.DNSKEY {
|
||||
out := make([]*dns.DNSKEY, 0, len(list))
|
||||
for _, k := range list {
|
||||
out = append(out, k.ToMiekg())
|
||||
}
|
||||
return out
|
||||
}
|
||||
136
checker/helpers_test.go
Normal file
136
checker/helpers_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func TestDiffStringSets(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
want, got []string
|
||||
missing, extra []string
|
||||
}{
|
||||
{
|
||||
name: "identical",
|
||||
want: []string{"a.example.", "b.example."},
|
||||
got: []string{"a.example.", "b.example."},
|
||||
missing: nil, extra: nil,
|
||||
},
|
||||
{
|
||||
name: "case and trailing dot are normalized",
|
||||
want: []string{"A.Example."},
|
||||
got: []string{"a.example"},
|
||||
missing: nil, extra: nil,
|
||||
},
|
||||
{
|
||||
name: "missing and extra reported",
|
||||
want: []string{"a.example.", "b.example."},
|
||||
got: []string{"b.example.", "c.example."},
|
||||
missing: []string{"a.example"},
|
||||
extra: []string{"c.example"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotMissing, gotExtra := diffStringSets(tc.want, tc.got)
|
||||
if !reflect.DeepEqual(gotMissing, tc.missing) {
|
||||
t.Errorf("missing: got %v want %v", gotMissing, tc.missing)
|
||||
}
|
||||
if !reflect.DeepEqual(gotExtra, tc.extra) {
|
||||
t.Errorf("extra: got %v want %v", gotExtra, tc.extra)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInBailiwick(t *testing.T) {
|
||||
cases := []struct {
|
||||
host, zone string
|
||||
want bool
|
||||
}{
|
||||
{"ns1.example.com.", "example.com.", true},
|
||||
{"ns1.example.com", "example.com", true},
|
||||
{"example.com.", "example.com.", true},
|
||||
{"ns1.other.com.", "example.com.", false},
|
||||
{"ns1.notexample.com.", "example.com.", false}, // suffix-but-not-subdomain trap
|
||||
{"NS1.Example.COM", "example.com", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isInBailiwick(tc.host, tc.zone); got != tc.want {
|
||||
t.Errorf("isInBailiwick(%q,%q)=%v want %v", tc.host, tc.zone, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostPort(t *testing.T) {
|
||||
cases := []struct {
|
||||
host, port, want string
|
||||
}{
|
||||
{"192.0.2.1", "53", "192.0.2.1:53"},
|
||||
{"2001:db8::1", "53", "[2001:db8::1]:53"},
|
||||
{"ns1.example.com.", "53", "ns1.example.com:53"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := hostPort(tc.host, tc.port); got != tc.want {
|
||||
t.Errorf("hostPort(%q,%q)=%q want %q", tc.host, tc.port, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeNSList(t *testing.T) {
|
||||
in := []*dns.NS{
|
||||
{Ns: "B.example.COM"},
|
||||
nil, // must be skipped
|
||||
{Ns: "a.example.com."},
|
||||
}
|
||||
want := []string{"a.example.com.", "b.example.com."}
|
||||
got := normalizeNSList(in)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("normalizeNSList: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffDS(t *testing.T) {
|
||||
a := &dns.DS{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "AAAA"}
|
||||
b := &dns.DS{KeyTag: 2, Algorithm: 8, DigestType: 2, Digest: "BBBB"}
|
||||
c := &dns.DS{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "aaaa"} // case-insensitive digest
|
||||
|
||||
missing, extra := diffDS([]*dns.DS{a, b}, []*dns.DS{c})
|
||||
if len(missing) != 1 || missing[0] != b {
|
||||
t.Errorf("missing: got %v want [b]", missing)
|
||||
}
|
||||
if len(extra) != 0 {
|
||||
t.Errorf("extra: got %v want []", extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDSMatchesAnyKey(t *testing.T) {
|
||||
// Build a DNSKEY and derive its DS, so we know they match.
|
||||
key := &dns.DNSKEY{
|
||||
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET},
|
||||
Flags: 257,
|
||||
Protocol: 3,
|
||||
Algorithm: dns.RSASHA256,
|
||||
// A throwaway public key; ToDS only needs the wire form to be deterministic.
|
||||
PublicKey: "AwEAAcMnWBKLuvG/LwnPVykcmpvnntwxfshHlHRhlY0F3oz8AMcuF8gw" +
|
||||
"2Ge56vG9oqVxTzHl4Ss2dEqCQOjFlOVo+pa3JwIO1lUzbQ==",
|
||||
}
|
||||
matchingDS := key.ToDS(dns.SHA256)
|
||||
if matchingDS == nil {
|
||||
t.Fatal("could not derive DS from DNSKEY")
|
||||
}
|
||||
other := &dns.DS{KeyTag: 9999, Algorithm: 99, DigestType: 99, Digest: "DEAD"}
|
||||
|
||||
if !dsMatchesAnyKey([]*dns.DS{matchingDS, other}, []*dns.DNSKEY{key}) {
|
||||
t.Error("expected match between key and its derived DS")
|
||||
}
|
||||
if dsMatchesAnyKey([]*dns.DS{other}, []*dns.DNSKEY{key}) {
|
||||
t.Error("unexpected match against unrelated DS")
|
||||
}
|
||||
if dsMatchesAnyKey(nil, []*dns.DNSKEY{key}) {
|
||||
t.Error("no DS records: must not match")
|
||||
}
|
||||
}
|
||||
135
checker/interactive.go
Normal file
135
checker/interactive.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
//go:build standalone
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func (p *delegationProvider) RenderForm() []sdk.CheckerOptionField {
|
||||
return []sdk.CheckerOptionField{
|
||||
{
|
||||
Id: "domain",
|
||||
Type: "string",
|
||||
Label: "Delegated domain",
|
||||
Placeholder: "sub.example.com",
|
||||
Required: true,
|
||||
Description: "Fully-qualified name of the delegated zone to check.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *delegationProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||
if domain == "" {
|
||||
return nil, fmt.Errorf("domain is required")
|
||||
}
|
||||
fqdn := dns.Fqdn(domain)
|
||||
labels := dns.SplitDomainName(fqdn)
|
||||
if len(labels) < 2 {
|
||||
return nil, fmt.Errorf("%q has no parent zone", domain)
|
||||
}
|
||||
parentZone := strings.Join(labels[1:], ".")
|
||||
subdomain := labels[0]
|
||||
|
||||
resolver := interactiveResolver()
|
||||
|
||||
ctx := r.Context()
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
nsRecords []*dns.NS
|
||||
dsRecords []*dns.DS
|
||||
nsErr error
|
||||
dsErr error
|
||||
)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
nsRecords, nsErr = lookupRecords[*dns.NS](ctx, resolver, fqdn, dns.TypeNS, false)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
dsRecords, dsErr = lookupRecords[*dns.DS](ctx, resolver, fqdn, dns.TypeDS, true)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
if nsErr != nil {
|
||||
return nil, fmt.Errorf("NS lookup for %s: %w", domain, nsErr)
|
||||
}
|
||||
if len(nsRecords) == 0 {
|
||||
return nil, fmt.Errorf("no NS records found for %s", domain)
|
||||
}
|
||||
if dsErr != nil {
|
||||
return nil, fmt.Errorf("DS lookup for %s: %w", domain, dsErr)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(delegationService{NameServers: nsRecords, DS: dsRecords})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal delegation service: %w", err)
|
||||
}
|
||||
|
||||
svc := serviceMessage{
|
||||
Type: "abstract.Delegation",
|
||||
Service: body,
|
||||
}
|
||||
|
||||
return sdk.CheckerOptions{
|
||||
"domain_name": parentZone,
|
||||
"subdomain": subdomain,
|
||||
"service": svc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
resolverOnce sync.Once
|
||||
resolverAddr string
|
||||
|
||||
interactiveClient = &dns.Client{Timeout: dnsTimeout}
|
||||
)
|
||||
|
||||
func interactiveResolver() string {
|
||||
resolverOnce.Do(func() {
|
||||
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
|
||||
if err != nil || len(cfg.Servers) == 0 {
|
||||
resolverAddr = net.JoinHostPort("1.1.1.1", "53")
|
||||
return
|
||||
}
|
||||
resolverAddr = net.JoinHostPort(cfg.Servers[0], cfg.Port)
|
||||
})
|
||||
return resolverAddr
|
||||
}
|
||||
|
||||
func lookupRecords[T dns.RR](ctx context.Context, resolver, fqdn string, qtype uint16, edns bool) ([]T, error) {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(fqdn, qtype)
|
||||
msg.RecursionDesired = true
|
||||
if edns {
|
||||
msg.SetEdns0(4096, true)
|
||||
}
|
||||
|
||||
in, _, err := interactiveClient.ExchangeContext(ctx, msg, resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Rcode != dns.RcodeSuccess && in.Rcode != dns.RcodeNameError {
|
||||
return nil, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode])
|
||||
}
|
||||
|
||||
var out []T
|
||||
for _, rr := range in.Answer {
|
||||
if t, ok := rr.(T); ok {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
48
checker/provider.go
Normal file
48
checker/provider.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &delegationProvider{}
|
||||
}
|
||||
|
||||
type delegationProvider struct{}
|
||||
|
||||
func (p *delegationProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeyDelegation
|
||||
}
|
||||
|
||||
// ValidateOptions runs once per provider so each rule doesn't re-check.
|
||||
func (p *delegationProvider) ValidateOptions(opts sdk.CheckerOptions) error {
|
||||
if v, ok := opts["minNameServers"]; ok {
|
||||
var f float64
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
f = n
|
||||
case float32:
|
||||
f = float64(n)
|
||||
case int:
|
||||
f = float64(n)
|
||||
case int32:
|
||||
f = float64(n)
|
||||
case int64:
|
||||
f = float64(n)
|
||||
case uint:
|
||||
f = float64(n)
|
||||
case uint32:
|
||||
f = float64(n)
|
||||
case uint64:
|
||||
f = float64(n)
|
||||
default:
|
||||
return fmt.Errorf("minNameServers must be a number")
|
||||
}
|
||||
if f < 1 {
|
||||
return fmt.Errorf("minNameServers must be >= 1")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
262
checker/report.go
Normal file
262
checker/report.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// GetHTMLReport falls back to a data-only render when the host hasn't
|
||||
// threaded rule states into the context yet.
|
||||
func (p *delegationProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||
var data DelegationData
|
||||
if raw := ctx.Data(); len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return "", fmt.Errorf("decoding delegation data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
states := ctx.States()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!doctype html><html><head><meta charset="utf-8">`)
|
||||
b.WriteString(`<title>Delegation report</title></head><body style="font-family:sans-serif">`)
|
||||
|
||||
fmt.Fprintf(&b, `<h1>Delegation of %s</h1>`, html.EscapeString(strings.TrimSuffix(data.DelegatedFQDN, ".")))
|
||||
|
||||
if len(states) == 0 {
|
||||
b.WriteString(`<p><em>No rule states were threaded into this report; rendering raw observation only.</em></p>`)
|
||||
writeDataOnly(&b, &data)
|
||||
b.WriteString(`</body></html>`)
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
writeBanner(&b, states)
|
||||
writeFixTheseFirst(&b, states)
|
||||
writeAllStates(&b, states)
|
||||
writeDataOnly(&b, &data)
|
||||
|
||||
b.WriteString(`</body></html>`)
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func (p *delegationProvider) ExtractMetrics(ctx sdk.ReportContext, collectedAt time.Time) ([]sdk.CheckMetric, error) {
|
||||
var data DelegationData
|
||||
if raw := ctx.Data(); len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, fmt.Errorf("decoding delegation data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var metrics []sdk.CheckMetric
|
||||
|
||||
metrics = append(metrics, sdk.CheckMetric{
|
||||
Name: "delegation.parent_views.count",
|
||||
Value: float64(len(data.ParentViews)),
|
||||
Timestamp: collectedAt,
|
||||
})
|
||||
metrics = append(metrics, sdk.CheckMetric{
|
||||
Name: "delegation.child_servers.count",
|
||||
Value: float64(len(data.Children)),
|
||||
Timestamp: collectedAt,
|
||||
})
|
||||
|
||||
byRuleStatus := map[string]map[sdk.Status]int{}
|
||||
byStatus := map[sdk.Status]int{}
|
||||
for _, s := range ctx.States() {
|
||||
byStatus[s.Status]++
|
||||
if byRuleStatus[s.RuleName] == nil {
|
||||
byRuleStatus[s.RuleName] = map[sdk.Status]int{}
|
||||
}
|
||||
byRuleStatus[s.RuleName][s.Status]++
|
||||
}
|
||||
|
||||
for rule, perStatus := range byRuleStatus {
|
||||
for status, n := range perStatus {
|
||||
metrics = append(metrics, sdk.CheckMetric{
|
||||
Name: "delegation.rule.status",
|
||||
Value: float64(n),
|
||||
Labels: map[string]string{
|
||||
"rule": rule,
|
||||
"status": status.String(),
|
||||
},
|
||||
Timestamp: collectedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for status, n := range byStatus {
|
||||
if status == sdk.StatusOK {
|
||||
continue
|
||||
}
|
||||
metrics = append(metrics, sdk.CheckMetric{
|
||||
Name: "delegation.findings.count",
|
||||
Value: float64(n),
|
||||
Labels: map[string]string{"status": status.String()},
|
||||
Timestamp: collectedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func worstStatus(states []sdk.CheckState) sdk.Status {
|
||||
worst := sdk.StatusOK
|
||||
for _, s := range states {
|
||||
if s.Status > worst {
|
||||
worst = s.Status
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
func statusColor(s sdk.Status) string {
|
||||
switch s {
|
||||
case sdk.StatusOK:
|
||||
return "#2e7d32"
|
||||
case sdk.StatusInfo:
|
||||
return "#0277bd"
|
||||
case sdk.StatusWarn:
|
||||
return "#ef6c00"
|
||||
case sdk.StatusCrit:
|
||||
return "#c62828"
|
||||
case sdk.StatusError:
|
||||
return "#6a1b9a"
|
||||
default:
|
||||
return "#555"
|
||||
}
|
||||
}
|
||||
|
||||
func writeBanner(b *strings.Builder, states []sdk.CheckState) {
|
||||
worst := worstStatus(states)
|
||||
fmt.Fprintf(b, `<p style="padding:.5em 1em;background:%s;color:#fff;display:inline-block;border-radius:4px">Overall: <strong>%s</strong></p>`,
|
||||
statusColor(worst), worst.String())
|
||||
}
|
||||
|
||||
func writeFixTheseFirst(b *strings.Builder, states []sdk.CheckState) {
|
||||
var fix []sdk.CheckState
|
||||
for _, s := range states {
|
||||
if s.Status >= sdk.StatusWarn {
|
||||
fix = append(fix, s)
|
||||
}
|
||||
}
|
||||
if len(fix) == 0 {
|
||||
return
|
||||
}
|
||||
sort.SliceStable(fix, func(i, j int) bool {
|
||||
if fix[i].Status != fix[j].Status {
|
||||
return fix[i].Status > fix[j].Status
|
||||
}
|
||||
if fix[i].RuleName != fix[j].RuleName {
|
||||
return fix[i].RuleName < fix[j].RuleName
|
||||
}
|
||||
return fix[i].Subject < fix[j].Subject
|
||||
})
|
||||
b.WriteString(`<h2>Fix these first</h2>`)
|
||||
writeStatesTable(b, fix)
|
||||
}
|
||||
|
||||
func writeAllStates(b *strings.Builder, states []sdk.CheckState) {
|
||||
sorted := append([]sdk.CheckState(nil), states...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
if sorted[i].RuleName != sorted[j].RuleName {
|
||||
return sorted[i].RuleName < sorted[j].RuleName
|
||||
}
|
||||
return sorted[i].Subject < sorted[j].Subject
|
||||
})
|
||||
b.WriteString(`<h2>All rule states</h2>`)
|
||||
writeStatesTable(b, sorted)
|
||||
}
|
||||
|
||||
func writeStatesTable(b *strings.Builder, states []sdk.CheckState) {
|
||||
b.WriteString(`<table style="border-collapse:collapse" cellpadding="4" border="1">`)
|
||||
b.WriteString(`<thead><tr><th>Status</th><th>Rule</th><th>Subject</th><th>Message</th></tr></thead><tbody>`)
|
||||
for _, s := range states {
|
||||
fmt.Fprintf(b, `<tr><td style="color:%s;font-weight:bold">%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
|
||||
statusColor(s.Status),
|
||||
html.EscapeString(s.Status.String()),
|
||||
html.EscapeString(s.RuleName),
|
||||
html.EscapeString(s.Subject),
|
||||
html.EscapeString(s.Message),
|
||||
)
|
||||
}
|
||||
b.WriteString(`</tbody></table>`)
|
||||
}
|
||||
|
||||
func writeDataOnly(b *strings.Builder, data *DelegationData) {
|
||||
b.WriteString(`<h2>Observation</h2>`)
|
||||
if data.ParentDiscoveryError != "" {
|
||||
fmt.Fprintf(b, `<p><strong>Parent discovery error:</strong> %s</p>`, html.EscapeString(data.ParentDiscoveryError))
|
||||
}
|
||||
|
||||
if len(data.DeclaredNS) > 0 {
|
||||
fmt.Fprintf(b, `<p><strong>Declared NS:</strong> %s</p>`, html.EscapeString(strings.Join(data.DeclaredNS, ", ")))
|
||||
}
|
||||
if len(data.DeclaredDS) > 0 {
|
||||
var texts []string
|
||||
for _, d := range data.DeclaredDS {
|
||||
texts = append(texts, fmt.Sprintf("keytag=%d algo=%d digest-type=%d", d.KeyTag, d.Algorithm, d.DigestType))
|
||||
}
|
||||
fmt.Fprintf(b, `<p><strong>Declared DS:</strong> %s</p>`, html.EscapeString(strings.Join(texts, "; ")))
|
||||
}
|
||||
|
||||
if len(data.ParentViews) > 0 {
|
||||
b.WriteString(`<h3>Parent views</h3><ul>`)
|
||||
for _, v := range data.ParentViews {
|
||||
fmt.Fprintf(b, `<li><strong>%s</strong>: NS=[%s], glue=%d, DS=%d`,
|
||||
html.EscapeString(v.Server),
|
||||
html.EscapeString(strings.Join(v.NS, ", ")),
|
||||
len(v.Glue), len(v.DS))
|
||||
if v.UDPNSError != "" {
|
||||
fmt.Fprintf(b, `, UDP err=%s`, html.EscapeString(v.UDPNSError))
|
||||
}
|
||||
if v.TCPNSError != "" {
|
||||
fmt.Fprintf(b, `, TCP err=%s`, html.EscapeString(v.TCPNSError))
|
||||
}
|
||||
if v.DSQueryError != "" {
|
||||
fmt.Fprintf(b, `, DS err=%s`, html.EscapeString(v.DSQueryError))
|
||||
}
|
||||
b.WriteString(`</li>`)
|
||||
}
|
||||
b.WriteString(`</ul>`)
|
||||
}
|
||||
|
||||
if len(data.Children) > 0 {
|
||||
b.WriteString(`<h3>Delegated servers</h3><ul>`)
|
||||
for _, c := range data.Children {
|
||||
fmt.Fprintf(b, `<li><strong>%s</strong>`, html.EscapeString(c.NSName))
|
||||
if c.ResolveError != "" {
|
||||
fmt.Fprintf(b, ` (resolve err: %s)`, html.EscapeString(c.ResolveError))
|
||||
}
|
||||
if len(c.Addresses) > 0 {
|
||||
b.WriteString(`<ul>`)
|
||||
for _, a := range c.Addresses {
|
||||
fmt.Fprintf(b, `<li>%s, AA=%t`, html.EscapeString(a.Address), a.Authoritative)
|
||||
if a.SOASerialKnown {
|
||||
fmt.Fprintf(b, `, SOA=%d`, a.SOASerial)
|
||||
}
|
||||
if a.UDPError != "" {
|
||||
fmt.Fprintf(b, `, UDP err=%s`, html.EscapeString(a.UDPError))
|
||||
}
|
||||
if a.TCPError != "" {
|
||||
fmt.Fprintf(b, `, TCP err=%s`, html.EscapeString(a.TCPError))
|
||||
}
|
||||
if a.DNSKEYError != "" {
|
||||
fmt.Fprintf(b, `, DNSKEY err=%s`, html.EscapeString(a.DNSKEYError))
|
||||
} else if len(a.DNSKEYs) > 0 {
|
||||
fmt.Fprintf(b, `, DNSKEYs=%d`, len(a.DNSKEYs))
|
||||
}
|
||||
b.WriteString(`</li>`)
|
||||
}
|
||||
b.WriteString(`</ul>`)
|
||||
}
|
||||
b.WriteString(`</li>`)
|
||||
}
|
||||
b.WriteString(`</ul>`)
|
||||
}
|
||||
}
|
||||
992
checker/rule.go
Normal file
992
checker/rule.go
Normal file
|
|
@ -0,0 +1,992 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules returns the full rule set. All rules share one DelegationData
|
||||
// observation and emit one CheckState per evaluated subject.
|
||||
func Rules() []sdk.CheckRule {
|
||||
return []sdk.CheckRule{
|
||||
&minNameServersRule{},
|
||||
&parentDiscoveredRule{},
|
||||
&parentNSQueryRule{},
|
||||
&parentTCPRule{},
|
||||
&nsMatchesDeclaredRule{},
|
||||
&inBailiwickGlueRule{},
|
||||
&unnecessaryGlueRule{},
|
||||
&dsQueryRule{},
|
||||
&dsMatchesDeclaredRule{},
|
||||
&dsPresentAtParentRule{},
|
||||
&dsRRSIGValidityRule{},
|
||||
&nsResolvableRule{},
|
||||
&childReachableRule{},
|
||||
&childAuthoritativeRule{},
|
||||
&childSOASerialDriftRule{},
|
||||
&childTCPRule{},
|
||||
&childNSMatchesParentRule{},
|
||||
&childGlueMatchesParentRule{},
|
||||
&dnskeyQueryRule{},
|
||||
&dnskeyMatchesDSRule{},
|
||||
&nsHasAuthoritativeAnswerRule{},
|
||||
}
|
||||
}
|
||||
|
||||
func loadData(ctx context.Context, obs sdk.ObservationGetter, code string) (*DelegationData, []sdk.CheckState) {
|
||||
var data DelegationData
|
||||
if err := obs.Get(ctx, ObservationKeyDelegation, &data); err != nil {
|
||||
return nil, []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("Failed to get delegation data: %v", err),
|
||||
Code: code,
|
||||
}}
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// primaryParentView mirrors Collect's Phase-B source-of-truth choice.
|
||||
func primaryParentView(views []ParentView) *ParentView {
|
||||
for i := range views {
|
||||
if views[i].UDPNSError == "" && len(views[i].NS) > 0 {
|
||||
return &views[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ───────────────────────── checker-wide rules ─────────────────────────
|
||||
|
||||
type minNameServersRule struct{}
|
||||
|
||||
func (r *minNameServersRule) Name() string { return "delegation_min_name_servers" }
|
||||
func (r *minNameServersRule) Description() string {
|
||||
return "Checks that enough name servers are declared for the delegation (RFC 1034 recommends at least 2)"
|
||||
}
|
||||
func (r *minNameServersRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_too_few_ns")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
minNS := sdk.GetIntOption(opts, "minNameServers", 2)
|
||||
if len(data.DeclaredNS) < minNS {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "delegation_too_few_ns",
|
||||
Message: fmt.Sprintf("only %d name server(s) declared, at least %d recommended", len(data.DeclaredNS), minNS),
|
||||
Meta: map[string]any{"declared": len(data.DeclaredNS), "minimum": minNS},
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "delegation_too_few_ns",
|
||||
Message: fmt.Sprintf("%d name server(s) declared", len(data.DeclaredNS)),
|
||||
}}
|
||||
}
|
||||
|
||||
type parentDiscoveredRule struct{}
|
||||
|
||||
func (r *parentDiscoveredRule) Name() string { return "delegation_parent_discovered" }
|
||||
func (r *parentDiscoveredRule) Description() string {
|
||||
return "Verifies that the parent zone's authoritative servers could be discovered"
|
||||
}
|
||||
func (r *parentDiscoveredRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_no_parent_ns")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
if data.ParentDiscoveryError != "" {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "delegation_no_parent_ns",
|
||||
Message: data.ParentDiscoveryError,
|
||||
}}
|
||||
}
|
||||
if len(data.ParentNS) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "delegation_no_parent_ns",
|
||||
Message: "parent zone has no resolvable authoritative servers",
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "delegation_no_parent_ns",
|
||||
Message: fmt.Sprintf("%d parent authoritative server(s) discovered", len(data.ParentNS)),
|
||||
}}
|
||||
}
|
||||
|
||||
// ───────────────────────── parent-side rules ─────────────────────────
|
||||
|
||||
type parentNSQueryRule struct{}
|
||||
|
||||
func (r *parentNSQueryRule) Name() string { return "delegation_parent_ns_query" }
|
||||
func (r *parentNSQueryRule) Description() string {
|
||||
return "Verifies that every parent authoritative server answers the NS query for the delegated FQDN"
|
||||
}
|
||||
func (r *parentNSQueryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_parent_query_failed")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
if len(data.ParentViews) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_parent_query_failed",
|
||||
Message: "no parent server was queried",
|
||||
}}
|
||||
}
|
||||
out := make([]sdk.CheckState, 0, len(data.ParentViews))
|
||||
for _, v := range data.ParentViews {
|
||||
st := sdk.CheckState{Code: "delegation_parent_query_failed", Subject: v.Server}
|
||||
switch {
|
||||
case v.UDPNSError != "":
|
||||
st.Status = sdk.StatusCrit
|
||||
st.Message = fmt.Sprintf("parent NS query failed: %s", v.UDPNSError)
|
||||
case len(v.NS) == 0:
|
||||
st.Status = sdk.StatusCrit
|
||||
st.Message = "parent returned an empty NS RRset"
|
||||
default:
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = fmt.Sprintf("%d NS record(s) returned", len(v.NS))
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type parentTCPRule struct{}
|
||||
|
||||
func (r *parentTCPRule) Name() string { return "delegation_parent_tcp" }
|
||||
func (r *parentTCPRule) Description() string {
|
||||
return "Verifies that every parent authoritative server answers the NS query over TCP"
|
||||
}
|
||||
func (r *parentTCPRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_parent_tcp_failed")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
if len(data.ParentViews) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_parent_tcp_failed",
|
||||
Message: "no parent server was queried",
|
||||
}}
|
||||
}
|
||||
requireTCP := sdk.GetBoolOption(opts, "requireTCP", true)
|
||||
failStatus := sdk.StatusCrit
|
||||
if !requireTCP {
|
||||
failStatus = sdk.StatusWarn
|
||||
}
|
||||
out := make([]sdk.CheckState, 0, len(data.ParentViews))
|
||||
for _, v := range data.ParentViews {
|
||||
st := sdk.CheckState{Code: "delegation_parent_tcp_failed", Subject: v.Server}
|
||||
if v.TCPNSError != "" {
|
||||
st.Status = failStatus
|
||||
st.Message = fmt.Sprintf("parent NS query over TCP failed: %s", v.TCPNSError)
|
||||
} else {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = "TCP reachable"
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type nsMatchesDeclaredRule struct{}
|
||||
|
||||
func (r *nsMatchesDeclaredRule) Name() string { return "delegation_ns_matches_declared" }
|
||||
func (r *nsMatchesDeclaredRule) Description() string {
|
||||
return "Verifies that the NS RRset served by the parent matches the service's declared name servers"
|
||||
}
|
||||
func (r *nsMatchesDeclaredRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_ns_mismatch")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, v := range data.ParentViews {
|
||||
if v.UDPNSError != "" || len(v.NS) == 0 {
|
||||
continue
|
||||
}
|
||||
missing, extra := diffStringSets(data.DeclaredNS, v.NS)
|
||||
st := sdk.CheckState{Code: "delegation_ns_mismatch", Subject: v.Server}
|
||||
if len(missing) > 0 || len(extra) > 0 {
|
||||
st.Status = sdk.StatusCrit
|
||||
st.Message = fmt.Sprintf("NS RRset does not match declared: missing=%v extra=%v", missing, extra)
|
||||
st.Meta = map[string]any{"missing": missing, "extra": extra}
|
||||
} else {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = "NS RRset matches the declared service"
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_ns_mismatch",
|
||||
Message: "no parent server returned an NS RRset",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type inBailiwickGlueRule struct{}
|
||||
|
||||
func (r *inBailiwickGlueRule) Name() string { return "delegation_in_bailiwick_glue" }
|
||||
func (r *inBailiwickGlueRule) Description() string {
|
||||
return "Verifies that every in-bailiwick NS hostname has glue records at the parent"
|
||||
}
|
||||
func (r *inBailiwickGlueRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_missing_glue")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
if len(data.ParentViews) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_missing_glue",
|
||||
Message: "no parent server was queried",
|
||||
}}
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, v := range data.ParentViews {
|
||||
if v.UDPNSError != "" {
|
||||
continue
|
||||
}
|
||||
for _, n := range v.NS {
|
||||
if !isInBailiwick(n, data.DelegatedFQDN) {
|
||||
continue
|
||||
}
|
||||
subject := fmt.Sprintf("%s@%s", n, v.Server)
|
||||
if len(v.Glue[n]) == 0 {
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "delegation_missing_glue",
|
||||
Subject: subject,
|
||||
Message: "in-bailiwick NS has no glue",
|
||||
})
|
||||
} else {
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "delegation_missing_glue",
|
||||
Subject: subject,
|
||||
Message: fmt.Sprintf("%d glue address(es)", len(v.Glue[n])),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "delegation_missing_glue",
|
||||
Message: "no in-bailiwick NS, glue not required",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type unnecessaryGlueRule struct{}
|
||||
|
||||
func (r *unnecessaryGlueRule) Name() string { return "delegation_unnecessary_glue" }
|
||||
func (r *unnecessaryGlueRule) Description() string {
|
||||
return "Flags out-of-bailiwick NS hostnames for which the parent still returns glue"
|
||||
}
|
||||
func (r *unnecessaryGlueRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_unnecessary_glue")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, v := range data.ParentViews {
|
||||
if v.UDPNSError != "" {
|
||||
continue
|
||||
}
|
||||
for _, n := range v.NS {
|
||||
if isInBailiwick(n, data.DelegatedFQDN) {
|
||||
continue
|
||||
}
|
||||
subject := fmt.Sprintf("%s@%s", n, v.Server)
|
||||
if len(v.Glue[n]) > 0 {
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "delegation_unnecessary_glue",
|
||||
Subject: subject,
|
||||
Message: "out-of-bailiwick NS has glue records at the parent",
|
||||
})
|
||||
} else {
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "delegation_unnecessary_glue",
|
||||
Subject: subject,
|
||||
Message: "no glue (expected)",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "delegation_unnecessary_glue",
|
||||
Message: "no out-of-bailiwick NS to evaluate",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type dsQueryRule struct{}
|
||||
|
||||
func (r *dsQueryRule) Name() string { return "delegation_ds_query" }
|
||||
func (r *dsQueryRule) Description() string {
|
||||
return "Verifies that every parent authoritative server answers the DS query for the delegated FQDN"
|
||||
}
|
||||
func (r *dsQueryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_ds_query_failed")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
if len(data.ParentViews) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_ds_query_failed",
|
||||
Message: "no parent server was queried",
|
||||
}}
|
||||
}
|
||||
out := make([]sdk.CheckState, 0, len(data.ParentViews))
|
||||
for _, v := range data.ParentViews {
|
||||
st := sdk.CheckState{Code: "delegation_ds_query_failed", Subject: v.Server}
|
||||
if v.DSQueryError != "" {
|
||||
st.Status = sdk.StatusWarn
|
||||
st.Message = fmt.Sprintf("DS query failed: %s", v.DSQueryError)
|
||||
} else {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = fmt.Sprintf("%d DS record(s) returned", len(v.DS))
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type dsMatchesDeclaredRule struct{}
|
||||
|
||||
func (r *dsMatchesDeclaredRule) Name() string { return "delegation_ds_matches_declared" }
|
||||
func (r *dsMatchesDeclaredRule) Description() string {
|
||||
return "Verifies that the DS RRset served by the parent matches the service's declared DS records"
|
||||
}
|
||||
func (r *dsMatchesDeclaredRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_ds_mismatch")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
declared := dsRecordsToMiekg(data.DeclaredDS)
|
||||
var out []sdk.CheckState
|
||||
for _, v := range data.ParentViews {
|
||||
if v.DSQueryError != "" {
|
||||
continue
|
||||
}
|
||||
got := dsRecordsToMiekg(v.DS)
|
||||
if len(declared) == 0 && len(got) == 0 {
|
||||
continue
|
||||
}
|
||||
missing, extra := diffDS(declared, got)
|
||||
st := sdk.CheckState{Code: "delegation_ds_mismatch", Subject: v.Server}
|
||||
if len(missing) == 0 && len(extra) == 0 {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = "DS RRset matches the declared service"
|
||||
} else {
|
||||
if len(declared) == 0 {
|
||||
st.Status = sdk.StatusWarn
|
||||
} else {
|
||||
st.Status = sdk.StatusCrit
|
||||
}
|
||||
st.Message = fmt.Sprintf("DS RRset does not match declared: missing=%d extra=%d", len(missing), len(extra))
|
||||
st.Meta = map[string]any{"missing": len(missing), "extra": len(extra)}
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "delegation_ds_mismatch",
|
||||
Message: "no DS data to compare",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type dsPresentAtParentRule struct{}
|
||||
|
||||
func (r *dsPresentAtParentRule) Name() string { return "delegation_ds_present_at_parent" }
|
||||
func (r *dsPresentAtParentRule) Description() string {
|
||||
return "Flags the case where the service declares DS records but the parent serves none"
|
||||
}
|
||||
func (r *dsPresentAtParentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_ds_missing")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
if len(data.DeclaredDS) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "delegation_ds_missing",
|
||||
Message: "service declares no DS records",
|
||||
}}
|
||||
}
|
||||
anyDS := false
|
||||
for _, v := range data.ParentViews {
|
||||
if v.DSQueryError == "" && len(v.DS) > 0 {
|
||||
anyDS = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if anyDS {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "delegation_ds_missing",
|
||||
Message: "parent serves DS records for the delegation",
|
||||
}}
|
||||
}
|
||||
status := sdk.StatusInfo
|
||||
if sdk.GetBoolOption(opts, "requireDS", false) {
|
||||
status = sdk.StatusCrit
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: status,
|
||||
Code: "delegation_ds_missing",
|
||||
Message: "service declares DS records but parent serves none",
|
||||
}}
|
||||
}
|
||||
|
||||
type dsRRSIGValidityRule struct{}
|
||||
|
||||
func (r *dsRRSIGValidityRule) Name() string { return "delegation_ds_rrsig_validity" }
|
||||
func (r *dsRRSIGValidityRule) Description() string {
|
||||
return "Verifies that every RRSIG covering the DS RRset is inside its validity window"
|
||||
}
|
||||
func (r *dsRRSIGValidityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_ds_rrsig_invalid")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
now := time.Now()
|
||||
var out []sdk.CheckState
|
||||
for _, v := range data.ParentViews {
|
||||
if v.DSQueryError != "" || len(v.DSRRSIGs) == 0 {
|
||||
continue
|
||||
}
|
||||
worst := sdk.StatusOK
|
||||
var reason string
|
||||
for _, sig := range v.DSRRSIGs {
|
||||
probe := &dns.RRSIG{Inception: sig.Inception, Expiration: sig.Expiration}
|
||||
if !probe.ValidityPeriod(now) {
|
||||
worst = sdk.StatusCrit
|
||||
reason = rrsigReason(sig, now)
|
||||
break
|
||||
}
|
||||
}
|
||||
st := sdk.CheckState{Code: "delegation_ds_rrsig_invalid", Subject: v.Server, Status: worst}
|
||||
if worst == sdk.StatusOK {
|
||||
st.Message = "DS RRSIG within validity window"
|
||||
} else {
|
||||
st.Message = fmt.Sprintf("DS RRSIG: %s", reason)
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "delegation_ds_rrsig_invalid",
|
||||
Message: "no DS RRSIG to evaluate",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rrsigReason distinguishes "not yet valid" from "expired"; miekg's
|
||||
// ValidityPeriod only returns a bool, so we redo the uint32-wraparound math.
|
||||
func rrsigReason(sig DSRRSIGObservation, now time.Time) string {
|
||||
utc := now.UTC().Unix()
|
||||
modi := (int64(sig.Inception) - utc) / year68
|
||||
ti := int64(sig.Inception) + modi*year68
|
||||
mode := (int64(sig.Expiration) - utc) / year68
|
||||
te := int64(sig.Expiration) + mode*year68
|
||||
switch {
|
||||
case ti > utc:
|
||||
return "signature not yet valid"
|
||||
case utc > te:
|
||||
return "signature expired"
|
||||
default:
|
||||
return "signature outside its validity window"
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── child-side rules ─────────────────────────
|
||||
|
||||
type nsResolvableRule struct{}
|
||||
|
||||
func (r *nsResolvableRule) Name() string { return "delegation_ns_resolvable" }
|
||||
func (r *nsResolvableRule) Description() string {
|
||||
return "Verifies that every out-of-bailiwick NS hostname resolves to at least one address"
|
||||
}
|
||||
func (r *nsResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_ns_unresolvable")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, c := range data.Children {
|
||||
if isInBailiwick(c.NSName, data.DelegatedFQDN) {
|
||||
continue
|
||||
}
|
||||
st := sdk.CheckState{Code: "delegation_ns_unresolvable", Subject: c.NSName}
|
||||
if c.ResolveError != "" {
|
||||
st.Status = sdk.StatusCrit
|
||||
st.Message = fmt.Sprintf("cannot resolve NS: %s", c.ResolveError)
|
||||
} else {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = fmt.Sprintf("%d address(es)", len(c.Addresses))
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "delegation_ns_unresolvable",
|
||||
Message: "no out-of-bailiwick NS to resolve",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type childReachableRule struct{}
|
||||
|
||||
func (r *childReachableRule) Name() string { return "delegation_child_reachable" }
|
||||
func (r *childReachableRule) Description() string {
|
||||
return "Verifies that every delegated name server address answers over UDP"
|
||||
}
|
||||
func (r *childReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_unreachable")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, c := range data.Children {
|
||||
for _, a := range c.Addresses {
|
||||
subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address)
|
||||
st := sdk.CheckState{Code: "delegation_unreachable", Subject: subject}
|
||||
if a.UDPError != "" {
|
||||
st.Status = sdk.StatusCrit
|
||||
st.Message = fmt.Sprintf("UDP SOA query failed: %s", a.UDPError)
|
||||
} else {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = "UDP SOA query succeeded"
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_unreachable",
|
||||
Message: "no delegated server address to probe",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type childAuthoritativeRule struct{}
|
||||
|
||||
func (r *childAuthoritativeRule) Name() string { return "delegation_child_authoritative" }
|
||||
func (r *childAuthoritativeRule) Description() string {
|
||||
return "Verifies that every reachable delegated server answers authoritatively (AA bit) for the zone"
|
||||
}
|
||||
func (r *childAuthoritativeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_lame")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, c := range data.Children {
|
||||
for _, a := range c.Addresses {
|
||||
if a.UDPError != "" {
|
||||
continue
|
||||
}
|
||||
subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address)
|
||||
st := sdk.CheckState{Code: "delegation_lame", Subject: subject}
|
||||
if !a.Authoritative {
|
||||
st.Status = sdk.StatusCrit
|
||||
st.Message = "server is not authoritative for the zone"
|
||||
} else {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = "authoritative answer"
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_lame",
|
||||
Message: "no reachable delegated server to probe",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type childSOASerialDriftRule struct{}
|
||||
|
||||
func (r *childSOASerialDriftRule) Name() string { return "delegation_child_soa_serial_drift" }
|
||||
func (r *childSOASerialDriftRule) Description() string {
|
||||
return "Verifies that all reachable addresses of a name server agree on the SOA serial"
|
||||
}
|
||||
func (r *childSOASerialDriftRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_soa_serial_drift")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, c := range data.Children {
|
||||
seen := map[uint32]bool{}
|
||||
for _, a := range c.Addresses {
|
||||
if a.SOASerialKnown {
|
||||
seen[a.SOASerial] = true
|
||||
}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
continue
|
||||
}
|
||||
st := sdk.CheckState{Code: "delegation_soa_serial_drift", Subject: c.NSName}
|
||||
if len(seen) > 1 {
|
||||
serials := make([]string, 0, len(seen))
|
||||
for s := range seen {
|
||||
serials = append(serials, fmt.Sprintf("%d", s))
|
||||
}
|
||||
st.Status = sdk.StatusWarn
|
||||
st.Message = fmt.Sprintf("SOA serial drift across addresses: %s", strings.Join(serials, ", "))
|
||||
} else {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = "all addresses agree on SOA serial"
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_soa_serial_drift",
|
||||
Message: "no SOA serial observed",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type childTCPRule struct{}
|
||||
|
||||
func (r *childTCPRule) Name() string { return "delegation_child_tcp" }
|
||||
func (r *childTCPRule) Description() string {
|
||||
return "Verifies that every reachable delegated server also answers over TCP"
|
||||
}
|
||||
func (r *childTCPRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_tcp_failed")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
requireTCP := sdk.GetBoolOption(opts, "requireTCP", true)
|
||||
failStatus := sdk.StatusCrit
|
||||
if !requireTCP {
|
||||
failStatus = sdk.StatusWarn
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, c := range data.Children {
|
||||
for _, a := range c.Addresses {
|
||||
if a.UDPError != "" {
|
||||
continue
|
||||
}
|
||||
subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address)
|
||||
st := sdk.CheckState{Code: "delegation_tcp_failed", Subject: subject}
|
||||
if a.TCPError != "" {
|
||||
st.Status = failStatus
|
||||
st.Message = fmt.Sprintf("TCP SOA query failed: %s", a.TCPError)
|
||||
} else {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = "TCP reachable"
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_tcp_failed",
|
||||
Message: "no reachable delegated server to probe",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type childNSMatchesParentRule struct{}
|
||||
|
||||
func (r *childNSMatchesParentRule) Name() string { return "delegation_child_ns_matches_parent" }
|
||||
func (r *childNSMatchesParentRule) Description() string {
|
||||
return "Verifies that the NS RRset served by each delegated server agrees with the parent's view"
|
||||
}
|
||||
func (r *childNSMatchesParentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_ns_drift")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
primary := primaryParentView(data.ParentViews)
|
||||
if primary == nil {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_ns_drift",
|
||||
Message: "no parent NS RRset to compare against",
|
||||
}}
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, c := range data.Children {
|
||||
for _, a := range c.Addresses {
|
||||
if a.UDPError != "" || a.ChildNSError != "" {
|
||||
continue
|
||||
}
|
||||
subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address)
|
||||
missing, extra := diffStringSets(primary.NS, a.ChildNS)
|
||||
st := sdk.CheckState{Code: "delegation_ns_drift", Subject: subject}
|
||||
if len(missing) > 0 || len(extra) > 0 {
|
||||
st.Status = sdk.StatusWarn
|
||||
st.Message = fmt.Sprintf("child NS RRset differs from parent: missing=%v extra=%v", missing, extra)
|
||||
st.Meta = map[string]any{"missing": missing, "extra": extra}
|
||||
} else {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = "child NS RRset matches parent"
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_ns_drift",
|
||||
Message: "no child NS RRset observed",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type childGlueMatchesParentRule struct{}
|
||||
|
||||
func (r *childGlueMatchesParentRule) Name() string { return "delegation_child_glue_matches_parent" }
|
||||
func (r *childGlueMatchesParentRule) Description() string {
|
||||
return "Verifies that the addresses served by the child for in-bailiwick NS names match the parent glue"
|
||||
}
|
||||
func (r *childGlueMatchesParentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_glue_mismatch")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
primary := primaryParentView(data.ParentViews)
|
||||
if primary == nil {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_glue_mismatch",
|
||||
Message: "no parent glue to compare against",
|
||||
}}
|
||||
}
|
||||
allow := sdk.GetBoolOption(opts, "allowGlueMismatch", false)
|
||||
failStatus := sdk.StatusCrit
|
||||
if allow {
|
||||
failStatus = sdk.StatusWarn
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, c := range data.Children {
|
||||
if !isInBailiwick(c.NSName, data.DelegatedFQDN) {
|
||||
continue
|
||||
}
|
||||
for _, a := range c.Addresses {
|
||||
if a.UDPError != "" {
|
||||
continue
|
||||
}
|
||||
subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address)
|
||||
// Extras are allowed: child may have more interfaces than the
|
||||
// parent publishes; only missing parent-glue matters.
|
||||
missing, _ := diffStringSets(primary.Glue[c.NSName], a.ChildGlueAddrs)
|
||||
st := sdk.CheckState{Code: "delegation_glue_mismatch", Subject: subject}
|
||||
if len(missing) > 0 {
|
||||
st.Status = failStatus
|
||||
st.Message = fmt.Sprintf("child addresses for %s differ from parent glue: missing=%v", c.NSName, missing)
|
||||
st.Meta = map[string]any{"missing": missing}
|
||||
} else {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = "child glue matches parent"
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
}
|
||||
// No in-bailiwick NS means there's no glue to compare; stay silent.
|
||||
return out
|
||||
}
|
||||
|
||||
// ───────────────────────── DNSSEC rules ─────────────────────────
|
||||
|
||||
func parentHasAnyDS(views []ParentView) bool {
|
||||
for _, v := range views {
|
||||
if len(v.DS) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type dnskeyQueryRule struct{}
|
||||
|
||||
func (r *dnskeyQueryRule) Name() string { return "delegation_dnskey_query" }
|
||||
func (r *dnskeyQueryRule) Description() string {
|
||||
return "Verifies that the delegated servers answer DNSKEY queries when the parent publishes DS records"
|
||||
}
|
||||
func (r *dnskeyQueryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_dnskey_query_failed")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
if !parentHasAnyDS(data.ParentViews) {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_dnskey_query_failed",
|
||||
Message: "parent has no DS records, DNSKEY probe skipped",
|
||||
}}
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, c := range data.Children {
|
||||
for _, a := range c.Addresses {
|
||||
if a.UDPError != "" {
|
||||
continue
|
||||
}
|
||||
subject := fmt.Sprintf("%s (%s)", c.NSName, a.Address)
|
||||
st := sdk.CheckState{Code: "delegation_dnskey_query_failed", Subject: subject}
|
||||
if a.DNSKEYError != "" {
|
||||
st.Status = sdk.StatusWarn
|
||||
st.Message = fmt.Sprintf("DNSKEY query failed: %s", a.DNSKEYError)
|
||||
} else {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = fmt.Sprintf("%d DNSKEY record(s) returned", len(a.DNSKEYs))
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_dnskey_query_failed",
|
||||
Message: "no reachable child server to probe",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type dnskeyMatchesDSRule struct{}
|
||||
|
||||
func (r *dnskeyMatchesDSRule) Name() string { return "delegation_dnskey_matches_ds" }
|
||||
func (r *dnskeyMatchesDSRule) Description() string {
|
||||
return "Verifies that at least one DNSKEY served by the child hashes to one of the DS records at the parent"
|
||||
}
|
||||
func (r *dnskeyMatchesDSRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_dnskey_no_match")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
if !parentHasAnyDS(data.ParentViews) {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_dnskey_no_match",
|
||||
Message: "parent has no DS records, DNSKEY/DS match skipped",
|
||||
}}
|
||||
}
|
||||
var parentDS []*dns.DS
|
||||
for _, v := range data.ParentViews {
|
||||
if len(v.DS) > 0 {
|
||||
parentDS = dsRecordsToMiekg(v.DS)
|
||||
break
|
||||
}
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, c := range data.Children {
|
||||
var keys []*dns.DNSKEY
|
||||
probed := false
|
||||
for _, a := range c.Addresses {
|
||||
if len(a.DNSKEYs) > 0 {
|
||||
probed = true
|
||||
keys = append(keys, dnskeysToMiekg(a.DNSKEYs)...)
|
||||
}
|
||||
}
|
||||
if !probed {
|
||||
continue
|
||||
}
|
||||
st := sdk.CheckState{Code: "delegation_dnskey_no_match", Subject: c.NSName}
|
||||
if dsMatchesAnyKey(parentDS, keys) {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = "at least one DNSKEY matches a parent DS record"
|
||||
} else {
|
||||
st.Status = sdk.StatusCrit
|
||||
st.Message = "no DNSKEY served by this NS matches any parent DS record"
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_dnskey_no_match",
|
||||
Message: "no DNSKEY observed at any child server",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type nsHasAuthoritativeAnswerRule struct{}
|
||||
|
||||
func (r *nsHasAuthoritativeAnswerRule) Name() string {
|
||||
return "delegation_ns_has_authoritative_answer"
|
||||
}
|
||||
func (r *nsHasAuthoritativeAnswerRule) Description() string {
|
||||
return "Verifies that every delegated NS produced at least one authoritative answer across all its addresses"
|
||||
}
|
||||
func (r *nsHasAuthoritativeAnswerRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errState := loadData(ctx, obs, "delegation_no_authoritative_answer")
|
||||
if errState != nil {
|
||||
return errState
|
||||
}
|
||||
var out []sdk.CheckState
|
||||
for _, c := range data.Children {
|
||||
if len(c.Addresses) == 0 {
|
||||
continue
|
||||
}
|
||||
sawAA := false
|
||||
for _, a := range c.Addresses {
|
||||
if a.UDPError == "" && a.Authoritative {
|
||||
sawAA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
st := sdk.CheckState{Code: "delegation_no_authoritative_answer", Subject: c.NSName}
|
||||
if sawAA {
|
||||
st.Status = sdk.StatusOK
|
||||
st.Message = "at least one address answered authoritatively"
|
||||
} else {
|
||||
st.Status = sdk.StatusCrit
|
||||
st.Message = "no address of this NS answered authoritatively"
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Code: "delegation_no_authoritative_answer",
|
||||
Message: "no delegated NS to probe",
|
||||
}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
439
checker/rule_test.go
Normal file
439
checker/rule_test.go
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// fakeObs is a tiny ObservationGetter that serves a single pre-built
|
||||
// DelegationData payload for the delegation key.
|
||||
type fakeObs struct {
|
||||
data *DelegationData
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
if key != ObservationKeyDelegation {
|
||||
return &errString{"unexpected key " + string(key)}
|
||||
}
|
||||
raw, err := json.Marshal(f.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
|
||||
func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type errString struct{ s string }
|
||||
|
||||
func (e *errString) Error() string { return e.s }
|
||||
|
||||
// statusByCode indexes states by Code, asserting one state per code.
|
||||
func statusByCode(t *testing.T, states []sdk.CheckState) map[string]sdk.CheckState {
|
||||
t.Helper()
|
||||
out := map[string]sdk.CheckState{}
|
||||
for _, s := range states {
|
||||
out[s.Code+"|"+s.Subject] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func evalRule(t *testing.T, r sdk.CheckRule, data *DelegationData, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
t.Helper()
|
||||
if opts == nil {
|
||||
opts = sdk.CheckerOptions{}
|
||||
}
|
||||
return r.Evaluate(context.Background(), &fakeObs{data: data}, opts)
|
||||
}
|
||||
|
||||
func TestMinNameServersRule(t *testing.T) {
|
||||
r := &minNameServersRule{}
|
||||
|
||||
t.Run("warn when below default minimum", func(t *testing.T) {
|
||||
states := evalRule(t, r, &DelegationData{DeclaredNS: []string{"a."}}, nil)
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
|
||||
t.Fatalf("want one Warn state, got %+v", states)
|
||||
}
|
||||
})
|
||||
t.Run("ok when at minimum", func(t *testing.T) {
|
||||
states := evalRule(t, r, &DelegationData{DeclaredNS: []string{"a.", "b."}}, nil)
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusOK {
|
||||
t.Fatalf("want one OK state, got %+v", states)
|
||||
}
|
||||
})
|
||||
t.Run("respects custom minimum", func(t *testing.T) {
|
||||
opts := sdk.CheckerOptions{"minNameServers": float64(3)}
|
||||
states := evalRule(t, r, &DelegationData{DeclaredNS: []string{"a.", "b."}}, opts)
|
||||
if states[0].Status != sdk.StatusWarn {
|
||||
t.Fatalf("want Warn with min=3 and 2 NS, got %+v", states)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParentDiscoveredRule(t *testing.T) {
|
||||
r := &parentDiscoveredRule{}
|
||||
cases := []struct {
|
||||
name string
|
||||
data *DelegationData
|
||||
want sdk.Status
|
||||
}{
|
||||
{"discovery error", &DelegationData{ParentDiscoveryError: "boom"}, sdk.StatusCrit},
|
||||
{"no parent ns", &DelegationData{}, sdk.StatusCrit},
|
||||
{"ok", &DelegationData{ParentNS: []string{"1.2.3.4:53"}}, sdk.StatusOK},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
states := evalRule(t, r, tc.data, nil)
|
||||
if len(states) != 1 || states[0].Status != tc.want {
|
||||
t.Fatalf("want %v, got %+v", tc.want, states)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSMatchesDeclaredRule(t *testing.T) {
|
||||
r := &nsMatchesDeclaredRule{}
|
||||
data := &DelegationData{
|
||||
DelegatedFQDN: "www.example.com.",
|
||||
DeclaredNS: []string{"ns1.example.net.", "ns2.example.net."},
|
||||
ParentViews: []ParentView{
|
||||
{Server: "p1:53", NS: []string{"ns1.example.net.", "ns2.example.net."}}, // match
|
||||
{Server: "p2:53", NS: []string{"ns1.example.net.", "ns3.example.net."}}, // mismatch
|
||||
{Server: "p3:53", UDPNSError: "timeout"}, // skipped
|
||||
},
|
||||
}
|
||||
states := evalRule(t, r, data, nil)
|
||||
idx := statusByCode(t, states)
|
||||
if s := idx["delegation_ns_mismatch|p1:53"]; s.Status != sdk.StatusOK {
|
||||
t.Errorf("p1: want OK, got %+v", s)
|
||||
}
|
||||
if s := idx["delegation_ns_mismatch|p2:53"]; s.Status != sdk.StatusCrit {
|
||||
t.Errorf("p2: want Crit, got %+v", s)
|
||||
}
|
||||
if _, ok := idx["delegation_ns_mismatch|p3:53"]; ok {
|
||||
t.Errorf("p3 should be skipped, got %+v", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInBailiwickGlueRule(t *testing.T) {
|
||||
r := &inBailiwickGlueRule{}
|
||||
data := &DelegationData{
|
||||
DelegatedFQDN: "example.com.",
|
||||
ParentViews: []ParentView{{
|
||||
Server: "p:53",
|
||||
NS: []string{"ns1.example.com.", "ns2.elsewhere.net."},
|
||||
Glue: map[string][]string{
|
||||
"ns1.example.com.": {"192.0.2.1"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
states := evalRule(t, r, data, nil)
|
||||
|
||||
var sawOK, sawMissing, sawOOB bool
|
||||
for _, s := range states {
|
||||
switch {
|
||||
case strings.HasPrefix(s.Subject, "ns1.example.com."):
|
||||
if s.Status == sdk.StatusOK {
|
||||
sawOK = true
|
||||
}
|
||||
case strings.HasPrefix(s.Subject, "ns2.elsewhere.net."):
|
||||
sawOOB = true // out-of-bailiwick: rule must not emit a state for it
|
||||
}
|
||||
if s.Status == sdk.StatusCrit {
|
||||
sawMissing = true
|
||||
}
|
||||
}
|
||||
if !sawOK {
|
||||
t.Error("expected OK state for in-bailiwick NS with glue")
|
||||
}
|
||||
if sawMissing {
|
||||
t.Error("did not expect Crit (no in-bailiwick NS is missing glue)")
|
||||
}
|
||||
if sawOOB {
|
||||
t.Error("out-of-bailiwick NS must be ignored by inBailiwickGlueRule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnnecessaryGlueRule(t *testing.T) {
|
||||
r := &unnecessaryGlueRule{}
|
||||
data := &DelegationData{
|
||||
DelegatedFQDN: "example.com.",
|
||||
ParentViews: []ParentView{{
|
||||
Server: "p:53",
|
||||
NS: []string{"ns1.elsewhere.net."},
|
||||
Glue: map[string][]string{"ns1.elsewhere.net.": {"192.0.2.5"}},
|
||||
}},
|
||||
}
|
||||
states := evalRule(t, r, data, nil)
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
|
||||
t.Fatalf("want single Warn, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDSPresentAtParentRule_RequireDS(t *testing.T) {
|
||||
r := &dsPresentAtParentRule{}
|
||||
data := &DelegationData{
|
||||
DeclaredDS: []DSRecord{{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "AAAA"}},
|
||||
ParentViews: []ParentView{{Server: "p:53"}}, // no DS at parent
|
||||
}
|
||||
t.Run("default is informational", func(t *testing.T) {
|
||||
states := evalRule(t, r, data, nil)
|
||||
if states[0].Status != sdk.StatusInfo {
|
||||
t.Fatalf("want Info, got %+v", states)
|
||||
}
|
||||
})
|
||||
t.Run("requireDS escalates to Crit", func(t *testing.T) {
|
||||
states := evalRule(t, r, data, sdk.CheckerOptions{"requireDS": true})
|
||||
if states[0].Status != sdk.StatusCrit {
|
||||
t.Fatalf("want Crit with requireDS, got %+v", states)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChildAuthoritativeRule(t *testing.T) {
|
||||
r := &childAuthoritativeRule{}
|
||||
data := &DelegationData{
|
||||
Children: []ChildNSView{{
|
||||
NSName: "ns1.example.com.",
|
||||
Addresses: []ChildAddressView{
|
||||
{Address: "192.0.2.1", Authoritative: true},
|
||||
{Address: "192.0.2.2", Authoritative: false},
|
||||
{Address: "192.0.2.3", UDPError: "timeout"}, // skipped
|
||||
},
|
||||
}},
|
||||
}
|
||||
states := evalRule(t, r, data, nil)
|
||||
if len(states) != 2 {
|
||||
t.Fatalf("want 2 states (skip the UDP failure), got %d: %+v", len(states), states)
|
||||
}
|
||||
var foundCrit bool
|
||||
for _, s := range states {
|
||||
if s.Status == sdk.StatusCrit {
|
||||
foundCrit = true
|
||||
}
|
||||
}
|
||||
if !foundCrit {
|
||||
t.Error("expected at least one Crit (the lame address)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSOASerialDriftRule(t *testing.T) {
|
||||
r := &childSOASerialDriftRule{}
|
||||
data := &DelegationData{
|
||||
Children: []ChildNSView{{
|
||||
NSName: "ns1.example.com.",
|
||||
Addresses: []ChildAddressView{
|
||||
{Address: "192.0.2.1", SOASerial: 1, SOASerialKnown: true},
|
||||
{Address: "192.0.2.2", SOASerial: 2, SOASerialKnown: true},
|
||||
},
|
||||
}, {
|
||||
NSName: "ns2.example.com.",
|
||||
Addresses: []ChildAddressView{
|
||||
{Address: "192.0.2.3", SOASerial: 7, SOASerialKnown: true},
|
||||
{Address: "192.0.2.4", SOASerial: 7, SOASerialKnown: true},
|
||||
},
|
||||
}},
|
||||
}
|
||||
states := evalRule(t, r, data, nil)
|
||||
if len(states) != 2 {
|
||||
t.Fatalf("want 2 states, got %d", len(states))
|
||||
}
|
||||
bySubject := map[string]sdk.Status{}
|
||||
for _, s := range states {
|
||||
bySubject[s.Subject] = s.Status
|
||||
}
|
||||
if bySubject["ns1.example.com."] != sdk.StatusWarn {
|
||||
t.Errorf("ns1 drift: want Warn, got %v", bySubject["ns1.example.com."])
|
||||
}
|
||||
if bySubject["ns2.example.com."] != sdk.StatusOK {
|
||||
t.Errorf("ns2 agreement: want OK, got %v", bySubject["ns2.example.com."])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildTCPRule_OptionToggle(t *testing.T) {
|
||||
r := &childTCPRule{}
|
||||
data := &DelegationData{
|
||||
Children: []ChildNSView{{
|
||||
NSName: "ns1.example.com.",
|
||||
Addresses: []ChildAddressView{
|
||||
{Address: "192.0.2.1", TCPError: "connection refused"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
t.Run("default requireTCP=true → Crit", func(t *testing.T) {
|
||||
states := evalRule(t, r, data, nil)
|
||||
if states[0].Status != sdk.StatusCrit {
|
||||
t.Fatalf("want Crit, got %+v", states)
|
||||
}
|
||||
})
|
||||
t.Run("requireTCP=false → Warn", func(t *testing.T) {
|
||||
states := evalRule(t, r, data, sdk.CheckerOptions{"requireTCP": false})
|
||||
if states[0].Status != sdk.StatusWarn {
|
||||
t.Fatalf("want Warn, got %+v", states)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChildGlueMatchesParentRule(t *testing.T) {
|
||||
r := &childGlueMatchesParentRule{}
|
||||
data := &DelegationData{
|
||||
DelegatedFQDN: "example.com.",
|
||||
ParentViews: []ParentView{{
|
||||
Server: "p:53",
|
||||
NS: []string{"ns1.example.com."},
|
||||
Glue: map[string][]string{"ns1.example.com.": {"192.0.2.1", "192.0.2.2"}},
|
||||
}},
|
||||
Children: []ChildNSView{{
|
||||
NSName: "ns1.example.com.",
|
||||
Addresses: []ChildAddressView{
|
||||
{Address: "192.0.2.1", ChildGlueAddrs: []string{"192.0.2.1"}}, // missing .2 → mismatch
|
||||
},
|
||||
}},
|
||||
}
|
||||
t.Run("default → Crit", func(t *testing.T) {
|
||||
states := evalRule(t, r, data, nil)
|
||||
if states[0].Status != sdk.StatusCrit {
|
||||
t.Fatalf("want Crit, got %+v", states)
|
||||
}
|
||||
})
|
||||
t.Run("allowGlueMismatch → Warn", func(t *testing.T) {
|
||||
states := evalRule(t, r, data, sdk.CheckerOptions{"allowGlueMismatch": true})
|
||||
if states[0].Status != sdk.StatusWarn {
|
||||
t.Fatalf("want Warn, got %+v", states)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNSHasAuthoritativeAnswerRule(t *testing.T) {
|
||||
r := &nsHasAuthoritativeAnswerRule{}
|
||||
data := &DelegationData{
|
||||
Children: []ChildNSView{
|
||||
{
|
||||
NSName: "ok.example.com.",
|
||||
Addresses: []ChildAddressView{
|
||||
{Address: "192.0.2.1", Authoritative: false},
|
||||
{Address: "192.0.2.2", Authoritative: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
NSName: "lame.example.com.",
|
||||
Addresses: []ChildAddressView{
|
||||
{Address: "192.0.2.3", Authoritative: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
states := evalRule(t, r, data, nil)
|
||||
bySubject := map[string]sdk.Status{}
|
||||
for _, s := range states {
|
||||
bySubject[s.Subject] = s.Status
|
||||
}
|
||||
if bySubject["ok.example.com."] != sdk.StatusOK {
|
||||
t.Errorf("ok.example.com.: want OK, got %v", bySubject["ok.example.com."])
|
||||
}
|
||||
if bySubject["lame.example.com."] != sdk.StatusCrit {
|
||||
t.Errorf("lame.example.com.: want Crit, got %v", bySubject["lame.example.com."])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSKEYMatchesDSRule_Match(t *testing.T) {
|
||||
// Build a key, derive its DS, and verify the rule passes when child serves
|
||||
// that key and parent serves that DS.
|
||||
key := &dns.DNSKEY{
|
||||
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET},
|
||||
Flags: 257,
|
||||
Protocol: 3,
|
||||
Algorithm: dns.RSASHA256,
|
||||
PublicKey: "AwEAAcMnWBKLuvG/LwnPVykcmpvnntwxfshHlHRhlY0F3oz8AMcuF8gw" +
|
||||
"2Ge56vG9oqVxTzHl4Ss2dEqCQOjFlOVo+pa3JwIO1lUzbQ==",
|
||||
}
|
||||
ds := key.ToDS(dns.SHA256)
|
||||
if ds == nil {
|
||||
t.Fatal("derive DS")
|
||||
}
|
||||
|
||||
data := &DelegationData{
|
||||
ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{NewDSRecord(ds)}}},
|
||||
Children: []ChildNSView{{
|
||||
NSName: "ns1.example.com.",
|
||||
Addresses: []ChildAddressView{
|
||||
{Address: "192.0.2.1", DNSKEYs: []DNSKEYRecord{NewDNSKEYRecord(key)}},
|
||||
},
|
||||
}},
|
||||
}
|
||||
r := &dnskeyMatchesDSRule{}
|
||||
states := evalRule(t, r, data, nil)
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusOK {
|
||||
t.Fatalf("want OK match, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSKEYMatchesDSRule_NoMatch(t *testing.T) {
|
||||
key := &dns.DNSKEY{
|
||||
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET},
|
||||
Flags: 257, Protocol: 3, Algorithm: dns.RSASHA256,
|
||||
PublicKey: "AwEAAcMnWBKLuvG/LwnPVykcmpvnntwxfshHlHRhlY0F3oz8AMcuF8gw" +
|
||||
"2Ge56vG9oqVxTzHl4Ss2dEqCQOjFlOVo+pa3JwIO1lUzbQ==",
|
||||
}
|
||||
bogus := &dns.DS{KeyTag: 1, Algorithm: 8, DigestType: 2, Digest: "00"}
|
||||
data := &DelegationData{
|
||||
ParentViews: []ParentView{{Server: "p:53", DS: []DSRecord{NewDSRecord(bogus)}}},
|
||||
Children: []ChildNSView{{
|
||||
NSName: "ns1.example.com.",
|
||||
Addresses: []ChildAddressView{
|
||||
{Address: "192.0.2.1", DNSKEYs: []DNSKEYRecord{NewDNSKEYRecord(key)}},
|
||||
},
|
||||
}},
|
||||
}
|
||||
states := evalRule(t, (&dnskeyMatchesDSRule{}), data, nil)
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusCrit {
|
||||
t.Fatalf("want Crit, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesReturnsAllRules(t *testing.T) {
|
||||
rules := Rules()
|
||||
if len(rules) == 0 {
|
||||
t.Fatal("expected at least one rule")
|
||||
}
|
||||
// Every rule must have a non-empty name and description, and must be
|
||||
// safely evaluable against an empty DelegationData (no panics).
|
||||
seen := map[string]bool{}
|
||||
for _, r := range rules {
|
||||
if r.Name() == "" {
|
||||
t.Errorf("rule %T has empty name", r)
|
||||
}
|
||||
if r.Description() == "" {
|
||||
t.Errorf("rule %s has empty description", r.Name())
|
||||
}
|
||||
if seen[r.Name()] {
|
||||
t.Errorf("duplicate rule name: %s", r.Name())
|
||||
}
|
||||
seen[r.Name()] = true
|
||||
|
||||
states := r.Evaluate(context.Background(), &fakeObs{data: &DelegationData{}}, sdk.CheckerOptions{})
|
||||
if len(states) == 0 {
|
||||
t.Errorf("rule %s returned no states for empty data", r.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDataPropagatesError(t *testing.T) {
|
||||
r := &minNameServersRule{}
|
||||
states := r.Evaluate(context.Background(), &fakeObs{err: &errString{"boom"}}, sdk.CheckerOptions{})
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusError {
|
||||
t.Fatalf("want single Error state, got %+v", states)
|
||||
}
|
||||
}
|
||||
143
checker/types.go
Normal file
143
checker/types.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
const ObservationKeyDelegation = "delegation"
|
||||
|
||||
// DelegationData is the raw, judgment-free observation produced by Collect.
|
||||
// Severity classification belongs to the rules, not to the data.
|
||||
type DelegationData struct {
|
||||
DelegatedFQDN string `json:"delegated_fqdn"`
|
||||
ParentZone string `json:"parent_zone"`
|
||||
|
||||
// DeclaredNS/DeclaredDS come from the service definition,
|
||||
// lowercased and FQDN-normalized for direct comparison.
|
||||
DeclaredNS []string `json:"declared_ns,omitempty"`
|
||||
DeclaredDS []DSRecord `json:"declared_ds,omitempty"`
|
||||
|
||||
ParentDiscoveryError string `json:"parent_discovery_error,omitempty"`
|
||||
ParentNS []string `json:"parent_ns,omitempty"`
|
||||
ParentViews []ParentView `json:"parent_views,omitempty"`
|
||||
|
||||
// Children is seeded from the first successful parent view only.
|
||||
Children []ChildNSView `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type ParentView struct {
|
||||
Server string `json:"server"`
|
||||
UDPNSError string `json:"udp_ns_error,omitempty"`
|
||||
TCPNSError string `json:"tcp_ns_error,omitempty"`
|
||||
NS []string `json:"ns,omitempty"`
|
||||
Glue map[string][]string `json:"glue,omitempty"`
|
||||
DSQueryError string `json:"ds_query_error,omitempty"`
|
||||
DS []DSRecord `json:"ds,omitempty"`
|
||||
DSRRSIGs []DSRRSIGObservation `json:"ds_rrsigs,omitempty"`
|
||||
}
|
||||
|
||||
type ChildNSView struct {
|
||||
NSName string `json:"ns_name"`
|
||||
ResolveError string `json:"resolve_error,omitempty"`
|
||||
Addresses []ChildAddressView `json:"addresses,omitempty"`
|
||||
}
|
||||
|
||||
type ChildAddressView struct {
|
||||
Address string `json:"address"`
|
||||
Server string `json:"server"`
|
||||
UDPError string `json:"udp_error,omitempty"`
|
||||
Authoritative bool `json:"authoritative"`
|
||||
SOASerial uint32 `json:"soa_serial,omitempty"`
|
||||
SOASerialKnown bool `json:"soa_serial_known,omitempty"`
|
||||
TCPError string `json:"tcp_error,omitempty"`
|
||||
ChildNS []string `json:"child_ns,omitempty"`
|
||||
ChildNSError string `json:"child_ns_error,omitempty"`
|
||||
ChildGlueAddrs []string `json:"child_glue_addrs,omitempty"`
|
||||
DNSKEYError string `json:"dnskey_error,omitempty"`
|
||||
DNSKEYs []DNSKEYRecord `json:"dnskeys,omitempty"`
|
||||
}
|
||||
|
||||
// DSRecord keeps both the rendered text (for humans) and the structured
|
||||
// fields (for direct comparison).
|
||||
type DSRecord struct {
|
||||
Text string `json:"text"`
|
||||
KeyTag uint16 `json:"keytag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
DigestType uint8 `json:"digest_type"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
func (d DSRecord) ToMiekg() *dns.DS {
|
||||
return &dns.DS{
|
||||
KeyTag: d.KeyTag,
|
||||
Algorithm: d.Algorithm,
|
||||
DigestType: d.DigestType,
|
||||
Digest: d.Digest,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDSRecord(d *dns.DS) DSRecord {
|
||||
return DSRecord{
|
||||
Text: d.String(),
|
||||
KeyTag: d.KeyTag,
|
||||
Algorithm: d.Algorithm,
|
||||
DigestType: d.DigestType,
|
||||
Digest: d.Digest,
|
||||
}
|
||||
}
|
||||
|
||||
// DNSKEYRecord keeps the fields needed to recompute DS digests.
|
||||
type DNSKEYRecord struct {
|
||||
Name string `json:"name"`
|
||||
Flags uint16 `json:"flags"`
|
||||
Protocol uint8 `json:"protocol"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
|
||||
// ToMiekg restores miekg form so k.ToDS is callable.
|
||||
func (k DNSKEYRecord) ToMiekg() *dns.DNSKEY {
|
||||
name := k.Name
|
||||
if name == "" {
|
||||
name = "."
|
||||
}
|
||||
return &dns.DNSKEY{
|
||||
Hdr: dns.RR_Header{Name: dns.Fqdn(name), Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET},
|
||||
Flags: k.Flags,
|
||||
Protocol: k.Protocol,
|
||||
Algorithm: k.Algorithm,
|
||||
PublicKey: k.PublicKey,
|
||||
}
|
||||
}
|
||||
|
||||
func NewDNSKEYRecord(k *dns.DNSKEY) DNSKEYRecord {
|
||||
return DNSKEYRecord{
|
||||
Name: k.Hdr.Name,
|
||||
Flags: k.Flags,
|
||||
Protocol: k.Protocol,
|
||||
Algorithm: k.Algorithm,
|
||||
PublicKey: k.PublicKey,
|
||||
}
|
||||
}
|
||||
|
||||
// DSRRSIGObservation: rules judge validity, not Collect.
|
||||
type DSRRSIGObservation struct {
|
||||
Inception uint32 `json:"inception"`
|
||||
Expiration uint32 `json:"expiration"`
|
||||
}
|
||||
|
||||
// delegationService mirrors abstract.Delegation locally so this checker
|
||||
// avoids importing the (heavy) happyDomain server module.
|
||||
type delegationService struct {
|
||||
NameServers []*dns.NS `json:"ns"`
|
||||
DS []*dns.DS `json:"ds"`
|
||||
}
|
||||
|
||||
// serviceMessage mirrors happyDomain's envelope; only the embedded JSON
|
||||
// is used downstream.
|
||||
type serviceMessage struct {
|
||||
Type string `json:"_svctype"`
|
||||
Service json.RawMessage `json:"Service"`
|
||||
}
|
||||
16
go.mod
Normal file
16
go.mod
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
module git.happydns.org/checker-delegation
|
||||
|
||||
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=
|
||||
25
main.go
Normal file
25
main.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
delegation "git.happydns.org/checker-delegation/checker"
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
)
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
// Version is overridden at link time: -ldflags "-X main.Version=1.2.3".
|
||||
var Version = "custom-build"
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
delegation.Version = Version
|
||||
|
||||
srv := server.New(delegation.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 Go-plugin entrypoint, loaded at runtime.
|
||||
package main
|
||||
|
||||
import (
|
||||
delegation "git.happydns.org/checker-delegation/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is overridden at link time: -ldflags "-X main.Version=1.2.3".
|
||||
var Version = "custom-build"
|
||||
|
||||
// NewCheckerPlugin is the symbol happyDomain resolves on plugin load.
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
delegation.Version = Version
|
||||
prvd := delegation.Provider()
|
||||
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue