Initial commit
This commit is contained in:
commit
00de0f9780
16 changed files with 1351 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
checker-delegation
|
||||||
|
*.so
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
ARG CHECKER_VERSION=custom-build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-delegation .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /checker-delegation /checker-delegation
|
||||||
|
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.
|
||||||
25
Makefile
Normal file
25
Makefile
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
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 clean
|
||||||
|
|
||||||
|
all: $(CHECKER_NAME)
|
||||||
|
|
||||||
|
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||||
|
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||||
|
|
||||||
|
plugin: $(CHECKER_NAME).so
|
||||||
|
|
||||||
|
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||||
|
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||||
122
README.md
Normal file
122
README.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
496
checker/collect.go
Normal file
496
checker/collect.go
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect runs the delegation testsuite and returns a *DelegationData
|
||||||
|
// populated with findings.
|
||||||
|
//
|
||||||
|
// The collector resolves the parent zone's authoritative servers, asks each
|
||||||
|
// of them for the delegation of the target FQDN, then turns around and
|
||||||
|
// queries every delegated server using ONLY the NS names + glue learned
|
||||||
|
// from the parent. The child zone is never used 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 subdomain == "" {
|
||||||
|
return nil, fmt.Errorf("missing 'subdomain' option")
|
||||||
|
}
|
||||||
|
if parentZone == "" {
|
||||||
|
return nil, fmt.Errorf("missing 'domain_name' option")
|
||||||
|
}
|
||||||
|
|
||||||
|
delegatedFQDN := dns.Fqdn(strings.TrimSuffix(subdomain, ".") + "." + strings.TrimSuffix(parentZone, ".") + ".")
|
||||||
|
|
||||||
|
data := &DelegationData{
|
||||||
|
DelegatedFQDN: delegatedFQDN,
|
||||||
|
ParentZone: dns.Fqdn(parentZone),
|
||||||
|
}
|
||||||
|
|
||||||
|
requireDS := sdk.GetBoolOption(opts, "requireDS", false)
|
||||||
|
requireTCP := sdk.GetBoolOption(opts, "requireTCP", true)
|
||||||
|
minNS := sdk.GetIntOption(opts, "minNameServers", 2)
|
||||||
|
allowGlueMismatch := sdk.GetBoolOption(opts, "allowGlueMismatch", false)
|
||||||
|
|
||||||
|
// Declared NS / DS from the service.
|
||||||
|
declaredNS := normalizeNSList(svc.NameServers)
|
||||||
|
if len(declaredNS) < minNS {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_too_few_ns",
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: fmt.Sprintf("only %d name server(s) declared, RFC 1034 recommends at least %d", len(declaredNS), minNS),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve parent's authoritative servers.
|
||||||
|
_, parentServers, err := findParentZone(ctx, delegatedFQDN, parentZone)
|
||||||
|
if err != nil {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_no_parent_ns",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
data.ParentNS = parentServers
|
||||||
|
|
||||||
|
// Phase A: query every parent server.
|
||||||
|
type parentView struct {
|
||||||
|
server string
|
||||||
|
ns []string
|
||||||
|
glue map[string][]string
|
||||||
|
ds []*dns.DS
|
||||||
|
}
|
||||||
|
var views []parentView
|
||||||
|
|
||||||
|
for _, ps := range parentServers {
|
||||||
|
ns, glue, _, qerr := queryDelegation(ctx, ps, delegatedFQDN)
|
||||||
|
if qerr != nil {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_parent_query_failed",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("parent NS query failed: %v", qerr),
|
||||||
|
Server: ps,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(ns) == 0 {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_no_parent_ns",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: "parent returned an empty NS RRset",
|
||||||
|
Server: ps,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCP reachability of the parent for the same query.
|
||||||
|
if _, _, _, terr := queryDelegationTCP(ctx, ps, delegatedFQDN); terr != nil {
|
||||||
|
sev := SeverityCrit
|
||||||
|
if !requireTCP {
|
||||||
|
sev = SeverityWarn
|
||||||
|
}
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_parent_tcp_failed",
|
||||||
|
Severity: sev,
|
||||||
|
Message: fmt.Sprintf("parent NS query over TCP failed: %v", terr),
|
||||||
|
Server: ps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare NS to the declared list.
|
||||||
|
missing, extra := diffStringSets(declaredNS, ns)
|
||||||
|
if len(missing) > 0 || len(extra) > 0 {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_ns_mismatch",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("NS RRset at parent does not match declared service: missing=%v extra=%v", missing, extra),
|
||||||
|
Server: ps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glue sanity: in-bailiwick NS must have glue, out-of-bailiwick NS must not.
|
||||||
|
for _, n := range ns {
|
||||||
|
inBailiwick := strings.HasSuffix(n, "."+delegatedFQDN) || strings.HasSuffix(n, delegatedFQDN)
|
||||||
|
if inBailiwick {
|
||||||
|
if len(glue[n]) == 0 {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_missing_glue",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("in-bailiwick NS %s has no glue", n),
|
||||||
|
Server: ps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(glue[n]) > 0 {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_unnecessary_glue",
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: fmt.Sprintf("out-of-bailiwick NS %s has glue records, which the parent should not return", n),
|
||||||
|
Server: ps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DS at parent.
|
||||||
|
ds, sigs, dserr := queryDS(ctx, ps, delegatedFQDN)
|
||||||
|
if dserr != nil {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_ds_query_failed",
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: fmt.Sprintf("DS query failed: %v", dserr),
|
||||||
|
Server: ps,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Compare DS with declared service DS.
|
||||||
|
declaredDS := svc.DS
|
||||||
|
if len(declaredDS) > 0 || len(ds) > 0 {
|
||||||
|
dsMissing, dsExtra := diffDS(declaredDS, ds)
|
||||||
|
if len(dsMissing) > 0 || len(dsExtra) > 0 {
|
||||||
|
sev := SeverityCrit
|
||||||
|
if len(declaredDS) == 0 {
|
||||||
|
// Service does not declare any DS but parent has some — warn only.
|
||||||
|
sev = SeverityWarn
|
||||||
|
}
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_ds_mismatch",
|
||||||
|
Severity: sev,
|
||||||
|
Message: fmt.Sprintf("DS RRset at parent does not match declared service: missing=%d extra=%d", len(dsMissing), len(dsExtra)),
|
||||||
|
Server: ps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(declaredDS) > 0 && len(ds) == 0 {
|
||||||
|
sev := SeverityInfo
|
||||||
|
if requireDS {
|
||||||
|
sev = SeverityCrit
|
||||||
|
}
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_ds_missing",
|
||||||
|
Severity: sev,
|
||||||
|
Message: "service declares DS records but parent serves none",
|
||||||
|
Server: ps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate DS RRSIG validity period if a signature is present.
|
||||||
|
for _, sig := range sigs {
|
||||||
|
if !sig.ValidityPeriod(time.Now()) {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_ds_rrsig_invalid",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("DS RRSIG: %s", validityWindow(sig)),
|
||||||
|
Server: ps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ds) > 0 {
|
||||||
|
dsTexts := make([]string, len(ds))
|
||||||
|
for i, d := range ds {
|
||||||
|
dsTexts[i] = d.String()
|
||||||
|
}
|
||||||
|
data.ParentDS = dsTexts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
views = append(views, parentView{server: ps, ns: ns, glue: glue, ds: ds})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(views) == 0 {
|
||||||
|
// All parent servers failed; no point in continuing.
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the first successful parent view as the source of truth for
|
||||||
|
// Phase B. We rely on the per-parent NS_mismatch findings already
|
||||||
|
// emitted above to flag inconsistencies between parents.
|
||||||
|
parent := views[0]
|
||||||
|
data.AdvertisedNS = parent.ns
|
||||||
|
data.AdvertisedGlue = parent.glue
|
||||||
|
|
||||||
|
// Phase B: query each child name server using only parent-supplied data.
|
||||||
|
data.ChildSerials = map[string]uint32{}
|
||||||
|
for _, nsName := range parent.ns {
|
||||||
|
addrs := parent.glue[nsName]
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
// Out-of-bailiwick: resolve via the system resolver.
|
||||||
|
resolved, rerr := resolveHost(ctx, nsName)
|
||||||
|
if rerr != nil {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_ns_unresolvable",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("cannot resolve NS %s: %v", nsName, rerr),
|
||||||
|
Server: nsName,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addrs = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastSerial uint32
|
||||||
|
var sawAA bool
|
||||||
|
for _, addr := range addrs {
|
||||||
|
srv := hostPort(addr, "53")
|
||||||
|
|
||||||
|
// UDP reachability + AA check.
|
||||||
|
soa, aa, qerr := querySOA(ctx, "", srv, delegatedFQDN)
|
||||||
|
if qerr != nil {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_unreachable",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("UDP SOA query failed at %s (%s): %v", nsName, addr, qerr),
|
||||||
|
Server: srv,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !aa {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_lame",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("server %s (%s) is not authoritative for %s", nsName, addr, delegatedFQDN),
|
||||||
|
Server: srv,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sawAA = true
|
||||||
|
if soa != nil {
|
||||||
|
if lastSerial != 0 && lastSerial != soa.Serial {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_soa_serial_drift",
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: fmt.Sprintf("SOA serial drift on %s: %d vs %d", nsName, lastSerial, soa.Serial),
|
||||||
|
Server: srv,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
lastSerial = soa.Serial
|
||||||
|
data.ChildSerials[srv] = soa.Serial
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCP reachability.
|
||||||
|
if _, _, terr := querySOA(ctx, "tcp", srv, delegatedFQDN); terr != nil {
|
||||||
|
sev := SeverityCrit
|
||||||
|
if !requireTCP {
|
||||||
|
sev = SeverityWarn
|
||||||
|
}
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_tcp_failed",
|
||||||
|
Severity: sev,
|
||||||
|
Message: fmt.Sprintf("TCP SOA query failed at %s (%s): %v", nsName, addr, terr),
|
||||||
|
Server: srv,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NS RRset agreement with parent.
|
||||||
|
childNS, nerr := queryNSAt(ctx, srv, delegatedFQDN)
|
||||||
|
if nerr == nil {
|
||||||
|
missing, extra := diffStringSets(parent.ns, childNS)
|
||||||
|
if len(missing) > 0 || len(extra) > 0 {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_ns_drift",
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: fmt.Sprintf("child NS RRset differs from parent: missing=%v extra=%v", missing, extra),
|
||||||
|
Server: srv,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-bailiwick glue agreement.
|
||||||
|
if isInBailiwick(nsName, delegatedFQDN) {
|
||||||
|
childAddrs, _ := queryAddrsAt(ctx, srv, nsName)
|
||||||
|
missing, _ := diffStringSets(parent.glue[nsName], childAddrs)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
sev := SeverityCrit
|
||||||
|
if allowGlueMismatch {
|
||||||
|
sev = SeverityWarn
|
||||||
|
}
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_glue_mismatch",
|
||||||
|
Severity: sev,
|
||||||
|
Message: fmt.Sprintf("addresses served by child for %s differ from parent glue: missing=%v", nsName, missing),
|
||||||
|
Server: srv,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSKEY hand-off, only if the parent has DS records.
|
||||||
|
if len(parent.ds) > 0 {
|
||||||
|
keys, kerr := queryDNSKEY(ctx, srv, delegatedFQDN)
|
||||||
|
if kerr != nil {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_dnskey_query_failed",
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: fmt.Sprintf("DNSKEY query failed at %s: %v", nsName, kerr),
|
||||||
|
Server: srv,
|
||||||
|
})
|
||||||
|
} else if !dsMatchesAnyKey(parent.ds, keys) {
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_dnskey_no_match",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("none of the DNSKEY records served by %s match the DS published by the parent", nsName),
|
||||||
|
Server: srv,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawAA && len(addrs) > 0 {
|
||||||
|
// At least record we tried.
|
||||||
|
data.Findings = append(data.Findings, DelegationFinding{
|
||||||
|
Code: "delegation_no_authoritative_answer",
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: fmt.Sprintf("no authoritative answer obtained from any address of %s", nsName),
|
||||||
|
Server: nsName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryDelegationTCP is the TCP variant of queryDelegation. It is split out
|
||||||
|
// so the per-server findings keep their UDP/TCP roles distinct.
|
||||||
|
func queryDelegationTCP(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, "tcp", 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])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadService extracts the abstract.Delegation payload from the auto-filled
|
||||||
|
// "service" option. We parse it into our local minimal type so this checker
|
||||||
|
// does not have to import 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeNSList lowercases and FQDN-normalizes a list of NS records.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffStringSets returns the elements of "want" missing from "got" and the
|
||||||
|
// elements of "got" not present in "want".
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffDS returns the DS records present in "want" but missing from "got"
|
||||||
|
// and vice-versa.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// isInBailiwick reports whether host sits inside zone.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dsMatchesAnyKey reports whether at least one of the DNSKEY records hashes
|
||||||
|
// to one of the DS records.
|
||||||
|
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
|
||||||
|
}
|
||||||
83
checker/definition.go
Normal file
83
checker/definition.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the checker version reported in CheckerDefinition.Version.
|
||||||
|
var Version = "built-in"
|
||||||
|
|
||||||
|
// Definition returns the CheckerDefinition for the delegation checker.
|
||||||
|
func 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: []sdk.CheckRule{
|
||||||
|
Rule(),
|
||||||
|
},
|
||||||
|
Interval: &sdk.CheckIntervalSpec{
|
||||||
|
Min: 5 * time.Minute,
|
||||||
|
Max: 24 * time.Hour,
|
||||||
|
Default: 1 * time.Hour,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
296
checker/dns.go
Normal file
296
checker/dns.go
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// year68 mirrors the constant from miekg/dns used to wrap RRSIG validity
|
||||||
|
// periods around 2^32 seconds (≈68 years), as in the adlin checker.
|
||||||
|
const year68 = int64(1 << 31)
|
||||||
|
|
||||||
|
// dnsTimeout is the per-query deadline used by every helper here.
|
||||||
|
const dnsTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// dnsExchange sends a single query to the given server using the requested
|
||||||
|
// transport ("" for UDP, "tcp"). The server address must already include a
|
||||||
|
// port. RecursionDesired is forced off — this checker only talks to
|
||||||
|
// authoritative servers.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if ok {
|
||||||
|
if d := time.Until(deadline); d > 0 && d < client.Timeout {
|
||||||
|
client.Timeout = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r, _, err := client.Exchange(m, server)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("nil response from %s", server)
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostPort returns "host:port", correctly bracketing IPv6 literals.
|
||||||
|
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 resolves an NS hostname to its A and AAAA addresses using the
|
||||||
|
// system resolver. It is used as a fallback when no glue is provided by the
|
||||||
|
// parent for an out-of-bailiwick NS.
|
||||||
|
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 walks up the labels of fqdn until it finds the closest
|
||||||
|
// enclosing zone (the one that has its own SOA), and returns the FQDN of
|
||||||
|
// that zone along with its authoritative server addresses (resolved from
|
||||||
|
// its NS RRset). The walk stops as soon as a SOA query at the system
|
||||||
|
// resolver returns NOERROR with an answer.
|
||||||
|
//
|
||||||
|
// If hintParent is non-empty, it is used as the assumed parent and we only
|
||||||
|
// resolve its NS — this matches happyDomain's data model where the parent
|
||||||
|
// zone is known.
|
||||||
|
func findParentZone(ctx context.Context, fqdn, hintParent string) (zone string, servers []string, err error) {
|
||||||
|
zone = dns.Fqdn(hintParent)
|
||||||
|
if zone == "" || zone == "." {
|
||||||
|
// Walk up.
|
||||||
|
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 the list of "host:53" entries for every NS of
|
||||||
|
// the given zone, as seen by the system resolver. It is used to discover the
|
||||||
|
// parent's authoritative servers.
|
||||||
|
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 queries the given parent server for the NS RRset of fqdn
|
||||||
|
// and extracts the advertised NS names plus any glue records found in the
|
||||||
|
// Additional section. The query is sent without RD; the response is the
|
||||||
|
// classical "referral" packet.
|
||||||
|
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 asks the parent server for the DS RRset of fqdn and returns the
|
||||||
|
// DS records plus any RRSIGs found in the same section.
|
||||||
|
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 asks the given authoritative server for the SOA of fqdn and
|
||||||
|
// returns the SOA record plus the AA flag from the response header.
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryNSAt asks the given authoritative server for the NS RRset of fqdn.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryAddrsAt asks an authoritative server for the A and AAAA records of
|
||||||
|
// host (typically an in-bailiwick NS hostname).
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryDNSKEY asks the given child server for the DNSKEY RRset of fqdn.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// dsEqual returns true when two DS records refer to the same key material.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validityWindow returns a human-readable explanation of why a signature is
|
||||||
|
// outside its validity period, mirroring the year68 logic from the adlin
|
||||||
|
// checker.
|
||||||
|
func validityWindow(sig *dns.RRSIG) string {
|
||||||
|
utc := time.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
|
||||||
|
|
||||||
|
if ti > utc {
|
||||||
|
return "signature not yet valid"
|
||||||
|
} else if utc > te {
|
||||||
|
return "signature expired"
|
||||||
|
}
|
||||||
|
return "signature outside its validity window"
|
||||||
|
}
|
||||||
53
checker/evaluate.go
Normal file
53
checker/evaluate.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Evaluate folds findings into a single CheckState. The status is the
|
||||||
|
// highest severity observed: any Crit makes the whole result Crit, any Warn
|
||||||
|
// makes it Warn, otherwise OK.
|
||||||
|
func Evaluate(data *DelegationData) sdk.CheckState {
|
||||||
|
status := sdk.StatusOK
|
||||||
|
var crit, warn, info int
|
||||||
|
for _, f := range data.Findings {
|
||||||
|
switch f.Severity {
|
||||||
|
case SeverityCrit:
|
||||||
|
crit++
|
||||||
|
status = sdk.StatusCrit
|
||||||
|
case SeverityWarn:
|
||||||
|
warn++
|
||||||
|
if status != sdk.StatusCrit {
|
||||||
|
status = sdk.StatusWarn
|
||||||
|
}
|
||||||
|
case SeverityInfo:
|
||||||
|
info++
|
||||||
|
if status == sdk.StatusOK {
|
||||||
|
status = sdk.StatusInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg string
|
||||||
|
if len(data.Findings) == 0 {
|
||||||
|
msg = fmt.Sprintf("Delegation of %s is healthy", data.DelegatedFQDN)
|
||||||
|
} else {
|
||||||
|
msg = fmt.Sprintf("Delegation of %s: %d critical, %d warning, %d info", data.DelegatedFQDN, crit, warn, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: status,
|
||||||
|
Message: msg,
|
||||||
|
Code: "delegation_result",
|
||||||
|
Meta: map[string]any{
|
||||||
|
"findings": data.Findings,
|
||||||
|
"delegated_fqdn": data.DelegatedFQDN,
|
||||||
|
"parent_zone": data.ParentZone,
|
||||||
|
"advertised_ns": data.AdvertisedNS,
|
||||||
|
"parent_ds": data.ParentDS,
|
||||||
|
"child_serials": data.ChildSerials,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
22
checker/provider.go
Normal file
22
checker/provider.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider returns a new delegation observation provider.
|
||||||
|
func Provider() sdk.ObservationProvider {
|
||||||
|
return &delegationProvider{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type delegationProvider struct{}
|
||||||
|
|
||||||
|
func (p *delegationProvider) Key() sdk.ObservationKey {
|
||||||
|
return ObservationKeyDelegation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition implements sdk.CheckerDefinitionProvider so the SDK server can
|
||||||
|
// expose /definition without an extra argument.
|
||||||
|
func (p *delegationProvider) Definition() *sdk.CheckerDefinition {
|
||||||
|
return Definition()
|
||||||
|
}
|
||||||
46
checker/rule.go
Normal file
46
checker/rule.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule returns the delegation check rule.
|
||||||
|
func Rule() sdk.CheckRule {
|
||||||
|
return &delegationRule{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type delegationRule struct{}
|
||||||
|
|
||||||
|
func (r *delegationRule) Name() string { return "delegation_check" }
|
||||||
|
|
||||||
|
func (r *delegationRule) Description() string {
|
||||||
|
return "Verifies a DNS delegation against its parent zone and the delegated name servers"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *delegationRule) ValidateOptions(opts sdk.CheckerOptions) error {
|
||||||
|
if v, ok := opts["minNameServers"]; ok {
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("minNameServers must be a number")
|
||||||
|
}
|
||||||
|
if f < 1 {
|
||||||
|
return fmt.Errorf("minNameServers must be >= 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *delegationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState {
|
||||||
|
var data DelegationData
|
||||||
|
if err := obs.Get(ctx, ObservationKeyDelegation, &data); err != nil {
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Message: fmt.Sprintf("Failed to get delegation data: %v", err),
|
||||||
|
Code: "delegation_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Evaluate(&data)
|
||||||
|
}
|
||||||
89
checker/types.go
Normal file
89
checker/types.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ObservationKeyDelegation is the observation key for delegation data.
|
||||||
|
const ObservationKeyDelegation = "delegation"
|
||||||
|
|
||||||
|
// Severity classifies a finding emitted by the delegation checker.
|
||||||
|
type Severity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeverityInfo Severity = "info"
|
||||||
|
SeverityWarn Severity = "warn"
|
||||||
|
SeverityCrit Severity = "crit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DelegationFinding describes a single observation produced while running
|
||||||
|
// the delegation testsuite.
|
||||||
|
type DelegationFinding struct {
|
||||||
|
// Code is a stable machine-readable identifier (e.g. "delegation_ns_mismatch").
|
||||||
|
Code string `json:"code"`
|
||||||
|
|
||||||
|
// Severity grades the finding.
|
||||||
|
Severity Severity `json:"severity"`
|
||||||
|
|
||||||
|
// Message is a human-readable explanation.
|
||||||
|
Message string `json:"message"`
|
||||||
|
|
||||||
|
// Server is the DNS server that exhibited the finding (parent or child),
|
||||||
|
// when applicable. Empty for findings tied to the service definition itself.
|
||||||
|
Server string `json:"server,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DelegationData is the observation payload stored by the checker. It carries
|
||||||
|
// every finding emitted by the testsuite plus the raw observed state from the
|
||||||
|
// parent and from each delegated server.
|
||||||
|
type DelegationData struct {
|
||||||
|
// DelegatedFQDN is the FQDN of the delegated zone (subdomain + parent).
|
||||||
|
DelegatedFQDN string `json:"delegated_fqdn"`
|
||||||
|
|
||||||
|
// ParentZone is the FQDN of the parent zone that delegates DelegatedFQDN.
|
||||||
|
ParentZone string `json:"parent_zone"`
|
||||||
|
|
||||||
|
// ParentNS lists the parent zone's authoritative servers that were
|
||||||
|
// queried (FQDNs of NS records).
|
||||||
|
ParentNS []string `json:"parent_ns,omitempty"`
|
||||||
|
|
||||||
|
// AdvertisedNS holds the NS RRset returned by the parent for the
|
||||||
|
// delegated FQDN, normalized as lowercase FQDNs.
|
||||||
|
AdvertisedNS []string `json:"advertised_ns,omitempty"`
|
||||||
|
|
||||||
|
// AdvertisedGlue maps an in-bailiwick NS hostname to the glue addresses
|
||||||
|
// returned by the parent for that name.
|
||||||
|
AdvertisedGlue map[string][]string `json:"advertised_glue,omitempty"`
|
||||||
|
|
||||||
|
// ParentDS lists the DS records returned by the parent for the
|
||||||
|
// delegated FQDN, in their textual presentation form.
|
||||||
|
ParentDS []string `json:"parent_ds,omitempty"`
|
||||||
|
|
||||||
|
// ChildSerials maps an NS hostname to the SOA serial it returns for
|
||||||
|
// the delegated FQDN.
|
||||||
|
ChildSerials map[string]uint32 `json:"child_serials,omitempty"`
|
||||||
|
|
||||||
|
// Findings is the list of issues / observations produced by the run.
|
||||||
|
Findings []DelegationFinding `json:"findings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// delegationService is the minimal local mirror of happyDomain's
|
||||||
|
// `services/abstract.Delegation` type. It is duplicated on purpose so that
|
||||||
|
// this checker does not have to import the (heavy) happyDomain server module
|
||||||
|
// just to decode the service payload. github.com/miekg/dns marshals
|
||||||
|
// dns.NS / dns.DS to JSON in the same shape happyDomain uses.
|
||||||
|
type delegationService struct {
|
||||||
|
NameServers []*dns.NS `json:"ns"`
|
||||||
|
DS []*dns.DS `json:"ds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceMessage is the minimal local mirror of happyDomain's ServiceMessage
|
||||||
|
// envelope. We only need the embedded service JSON; the rest of the meta
|
||||||
|
// fields are ignored.
|
||||||
|
type serviceMessage struct {
|
||||||
|
Type string `json:"_svctype"`
|
||||||
|
Domain string `json:"_domain"`
|
||||||
|
Service json.RawMessage `json:"Service"`
|
||||||
|
}
|
||||||
16
go.mod
Normal file
16
go.mod
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
module git.happydns.org/checker-delegation
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.happydns.org/checker-sdk-go v0.0.1
|
||||||
|
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 v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI=
|
||||||
|
git.happydns.org/checker-sdk-go v0.0.1/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=
|
||||||
28
main.go
Normal file
28
main.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
delegation "git.happydns.org/checker-delegation/checker"
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||||
|
|
||||||
|
// Version is the standalone binary's version. It defaults to "custom-build"
|
||||||
|
// and is meant to be overridden by the CI at link time:
|
||||||
|
//
|
||||||
|
// go build -ldflags "-X main.Version=1.2.3" .
|
||||||
|
var Version = "custom-build"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
delegation.Version = Version
|
||||||
|
|
||||||
|
server := sdk.NewServer(delegation.Provider())
|
||||||
|
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
plugin/plugin.go
Normal file
22
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// Command plugin is the happyDomain plugin entrypoint for the delegation
|
||||||
|
// checker. It is built as a Go plugin (`go build -buildmode=plugin`) and
|
||||||
|
// loaded at runtime by happyDomain.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
delegation "git.happydns.org/checker-delegation/checker"
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the plugin's version. It defaults to "custom-build" and is
|
||||||
|
// meant to be overridden by the CI at link time:
|
||||||
|
//
|
||||||
|
// go build -buildmode=plugin -ldflags "-X main.Version=1.2.3" -o checker-delegation.so ./plugin
|
||||||
|
var Version = "custom-build"
|
||||||
|
|
||||||
|
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
|
||||||
|
// .so file.
|
||||||
|
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||||
|
delegation.Version = Version
|
||||||
|
return delegation.Definition(), delegation.Provider(), nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue