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