Initial commit

This commit is contained in:
nemunaire 2026-04-27 00:58:19 +07:00
commit d4a59fb9e8
18 changed files with 1439 additions and 0 deletions

138
checker/collect.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"`
}