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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-legacy-records
checker-legacy-records.so

17
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
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"`
}

16
go.mod Normal file
View 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
View 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
View 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
View 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
}