Initial commit
This commit is contained in:
commit
d4a59fb9e8
18 changed files with 1439 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-legacy-records
|
||||
checker-legacy-records.so
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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-legacy-records .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-legacy-records /checker-legacy-records
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD ["/checker-legacy-records", "-healthcheck"]
|
||||
ENTRYPOINT ["/checker-legacy-records"]
|
||||
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-legacy-records
|
||||
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 -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
|
||||
26
NOTICE
Normal file
26
NOTICE
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
checker-legacy-records
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
This product is licensed under the MIT License (see LICENSE).
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Third-party notices
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
This product includes software developed as part of the checker-sdk-go
|
||||
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
|
||||
under the Apache License, Version 2.0:
|
||||
|
||||
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 there under the Apache License, Version 2.0 to enable
|
||||
a permissively licensed ecosystem of checker plugins.
|
||||
|
||||
You may obtain a copy of the Apache License 2.0 at:
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
59
README.md
Normal file
59
README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# checker-legacy-records
|
||||
|
||||
A happyDomain checker that scans a working zone for **DNS record types
|
||||
deprecated by the IETF** and reports each occurrence with the relevant
|
||||
RFC reference and a concrete migration suggestion.
|
||||
|
||||
It runs in three deployment modes (standalone HTTP binary, Go plugin,
|
||||
Docker image), like every other checker in the happyDomain ecosystem.
|
||||
|
||||
## What it detects
|
||||
|
||||
The checker walks every service in the working zone (`AutoFillZone`) and
|
||||
inspects each `svcs.Orphan` body for an embedded RR header. Records whose
|
||||
type is in the [`deprecatedTypes`](checker/deprecated.go) table produce a
|
||||
finding.
|
||||
|
||||
| Severity | Record types | Why |
|
||||
|--------------|-----------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| Critical | `KEY`, `SIG`, `NXT` | RFC 3755: superseded by DNSKEY/RRSIG/NSEC; modern validators ignore them. |
|
||||
| Warning | `SPF`, `A6`, `MD`, `MF` | RFC 7208 / RFC 6563 / RFC 973: replaced by TXT, AAAA, MX. |
|
||||
| Informational| `WKS`, `MB`, `MG`, `MR`, `MINFO`, `NULL`, `GPOS`, `NSAP`, `NSAP-PTR`, `X25`, `ISDN`, `RT`, `ATMA`, `EID`, `NIMLOC`, `SINK`, `NINFO`, `RKEY` | Experimental or historical (RFC 1035, 1183, 1706, 1712, ...); safe to delete. |
|
||||
|
||||
## Tests
|
||||
|
||||
`go test ./...` covers:
|
||||
|
||||
- a clean zone (no findings, no errors, modern services skipped silently);
|
||||
- detection of common legacy types (`SPF`, `A6`, `KEY`, `NXT`, `WKS`);
|
||||
- grouping and ranking by severity (critical bubbles to the top, even
|
||||
when warning findings appear first in the zone);
|
||||
- the empty-zone OK path (rule still emits one OK state with the scan
|
||||
count);
|
||||
- a missing `zone` option (the host forgot to wire AutoFillZone) is a
|
||||
hard error;
|
||||
- the HTML "Fix this first" card always reflects the worst severity;
|
||||
- the OK banner appears when nothing legacy is found.
|
||||
|
||||
## HTML report
|
||||
|
||||
The report renders as a standalone HTML page suitable for iframe embedding:
|
||||
|
||||
- a coloured status banner (OK / Info / Warning / Critical) summarising
|
||||
the worst finding;
|
||||
- a **"Fix this first"** card with the most-severe legacy type, the RFC
|
||||
reason, the suggested replacement, and a concrete `How to fix`
|
||||
instruction; the card lists every owner where the type appears;
|
||||
- an "Other legacy records" section with one card per remaining type,
|
||||
sorted by descending severity then alphabetically;
|
||||
- a collapsible "skipped during scan" section listing parse errors so
|
||||
silent skips never masquerade as a clean pass.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
make # standalone binary
|
||||
make plugin # .so plugin for happyDomain
|
||||
make docker # Docker image
|
||||
make test # run the unit tests
|
||||
```
|
||||
138
checker/collect.go
Normal file
138
checker/collect.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Collect walks the working zone and records every legacy RR encountered.
|
||||
// We decode the zone as a minimal local shape (rawZone) so the checker stays
|
||||
// free of any happyDomain module dependency. Almost every legacy record
|
||||
// reaches us as an "svcs.Orphan" (happyDomain has no dedicated service for
|
||||
// these types), so the orphan body is the primary path; other service types
|
||||
// are also probed for an embedded RR header on a best-effort basis.
|
||||
func (p *legacyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
zone, err := readZone(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := &LegacyData{Zone: zone.DomainName}
|
||||
if data.Zone == "" {
|
||||
if name, ok := sdk.GetOption[string](opts, "domain_name"); ok {
|
||||
data.Zone = strings.TrimSuffix(name, ".")
|
||||
}
|
||||
}
|
||||
|
||||
// Sort subdomains so the report ordering is stable across runs and
|
||||
// findings stay diff-friendly when the user replays the check.
|
||||
subs := make([]string, 0, len(zone.Services))
|
||||
for s := range zone.Services {
|
||||
subs = append(subs, s)
|
||||
}
|
||||
sort.Strings(subs)
|
||||
|
||||
for _, sub := range subs {
|
||||
for _, svc := range zone.Services[sub] {
|
||||
data.ServicesScanned++
|
||||
f, perr := inspectService(sub, svc)
|
||||
if perr != nil {
|
||||
data.CollectErrors = append(data.CollectErrors,
|
||||
fmt.Sprintf("%s/%s: %v", displaySubdomain(sub), svc.Type, perr))
|
||||
continue
|
||||
}
|
||||
data.Findings = append(data.Findings, f...)
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// readZone normalises the "zone" option, which arrives either as a native
|
||||
// *Zone (in-process plugin) or as a JSON object (HTTP path). We round-trip
|
||||
// through json.Marshal in both cases: it costs one allocation and keeps the
|
||||
// rawZone decoder as the single shape contract.
|
||||
func readZone(opts sdk.CheckerOptions) (*rawZone, error) {
|
||||
v, ok := opts["zone"]
|
||||
if !ok || v == nil {
|
||||
return nil, fmt.Errorf("missing 'zone' option (AutoFillZone): the host did not provide a working zone")
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("re-marshal zone option: %w", err)
|
||||
}
|
||||
z := &rawZone{}
|
||||
if err := json.Unmarshal(raw, z); err != nil {
|
||||
return nil, fmt.Errorf("decode zone option: %w", err)
|
||||
}
|
||||
return z, nil
|
||||
}
|
||||
|
||||
// inspectService returns one finding per legacy record carried by the
|
||||
// service. Returns (nil, nil) for non-legacy services (the common case).
|
||||
func inspectService(sub string, svc rawService) ([]Finding, error) {
|
||||
hdr, ok, err := extractRRHeader(svc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if _, deprecated := deprecatedTypes[hdr.Rrtype]; !deprecated {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []Finding{{
|
||||
Subdomain: sub,
|
||||
Name: hdr.Name,
|
||||
Rrtype: hdr.Rrtype,
|
||||
TypeName: typeLabel(hdr.Rrtype),
|
||||
ServiceType: svc.Type,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// extractRRHeader pulls the RR header from a service body. Only svcs.Orphan
|
||||
// exposes such a header on the wire today; other service types are skipped
|
||||
// silently so the common case (MX, A, TXT, …) does not pollute CollectErrors.
|
||||
// When the service *is* an orphan but the body fails to decode, the error is
|
||||
// propagated so the operator sees the malformed entry in the report.
|
||||
func extractRRHeader(svc rawService) (orphanHdr, bool, error) {
|
||||
if len(svc.Service) == 0 {
|
||||
return orphanHdr{}, false, nil
|
||||
}
|
||||
|
||||
if svc.Type != "svcs.Orphan" {
|
||||
return orphanHdr{}, false, nil
|
||||
}
|
||||
|
||||
var ob orphanBody
|
||||
if err := json.Unmarshal(svc.Service, &ob); err != nil {
|
||||
return orphanHdr{}, false, fmt.Errorf("decode orphan body: %w", err)
|
||||
}
|
||||
if ob.Record.Hdr.Rrtype == 0 {
|
||||
return orphanHdr{}, false, nil
|
||||
}
|
||||
return orphanHdr(ob.Record.Hdr), true, nil
|
||||
}
|
||||
|
||||
// orphanHdr is a flat copy of orphanBody.Record.Hdr so callers don't have
|
||||
// to know about the JSON nesting.
|
||||
type orphanHdr struct {
|
||||
Name string `json:"Name"`
|
||||
Rrtype uint16 `json:"Rrtype"`
|
||||
}
|
||||
|
||||
// displaySubdomain renders the apex as "@" so error messages match the
|
||||
// convention used everywhere else in happyDomain.
|
||||
func displaySubdomain(s string) string {
|
||||
if s == "" || s == "@" {
|
||||
return "@"
|
||||
}
|
||||
return s
|
||||
}
|
||||
53
checker/definition.go
Normal file
53
checker/definition.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is overridden at build time via -ldflags by main.go and plugin.go.
|
||||
var Version = "built-in"
|
||||
|
||||
// Definition exposes the checker to the happyDomain host.
|
||||
//
|
||||
// The checker is zone-scoped: it walks every service in the working zone in
|
||||
// a single pass, which lets the report show one consolidated picture
|
||||
// instead of one observation per service.
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
def := &sdk.CheckerDefinition{
|
||||
ID: "legacy-records",
|
||||
Name: "Legacy DNS record types",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToZone: true,
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyLegacy},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
DomainOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domain_name",
|
||||
Label: "Domain name",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
},
|
||||
{
|
||||
Id: "zone",
|
||||
Label: "Zone",
|
||||
AutoFill: sdk.AutoFillZone,
|
||||
Hide: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []sdk.CheckRule{
|
||||
&legacyRecordsRule{},
|
||||
},
|
||||
HasHTMLReport: true,
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 5 * time.Minute,
|
||||
Max: 24 * time.Hour,
|
||||
Default: 6 * time.Hour,
|
||||
},
|
||||
}
|
||||
def.BuildRulesInfo()
|
||||
return def
|
||||
}
|
||||
238
checker/deprecated.go
Normal file
238
checker/deprecated.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// DeprecatedSeverity grades a deprecated record family.
|
||||
//
|
||||
// "Critical" reflects record types whose continued use breaks DNSSEC
|
||||
// validation or modern resolvers (KEY/SIG/NXT, replaced by the DNSSEC-bis
|
||||
// triplet, RFC 3755). "Warning" covers types that still parse but have a
|
||||
// long-standing replacement (SPF→TXT, A6→AAAA, …). "Info" is reserved for
|
||||
// experimental types nobody implements anymore (NULL, NSAP, …): present in
|
||||
// a zone is harmless but pointless.
|
||||
type DeprecatedSeverity int
|
||||
|
||||
const (
|
||||
SeverityInfo DeprecatedSeverity = iota
|
||||
SeverityWarn
|
||||
SeverityCrit
|
||||
)
|
||||
|
||||
func (s DeprecatedSeverity) String() string {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return "crit"
|
||||
case SeverityWarn:
|
||||
return "warn"
|
||||
default:
|
||||
return "info"
|
||||
}
|
||||
}
|
||||
|
||||
// DeprecationInfo describes one deprecated RR type.
|
||||
type DeprecationInfo struct {
|
||||
Reason string
|
||||
|
||||
// Replacement is the modern record type to use instead, or "" when the
|
||||
// type has no replacement (just remove the record).
|
||||
Replacement string
|
||||
|
||||
// HowToFix is the actionable instruction shown in the HTML report.
|
||||
// Phrased as a direct imperative so the user can act without context
|
||||
// switching to the relevant RFC.
|
||||
HowToFix string
|
||||
|
||||
Severity DeprecatedSeverity
|
||||
}
|
||||
|
||||
// deprecatedTypes is the source of truth for what counts as legacy.
|
||||
//
|
||||
// Numeric keys (instead of dns.TypeXxx) are used for types miekg/dns does
|
||||
// not export as named constants; they remain valid wire types and may
|
||||
// well show up in zones imported from BIND or older tooling.
|
||||
var deprecatedTypes = map[uint16]DeprecationInfo{
|
||||
// --- DNSSEC predecessors (RFC 3755, RFC 4033 family) -----------------
|
||||
dns.TypeKEY: {
|
||||
Reason: "RFC 3755 obsoleted KEY in favour of DNSKEY",
|
||||
Replacement: "DNSKEY",
|
||||
HowToFix: "Re-sign the zone with a DNSSEC implementation that emits DNSKEY/RRSIG/NSEC records, then remove the KEY entries.",
|
||||
Severity: SeverityCrit,
|
||||
},
|
||||
dns.TypeSIG: {
|
||||
Reason: "RFC 3755 obsoleted SIG in favour of RRSIG",
|
||||
Replacement: "RRSIG",
|
||||
HowToFix: "SIG records are not validated by modern resolvers. Drop them; RRSIG records are produced automatically when the zone is DNSSEC-signed.",
|
||||
Severity: SeverityCrit,
|
||||
},
|
||||
dns.TypeNXT: {
|
||||
Reason: "RFC 3755 obsoleted NXT in favour of NSEC",
|
||||
Replacement: "NSEC",
|
||||
HowToFix: "NXT predates DNSSEC-bis and is not understood by current validators. Re-sign the zone to produce NSEC (or NSEC3) records and remove NXT.",
|
||||
Severity: SeverityCrit,
|
||||
},
|
||||
|
||||
// --- Replaced by a clear modern equivalent ---------------------------
|
||||
dns.TypeSPF: {
|
||||
Reason: "RFC 7208 §3.1 deprecated the SPF record type; publish SPF policy in TXT only",
|
||||
Replacement: "TXT",
|
||||
HowToFix: "Publish the SPF policy as a TXT record (`v=spf1 …`) at the same owner name, then delete the SPF-typed record. Some receivers ignore SPF-typed records entirely.",
|
||||
Severity: SeverityWarn,
|
||||
},
|
||||
38: { // A6
|
||||
Reason: "RFC 6563 moved A6 to historic status",
|
||||
Replacement: "AAAA",
|
||||
HowToFix: "Replace each A6 record with an equivalent AAAA record carrying the full IPv6 address.",
|
||||
Severity: SeverityWarn,
|
||||
},
|
||||
dns.TypeMD: {
|
||||
Reason: "RFC 973 obsoleted MD in 1986; use MX",
|
||||
Replacement: "MX",
|
||||
HowToFix: "Translate the mail-destination into an MX record (preference + exchange host) and delete the MD record.",
|
||||
Severity: SeverityWarn,
|
||||
},
|
||||
dns.TypeMF: {
|
||||
Reason: "RFC 973 obsoleted MF in 1986; use MX",
|
||||
Replacement: "MX",
|
||||
HowToFix: "Translate the mail-forwarder into an MX record (preference + exchange host) and delete the MF record.",
|
||||
Severity: SeverityWarn,
|
||||
},
|
||||
dns.TypeGPOS: {
|
||||
Reason: "RFC 1712 superseded GPOS with LOC",
|
||||
Replacement: "LOC",
|
||||
HowToFix: "If geolocation is genuinely needed, publish a LOC record instead. Otherwise delete the GPOS record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
|
||||
// --- Privacy/info-leak deprecations ----------------------------------
|
||||
dns.TypeMB: {
|
||||
Reason: "RFC 2505/RFC 1035 §3.3: experimental, unused; replaced by MX",
|
||||
Replacement: "MX",
|
||||
HowToFix: "Delete the MB record; route mailbox traffic via MX.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
dns.TypeMG: {
|
||||
Reason: "RFC 1035 §3.3: experimental mail-group record, never widely deployed",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the MG record; mail-group semantics now belong on the SMTP layer.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
dns.TypeMR: {
|
||||
Reason: "RFC 1035 §3.3: experimental mail-rename record, never widely deployed",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the MR record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
dns.TypeMINFO: {
|
||||
Reason: "RFC 1035 §3.3: experimental mailbox-info record, never widely deployed",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the MINFO record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
dns.TypeNULL: {
|
||||
Reason: "RFC 1035 §3.3.10: experimental, must not appear in master files",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the NULL record. If it is used as a private channel, switch to TXT or a dedicated underscore label.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
11: { // WKS
|
||||
Reason: "RFC 1123 §6.1.3.6 discouraged WKS; modern stacks ignore it",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the WKS record. Service availability belongs in SRV, ALPN, or HTTPS/SVCB records, not WKS.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
|
||||
// --- Historical address families (no live deployment) ----------------
|
||||
22: { // NSAP
|
||||
Reason: "RFC 1706 historical: OSI/CLNP addressing, no current deployment",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the NSAP record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
dns.TypeNSAPPTR: {
|
||||
Reason: "RFC 1706 historical: OSI reverse mapping, no current deployment",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the NSAP-PTR record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
dns.TypeX25: {
|
||||
Reason: "RFC 1183 historical: X.25 addressing, no current deployment",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the X25 record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
dns.TypeISDN: {
|
||||
Reason: "RFC 1183 historical: ISDN addressing, no current deployment",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the ISDN record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
dns.TypeRT: {
|
||||
Reason: "RFC 1183 historical: route-through, superseded by direct routing",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the RT record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
dns.TypeATMA: {
|
||||
Reason: "ATM Forum AF-SAA-0069 historical: ATM addressing, no current deployment",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the ATMA record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
31: { // EID
|
||||
Reason: "Nimrod EID: never deployed beyond the experiment",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the EID record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
32: { // NIMLOC
|
||||
Reason: "Nimrod NIMLOC: never deployed beyond the experiment",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the NIMLOC record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
40: { // SINK
|
||||
Reason: "draft-eastlake-kitchen-sink: never standardised",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the SINK record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
56: { // NINFO
|
||||
Reason: "draft-reid-dnsext-zs: never standardised",
|
||||
Replacement: "TXT",
|
||||
HowToFix: "If you need free-form zone metadata, use a TXT record at the apex with a clearly scoped prefix.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
57: { // RKEY
|
||||
Reason: "draft-reid-dnsext-rkey: never standardised",
|
||||
Replacement: "",
|
||||
HowToFix: "Delete the RKEY record.",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
}
|
||||
|
||||
// extraTypeNames covers the deprecated record types that miekg/dns does
|
||||
// not list in TypeToString (WKS, NSAP, A6, SINK). Without this fallback,
|
||||
// typeLabel would return "TYPEnnn" for them and the report would lose the
|
||||
// human-friendly name.
|
||||
var extraTypeNames = map[uint16]string{
|
||||
11: "WKS",
|
||||
22: "NSAP",
|
||||
38: "A6",
|
||||
40: "SINK",
|
||||
}
|
||||
|
||||
// typeLabel returns the textual record type name. dns.TypeToString covers
|
||||
// the well-known set; for unknown rrtypes we fall back to RFC 3597 form
|
||||
// ("TYPEnnn") so the report stays readable.
|
||||
func typeLabel(rrtype uint16) string {
|
||||
if name, ok := dns.TypeToString[rrtype]; ok {
|
||||
return name
|
||||
}
|
||||
if name, ok := extraTypeNames[rrtype]; ok {
|
||||
return name
|
||||
}
|
||||
// dns.Type stringer produces "TYPEnnn" for unknown types (RFC 3597).
|
||||
return dns.Type(rrtype).String()
|
||||
}
|
||||
16
checker/provider.go
Normal file
16
checker/provider.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Provider returns the legacy-records observation provider.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &legacyProvider{}
|
||||
}
|
||||
|
||||
type legacyProvider struct{}
|
||||
|
||||
func (p *legacyProvider) Key() sdk.ObservationKey { return ObservationKeyLegacy }
|
||||
|
||||
func (p *legacyProvider) Definition() *sdk.CheckerDefinition { return Definition() }
|
||||
286
checker/report.go
Normal file
286
checker/report.go
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// GetHTMLReport renders the legacy-records observation as a self-contained
|
||||
// HTML page suitable for iframe embedding.
|
||||
//
|
||||
// The "fix this first" card is driven by the most-severe finding (no fixed
|
||||
// rule wins by name): SeverityCrit > SeverityWarn > SeverityInfo, with the
|
||||
// alphabetically-first type name as a stable tie-break. This matches what
|
||||
// the rule sorter produces, so the top card and the rule output never
|
||||
// disagree on which finding is "the" priority.
|
||||
func (p *legacyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||
var data LegacyData
|
||||
if raw := ctx.Data(); len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return "", fmt.Errorf("parse legacy-records data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
view := buildReportView(&data, ctx.States())
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
if err := reportTmpl.Execute(buf, view); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
type reportView struct {
|
||||
Zone string
|
||||
ServicesScanned int
|
||||
Total int
|
||||
OverallStatus string
|
||||
OverallText string
|
||||
OverallClass string
|
||||
Top *findingCard
|
||||
Others []findingCard
|
||||
CollectErrors []string
|
||||
}
|
||||
|
||||
type findingCard struct {
|
||||
TypeName string
|
||||
Reason string
|
||||
Replacement string
|
||||
HowToFix string
|
||||
Severity string
|
||||
SeverityCSS string
|
||||
Count int
|
||||
Locations []FindingLocation
|
||||
}
|
||||
|
||||
func buildReportView(data *LegacyData, states []sdk.CheckState) *reportView {
|
||||
v := &reportView{
|
||||
Zone: data.Zone,
|
||||
ServicesScanned: data.ServicesScanned,
|
||||
Total: len(data.Findings),
|
||||
CollectErrors: data.CollectErrors,
|
||||
}
|
||||
|
||||
groups := groupFindings(data.Findings)
|
||||
cards := make([]findingCard, 0, len(groups))
|
||||
worst := SeverityInfo
|
||||
for _, g := range groups {
|
||||
info := deprecatedTypes[g.Rrtype]
|
||||
if info.Severity > worst {
|
||||
worst = info.Severity
|
||||
}
|
||||
cards = append(cards, findingCard{
|
||||
TypeName: g.TypeName,
|
||||
Reason: info.Reason,
|
||||
Replacement: info.Replacement,
|
||||
HowToFix: info.HowToFix,
|
||||
Severity: severityLabel(info.Severity),
|
||||
SeverityCSS: info.Severity.String(),
|
||||
Count: len(g.Locations),
|
||||
Locations: g.Locations,
|
||||
})
|
||||
}
|
||||
|
||||
if len(cards) > 0 {
|
||||
v.Top = &cards[0]
|
||||
v.Others = cards[1:]
|
||||
v.OverallStatus = worst.String()
|
||||
v.OverallText, v.OverallClass = overallLabel(worst)
|
||||
} else {
|
||||
// Honour the rule's status when present: an Error from the rule
|
||||
// (e.g. observation load failure) must not be masked as "OK".
|
||||
if errState, ok := firstErrorState(states); ok {
|
||||
v.OverallStatus = "error"
|
||||
v.OverallText = errState.Message
|
||||
v.OverallClass = "status-crit"
|
||||
} else {
|
||||
v.OverallStatus = "ok"
|
||||
v.OverallText = fmt.Sprintf("No legacy record types found across %d service(s).", data.ServicesScanned)
|
||||
v.OverallClass = "status-ok"
|
||||
}
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func firstErrorState(states []sdk.CheckState) (sdk.CheckState, bool) {
|
||||
for i := range states {
|
||||
if states[i].Status == sdk.StatusError {
|
||||
return states[i], true
|
||||
}
|
||||
}
|
||||
return sdk.CheckState{}, false
|
||||
}
|
||||
|
||||
func severityLabel(s DeprecatedSeverity) string {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return "Critical"
|
||||
case SeverityWarn:
|
||||
return "Warning"
|
||||
default:
|
||||
return "Informational"
|
||||
}
|
||||
}
|
||||
|
||||
func overallLabel(s DeprecatedSeverity) (text, css string) {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return "Legacy records require urgent migration", "status-crit"
|
||||
case SeverityWarn:
|
||||
return "Legacy records should be migrated", "status-warn"
|
||||
default:
|
||||
return "Only informational legacy records found", "status-info"
|
||||
}
|
||||
}
|
||||
|
||||
var reportTmpl = template.Must(template.New("legacy-records-report").Funcs(template.FuncMap{
|
||||
"display": func(s string) string {
|
||||
if s == "" || s == "@" {
|
||||
return "@"
|
||||
}
|
||||
return s
|
||||
},
|
||||
}).Parse(reportTemplate))
|
||||
|
||||
const reportTemplate = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Legacy DNS records — {{if .Zone}}{{.Zone}}{{else}}zone report{{end}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--ok: #1e9e5d;
|
||||
--info: #3b82f6;
|
||||
--warn: #d97706;
|
||||
--crit: #dc2626;
|
||||
--bg: #f7f7f8;
|
||||
--card: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
}
|
||||
body { margin: 0; padding: 1.2rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); line-height: 1.45; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 .3rem 0; }
|
||||
h2 { font-size: 1.05rem; margin: 1.5rem 0 .6rem 0; border-bottom: 1px solid var(--border); padding-bottom: .25rem; }
|
||||
h3 { font-size: .95rem; margin: 0 0 .35rem 0; }
|
||||
.muted { color: var(--muted); font-size: .85rem; }
|
||||
.status-banner { display: flex; align-items: center; justify-content: space-between; padding: .9rem 1rem; border-radius: 8px; color: #fff; margin-bottom: 1rem; }
|
||||
.status-ok { background: var(--ok); }
|
||||
.status-info { background: var(--info); }
|
||||
.status-warn { background: var(--warn); }
|
||||
.status-crit { background: var(--crit); }
|
||||
.status-banner .label { font-weight: 600; font-size: 1rem; }
|
||||
.status-banner .sub { opacity: .9; font-size: .85rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
|
||||
.stat { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; }
|
||||
.stat .k { color: var(--muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .03em; }
|
||||
.stat .v { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.05rem; }
|
||||
.top-fix { border-left: 5px solid var(--crit); background: #fef2f2; padding: 1rem 1.1rem; border-radius: 8px; margin-bottom: 1rem; }
|
||||
.top-fix.severity-warn { border-color: var(--warn); background: #fffbeb; }
|
||||
.top-fix.severity-info { border-color: var(--info); background: #eff6ff; }
|
||||
.top-fix h3 { display: flex; align-items: center; gap: .5rem; }
|
||||
.top-fix h3 .type { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1rem; }
|
||||
.top-fix .why { color: var(--muted); font-size: .9rem; margin: .1rem 0 .6rem 0; }
|
||||
.top-fix .fix { background: rgba(0,0,0,.04); padding: .55rem .7rem; border-radius: 6px; font-size: .9rem; }
|
||||
.top-fix .fix strong { display: block; margin-bottom: .2rem; color: var(--text); }
|
||||
.top-fix .locs { margin: .55rem 0 0 0; font-size: .85rem; }
|
||||
.top-fix .locs code { background: rgba(0,0,0,.05); padding: .05rem .35rem; border-radius: 4px; }
|
||||
.other-fix { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; margin-bottom: .55rem; }
|
||||
.other-fix h3 { display: flex; align-items: center; gap: .5rem; }
|
||||
.other-fix h3 .type { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95rem; }
|
||||
.other-fix .why { color: var(--muted); font-size: .85rem; margin: .15rem 0 .5rem 0; }
|
||||
.other-fix .fix { background: #f3f4f6; padding: .45rem .6rem; border-radius: 4px; font-size: .85rem; }
|
||||
.other-fix .locs { font-size: .82rem; color: var(--muted); margin-top: .4rem; }
|
||||
.sev { display: inline-block; padding: .1rem .45rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.sev-info { background: var(--info); }
|
||||
.sev-warn { background: var(--warn); }
|
||||
.sev-crit { background: var(--crit); }
|
||||
.pill { display: inline-block; background: rgba(0,0,0,.06); padding: .1rem .5rem; border-radius: 999px; font-size: .75rem; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .85rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-top: .35rem; }
|
||||
th, td { text-align: left; padding: .4rem .65rem; border-bottom: 1px solid var(--border); }
|
||||
th { background: #f3f4f6; font-weight: 600; font-size: .72rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
details.errors { background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: .55rem .8rem; margin-top: 1rem; }
|
||||
details.errors summary { cursor: pointer; font-weight: 600; }
|
||||
details.errors li { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Legacy DNS records</h1>
|
||||
<div class="muted">{{if .Zone}}Zone: <code>{{.Zone}}</code> · {{end}}{{.ServicesScanned}} service(s) scanned · {{.Total}} legacy record(s) found</div>
|
||||
|
||||
<div class="status-banner {{.OverallClass}}" style="margin-top: 1rem;">
|
||||
<div>
|
||||
<div class="label">{{.OverallText}}</div>
|
||||
<div class="sub">{{if .Top}}Most severe: <code>{{.Top.TypeName}}</code> ({{.Top.Severity}}){{else}}No legacy records detected{{end}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Top}}
|
||||
<h2>Fix this first</h2>
|
||||
<div class="top-fix severity-{{.Top.SeverityCSS}}">
|
||||
<h3>
|
||||
<span class="type">{{.Top.TypeName}}</span>
|
||||
<span class="sev sev-{{.Top.SeverityCSS}}">{{.Top.Severity}}</span>
|
||||
<span class="pill">{{.Top.Count}} occurrence{{if ne .Top.Count 1}}s{{end}}</span>
|
||||
</h3>
|
||||
<div class="why">{{.Top.Reason}}{{if .Top.Replacement}} · use <code>{{.Top.Replacement}}</code> instead{{end}}</div>
|
||||
<div class="fix">
|
||||
<strong>How to fix</strong>
|
||||
{{.Top.HowToFix}}
|
||||
</div>
|
||||
{{if .Top.Locations}}
|
||||
<table>
|
||||
<thead><tr><th>Subdomain</th><th>Owner</th><th>Service</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Top.Locations}}
|
||||
<tr>
|
||||
<td><code>{{display .Subdomain}}</code></td>
|
||||
<td>{{if .Name}}<code>{{.Name}}</code>{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
<td>{{if .ServiceType}}<code>{{.ServiceType}}</code>{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Others}}
|
||||
<h2>Other legacy records</h2>
|
||||
{{range .Others}}
|
||||
<div class="other-fix">
|
||||
<h3>
|
||||
<span class="type">{{.TypeName}}</span>
|
||||
<span class="sev sev-{{.SeverityCSS}}">{{.Severity}}</span>
|
||||
<span class="pill">{{.Count}} occurrence{{if ne .Count 1}}s{{end}}</span>
|
||||
</h3>
|
||||
<div class="why">{{.Reason}}{{if .Replacement}} · use <code>{{.Replacement}}</code> instead{{end}}</div>
|
||||
<div class="fix"><strong>How to fix:</strong> {{.HowToFix}}</div>
|
||||
{{if .Locations}}
|
||||
<div class="locs">
|
||||
Owners:
|
||||
{{range $i, $l := .Locations}}{{if $i}}, {{end}}<code>{{display $l.Subdomain}}</code>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .CollectErrors}}
|
||||
<details class="errors">
|
||||
<summary>{{len .CollectErrors}} service(s) skipped during scan</summary>
|
||||
<ul>
|
||||
{{range .CollectErrors}}<li>{{.}}</li>{{end}}
|
||||
</ul>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
154
checker/rule.go
Normal file
154
checker/rule.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// legacyRecordsRule emits one CheckState per distinct legacy record type
|
||||
// found in the zone (not one per occurrence). This matches how operators
|
||||
// think about remediation ("fix the SPF record" is one task even when
|
||||
// the zone has six SPF records) and keeps the report's "fix these first"
|
||||
// section focused.
|
||||
type legacyRecordsRule struct{}
|
||||
|
||||
func (r *legacyRecordsRule) Name() string { return "legacy_records" }
|
||||
|
||||
func (r *legacyRecordsRule) Description() string {
|
||||
return "Detects DNS record types deprecated by the IETF (SPF, A6, KEY/SIG/NXT, WKS, MD/MF, NSAP, …) and reports each occurrence with the relevant RFC reference and a migration suggestion."
|
||||
}
|
||||
|
||||
func (r *legacyRecordsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
var data LegacyData
|
||||
if err := obs.Get(ctx, ObservationKeyLegacy, &data); err != nil {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to load legacy-records observation: %v", err),
|
||||
RuleName: r.Name(),
|
||||
Code: "legacy_records_error",
|
||||
}}
|
||||
}
|
||||
|
||||
if len(data.Findings) == 0 {
|
||||
// Even with zero findings we acknowledge the scan so the report
|
||||
// does not look empty. CollectErrors are surfaced as Info so a
|
||||
// silent-skip path doesn't masquerade as a clean pass.
|
||||
states := []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Message: fmt.Sprintf("No legacy record types detected (%d service(s) scanned)", data.ServicesScanned),
|
||||
RuleName: r.Name(),
|
||||
Code: "legacy_records_clean",
|
||||
}}
|
||||
for _, e := range data.CollectErrors {
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Message: "Skipped during scan: " + e,
|
||||
RuleName: r.Name(),
|
||||
Code: "legacy_records_skip",
|
||||
})
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
groups := groupFindings(data.Findings)
|
||||
out := make([]sdk.CheckState, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
info := deprecatedTypes[g.Rrtype]
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: severityToStatus(info.Severity),
|
||||
Message: buildMessage(g, info),
|
||||
RuleName: r.Name(),
|
||||
Code: "legacy_" + strings.ToLower(g.TypeName),
|
||||
Subject: g.TypeName,
|
||||
Meta: map[string]any{
|
||||
"rrtype": g.Rrtype,
|
||||
"type": g.TypeName,
|
||||
"reason": info.Reason,
|
||||
"replacement": info.Replacement,
|
||||
"how_to_fix": info.HowToFix,
|
||||
"severity": info.Severity.String(),
|
||||
"locations": g.Locations,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func severityToStatus(s DeprecatedSeverity) sdk.Status {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return sdk.StatusCrit
|
||||
case SeverityWarn:
|
||||
return sdk.StatusWarn
|
||||
default:
|
||||
return sdk.StatusInfo
|
||||
}
|
||||
}
|
||||
|
||||
func buildMessage(g groupedFinding, info DeprecationInfo) string {
|
||||
loc := "1 occurrence"
|
||||
if n := len(g.Locations); n > 1 {
|
||||
loc = fmt.Sprintf("%d occurrences", n)
|
||||
}
|
||||
if info.Replacement != "" {
|
||||
return fmt.Sprintf("%s record found (%s). %s; use %s instead.",
|
||||
g.TypeName, loc, info.Reason, info.Replacement)
|
||||
}
|
||||
return fmt.Sprintf("%s record found (%s). %s.",
|
||||
g.TypeName, loc, info.Reason)
|
||||
}
|
||||
|
||||
// groupedFinding aggregates Finding entries by record type so the rule
|
||||
// emits one CheckState per type, with Locations carrying the per-instance
|
||||
// detail for the report.
|
||||
type groupedFinding struct {
|
||||
Rrtype uint16
|
||||
TypeName string
|
||||
Locations []FindingLocation
|
||||
}
|
||||
|
||||
type FindingLocation struct {
|
||||
Subdomain string `json:"subdomain"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ServiceType string `json:"service_type,omitempty"`
|
||||
}
|
||||
|
||||
func groupFindings(fs []Finding) []groupedFinding {
|
||||
bytype := map[uint16]*groupedFinding{}
|
||||
for _, f := range fs {
|
||||
g, ok := bytype[f.Rrtype]
|
||||
if !ok {
|
||||
g = &groupedFinding{Rrtype: f.Rrtype, TypeName: f.TypeName}
|
||||
bytype[f.Rrtype] = g
|
||||
}
|
||||
g.Locations = append(g.Locations, FindingLocation{
|
||||
Subdomain: f.Subdomain,
|
||||
Name: f.Name,
|
||||
ServiceType: f.ServiceType,
|
||||
})
|
||||
}
|
||||
|
||||
out := make([]groupedFinding, 0, len(bytype))
|
||||
for _, g := range bytype {
|
||||
sort.SliceStable(g.Locations, func(i, j int) bool {
|
||||
return g.Locations[i].Subdomain < g.Locations[j].Subdomain
|
||||
})
|
||||
out = append(out, *g)
|
||||
}
|
||||
// Sort groups by descending severity then by type name so the most
|
||||
// urgent finding bubbles to the top of the rule output (the report
|
||||
// preserves this order when ranking the "fix this first" card).
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
si := deprecatedTypes[out[i].Rrtype].Severity
|
||||
sj := deprecatedTypes[out[j].Rrtype].Severity
|
||||
if si != sj {
|
||||
return si > sj
|
||||
}
|
||||
return out[i].TypeName < out[j].TypeName
|
||||
})
|
||||
return out
|
||||
}
|
||||
246
checker/rules_test.go
Normal file
246
checker/rules_test.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// orphanService builds a fake "svcs.Orphan" service body whose embedded RR
|
||||
// header matches the given (rrtype, owner). Used by every test below to
|
||||
// avoid duplicating the JSON shape.
|
||||
func orphanService(rrtype uint16, owner string) rawService {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"record": map[string]any{
|
||||
"Hdr": map[string]any{
|
||||
"Name": owner,
|
||||
"Rrtype": rrtype,
|
||||
"Class": uint16(1),
|
||||
"Ttl": uint32(3600),
|
||||
},
|
||||
},
|
||||
})
|
||||
return rawService{
|
||||
Type: "svcs.Orphan",
|
||||
Domain: owner,
|
||||
Service: body,
|
||||
}
|
||||
}
|
||||
|
||||
// modernService builds a non-orphan service body with no Hdr field, like
|
||||
// what a real svcs.MX or svcs.A would marshal. Used to assert the scanner
|
||||
// silently ignores services it cannot inspect.
|
||||
func modernService(svcType string) rawService {
|
||||
body, _ := json.Marshal(map[string]any{"preference": 10, "target": "mail.example.com."})
|
||||
return rawService{
|
||||
Type: svcType,
|
||||
Domain: "example.com.",
|
||||
Service: body,
|
||||
}
|
||||
}
|
||||
|
||||
func runCollect(t *testing.T, zone *rawZone) *LegacyData {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(zone)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal zone: %v", err)
|
||||
}
|
||||
var jsonZone map[string]any
|
||||
if err := json.Unmarshal(raw, &jsonZone); err != nil {
|
||||
t.Fatalf("unmarshal zone: %v", err)
|
||||
}
|
||||
|
||||
p := &legacyProvider{}
|
||||
out, err := p.Collect(context.Background(), sdk.CheckerOptions{"zone": jsonZone})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect: %v", err)
|
||||
}
|
||||
data, ok := out.(*LegacyData)
|
||||
if !ok {
|
||||
t.Fatalf("Collect returned %T, want *LegacyData", out)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestCollect_CleanZone(t *testing.T) {
|
||||
z := &rawZone{
|
||||
Services: map[string][]rawService{
|
||||
"": {modernService("svcs.A"), modernService("svcs.MX")},
|
||||
"www": {modernService("svcs.CNAME")},
|
||||
"mail": {modernService("svcs.A")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z)
|
||||
if data.ServicesScanned != 4 {
|
||||
t.Errorf("ServicesScanned = %d, want 4", data.ServicesScanned)
|
||||
}
|
||||
if len(data.Findings) != 0 {
|
||||
t.Errorf("Findings = %+v, want empty", data.Findings)
|
||||
}
|
||||
if len(data.CollectErrors) != 0 {
|
||||
t.Errorf("CollectErrors = %v, want empty (modern services must be skipped silently)", data.CollectErrors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollect_DetectsCommonLegacyTypes(t *testing.T) {
|
||||
z := &rawZone{
|
||||
Services: map[string][]rawService{
|
||||
"": {orphanService(dns.TypeSPF, "example.com.")},
|
||||
"old": {orphanService(38 /* A6 */, "old.example.com.")},
|
||||
"sec": {orphanService(dns.TypeKEY, "sec.example.com."), orphanService(dns.TypeNXT, "sec.example.com.")},
|
||||
"trash": {orphanService(11 /* WKS */, "trash.example.com.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z)
|
||||
|
||||
if got := len(data.Findings); got != 5 {
|
||||
t.Fatalf("Findings count = %d, want 5", got)
|
||||
}
|
||||
|
||||
want := map[string]bool{"SPF": false, "A6": false, "KEY": false, "NXT": false, "WKS": false}
|
||||
for _, f := range data.Findings {
|
||||
if _, ok := want[f.TypeName]; ok {
|
||||
want[f.TypeName] = true
|
||||
}
|
||||
}
|
||||
for k, ok := range want {
|
||||
if !ok {
|
||||
t.Errorf("missing finding for %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_GroupsAndRanksBySeverity(t *testing.T) {
|
||||
z := &rawZone{
|
||||
Services: map[string][]rawService{
|
||||
"": {orphanService(dns.TypeSPF, "example.com."), orphanService(dns.TypeSPF, "example.com.")},
|
||||
"a": {orphanService(dns.TypeKEY, "a.example.com.")}, // critical
|
||||
"b": {orphanService(11 /* WKS */, "b.example.com.")}, // info
|
||||
"c": {orphanService(11 /* WKS */, "c.example.com.")}, // info, second occurrence
|
||||
"d": {orphanService(dns.TypeNULL, "d.example.com.")}, // info
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z)
|
||||
|
||||
// Build a fake observation getter so we can call Evaluate without spinning a host.
|
||||
obs := staticObs{key: ObservationKeyLegacy, payload: mustMarshal(t, data)}
|
||||
rule := &legacyRecordsRule{}
|
||||
states := rule.Evaluate(context.Background(), obs, sdk.CheckerOptions{})
|
||||
|
||||
// 4 distinct types → 4 states.
|
||||
if len(states) != 4 {
|
||||
t.Fatalf("got %d states, want 4: %+v", len(states), states)
|
||||
}
|
||||
|
||||
// First state must be the critical KEY (severity wins, not first-seen).
|
||||
if states[0].Subject != "KEY" || states[0].Status != sdk.StatusCrit {
|
||||
t.Errorf("top state = %+v, want KEY/Crit", states[0])
|
||||
}
|
||||
// SPF (warn) must come before WKS / NULL (info).
|
||||
if states[1].Subject != "SPF" || states[1].Status != sdk.StatusWarn {
|
||||
t.Errorf("second state = %+v, want SPF/Warn", states[1])
|
||||
}
|
||||
|
||||
// SPF state should carry both occurrences in Meta.locations.
|
||||
locs, _ := states[1].Meta["locations"].([]FindingLocation)
|
||||
if len(locs) != 2 {
|
||||
t.Errorf("SPF Meta.locations length = %d, want 2", len(locs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_EmptyZoneReturnsOK(t *testing.T) {
|
||||
data := &LegacyData{Zone: "example.com", ServicesScanned: 3}
|
||||
obs := staticObs{key: ObservationKeyLegacy, payload: mustMarshal(t, data)}
|
||||
|
||||
states := (&legacyRecordsRule{}).Evaluate(context.Background(), obs, sdk.CheckerOptions{})
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusOK {
|
||||
t.Fatalf("want single OK state, got %+v", states)
|
||||
}
|
||||
if !strings.Contains(states[0].Message, "3 service(s) scanned") {
|
||||
t.Errorf("OK message = %q, want it to mention scanned count", states[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollect_MissingZoneOptionFails(t *testing.T) {
|
||||
p := &legacyProvider{}
|
||||
_, err := p.Collect(context.Background(), sdk.CheckerOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when 'zone' option is missing, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReport_TopCardMatchesWorstSeverity(t *testing.T) {
|
||||
// SPF (warn) + WKS (info) → top must be SPF.
|
||||
z := &rawZone{
|
||||
Services: map[string][]rawService{
|
||||
"a": {orphanService(dns.TypeSPF, "a.example.com.")},
|
||||
"b": {orphanService(11 /* WKS */, "b.example.com.")},
|
||||
},
|
||||
}
|
||||
data := runCollect(t, z)
|
||||
|
||||
html, err := (&legacyProvider{}).GetHTMLReport(staticReportCtx{data: mustMarshal(t, data)})
|
||||
if err != nil {
|
||||
t.Fatalf("GetHTMLReport: %v", err)
|
||||
}
|
||||
if !strings.Contains(html, "Fix this first") {
|
||||
t.Errorf("report missing 'Fix this first' card")
|
||||
}
|
||||
// The headline finding should reference SPF, not WKS.
|
||||
if i, j := strings.Index(html, "Fix this first"), strings.Index(html, "Other legacy records"); i < 0 || j < 0 || !strings.Contains(html[i:j], "SPF") {
|
||||
t.Errorf("'Fix this first' section does not reference SPF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReport_OKBannerWhenNoFindings(t *testing.T) {
|
||||
html, err := (&legacyProvider{}).GetHTMLReport(staticReportCtx{
|
||||
data: mustMarshal(t, &LegacyData{Zone: "example.com", ServicesScanned: 5}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetHTMLReport: %v", err)
|
||||
}
|
||||
if !strings.Contains(html, "status-ok") {
|
||||
t.Errorf("report missing OK banner: %q", html[:min(300, len(html))])
|
||||
}
|
||||
}
|
||||
|
||||
// --- test helpers ---------------------------------------------------------
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
type staticObs struct {
|
||||
key sdk.ObservationKey
|
||||
payload []byte
|
||||
}
|
||||
|
||||
func (s staticObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
|
||||
if key != s.key {
|
||||
return fmt.Errorf("staticObs: unexpected observation key %q (have %q)", key, s.key)
|
||||
}
|
||||
return json.Unmarshal(s.payload, dest)
|
||||
}
|
||||
|
||||
func (s staticObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type staticReportCtx struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (s staticReportCtx) Data() json.RawMessage { return s.data }
|
||||
func (s staticReportCtx) Related(_ sdk.ObservationKey) []sdk.RelatedObservation { return nil }
|
||||
func (s staticReportCtx) States() []sdk.CheckState { return nil }
|
||||
79
checker/types.go
Normal file
79
checker/types.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Package checker implements the happyDomain "legacy records" checker:
|
||||
// it scans a zone for DNS record types that have been deprecated by the
|
||||
// IETF and reports each occurrence with the relevant RFC reference and a
|
||||
// concrete migration suggestion.
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
const ObservationKeyLegacy = "legacy_records"
|
||||
|
||||
// LegacyData carries raw facts only; severity and remediation are decided
|
||||
// by the rules and the report layer.
|
||||
type LegacyData struct {
|
||||
Zone string `json:"zone,omitempty"`
|
||||
|
||||
// ServicesScanned counts every service inspected, regardless of whether
|
||||
// it produced a finding. It anchors the "x services scanned" line in the
|
||||
// report so an empty Findings slice is unambiguous (we did look).
|
||||
ServicesScanned int `json:"services_scanned"`
|
||||
|
||||
// Findings lists every legacy record encountered, one entry per record
|
||||
// instance. Two SPF records under the same subdomain produce two entries
|
||||
// so the report can show counts and locations honestly.
|
||||
Findings []Finding `json:"findings,omitempty"`
|
||||
|
||||
// CollectErrors records non-fatal problems encountered while parsing the
|
||||
// zone payload (malformed orphan, unknown rrtype, …). They surface in
|
||||
// the report so silent skips do not look like clean passes.
|
||||
CollectErrors []string `json:"collect_errors,omitempty"`
|
||||
}
|
||||
|
||||
type Finding struct {
|
||||
// Subdomain is the owner relative to the zone apex. Empty string means
|
||||
// apex (rendered as "@" in the report).
|
||||
Subdomain string `json:"subdomain"`
|
||||
|
||||
// Name is the FQDN owner from the RR header. Optional; Subdomain is always set.
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
Rrtype uint16 `json:"rrtype"`
|
||||
|
||||
// TypeName is the textual record type (e.g. "SPF", "A6"). Filled even
|
||||
// for types miekg/dns does not know about (rendered as "TYPE<n>").
|
||||
TypeName string `json:"type"`
|
||||
|
||||
// ServiceType is the happyDomain service that exposed the record
|
||||
// (typically "svcs.Orphan" since happyDomain has no dedicated service
|
||||
// for legacy types). Useful to point users at the right edit screen.
|
||||
ServiceType string `json:"service_type,omitempty"`
|
||||
}
|
||||
|
||||
// rawZone is the minimal slice of happyDomain's *Zone JSON we consume.
|
||||
// It intentionally redeclares only the fields we need: this lets the
|
||||
// checker compile without depending on the happyDomain module and shields
|
||||
// us from unrelated schema changes.
|
||||
type rawZone struct {
|
||||
DomainName string `json:"domain_name,omitempty"`
|
||||
Services map[string][]rawService `json:"services"`
|
||||
}
|
||||
|
||||
type rawService struct {
|
||||
Type string `json:"_svctype"`
|
||||
Domain string `json:"_domain"`
|
||||
Service json.RawMessage `json:"Service"`
|
||||
}
|
||||
|
||||
// orphanBody mirrors svcs.Orphan's JSON shape just enough to extract the
|
||||
// underlying RR header. dns.RR_Header has no JSON tags, so the field names
|
||||
// are the exact Go names ("Hdr", "Rrtype", …).
|
||||
type orphanBody struct {
|
||||
Record struct {
|
||||
Hdr struct {
|
||||
Name string `json:"Name"`
|
||||
Rrtype uint16 `json:"Rrtype"`
|
||||
} `json:"Hdr"`
|
||||
} `json:"record"`
|
||||
}
|
||||
16
go.mod
Normal file
16
go.mod
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
module git.happydns.org/checker-legacy-records
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v1.5.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.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
||||
git.happydns.org/checker-sdk-go v1.5.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=
|
||||
29
main.go
Normal file
29
main.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Command checker-legacy-records is the standalone HTTP server entrypoint
|
||||
// for the legacy DNS records checker.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
legacy "git.happydns.org/checker-legacy-records/checker"
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
)
|
||||
|
||||
// Version is overridden at build time:
|
||||
//
|
||||
// go build -ldflags "-X main.Version=1.2.3" .
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
legacy.Version = Version
|
||||
|
||||
srv := server.New(legacy.Provider())
|
||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
15
plugin/plugin.go
Normal file
15
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Command plugin is the happyDomain plugin entrypoint for the legacy
|
||||
// records checker. It is built as a Go plugin and loaded at runtime.
|
||||
package main
|
||||
|
||||
import (
|
||||
legacy "git.happydns.org/checker-legacy-records/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
legacy.Version = Version
|
||||
return legacy.Definition(), legacy.Provider(), nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue