From d4a59fb9e80c3ddfbe1f080085b8952ad00a7eec Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Mon, 27 Apr 2026 00:58:19 +0700 Subject: [PATCH] Initial commit --- .gitignore | 2 + Dockerfile | 17 +++ LICENSE | 21 ++++ Makefile | 28 +++++ NOTICE | 26 ++++ README.md | 59 +++++++++ checker/collect.go | 138 ++++++++++++++++++++ checker/definition.go | 53 ++++++++ checker/deprecated.go | 238 +++++++++++++++++++++++++++++++++++ checker/provider.go | 16 +++ checker/report.go | 286 ++++++++++++++++++++++++++++++++++++++++++ checker/rule.go | 154 +++++++++++++++++++++++ checker/rules_test.go | 246 ++++++++++++++++++++++++++++++++++++ checker/types.go | 79 ++++++++++++ go.mod | 16 +++ go.sum | 16 +++ main.go | 29 +++++ plugin/plugin.go | 15 +++ 18 files changed, 1439 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/collect.go create mode 100644 checker/definition.go create mode 100644 checker/deprecated.go create mode 100644 checker/provider.go create mode 100644 checker/report.go create mode 100644 checker/rule.go create mode 100644 checker/rules_test.go create mode 100644 checker/types.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e790ff6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-legacy-records +checker-legacy-records.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9517f01 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9fda340 --- /dev/null +++ b/Makefile @@ -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 diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2f80849 --- /dev/null +++ b/NOTICE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ebefa9 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..f436cdc --- /dev/null +++ b/checker/collect.go @@ -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 +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..8f11619 --- /dev/null +++ b/checker/definition.go @@ -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 +} diff --git a/checker/deprecated.go b/checker/deprecated.go new file mode 100644 index 0000000..93bbea8 --- /dev/null +++ b/checker/deprecated.go @@ -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() +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..0fd802b --- /dev/null +++ b/checker/provider.go @@ -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() } diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..12f4352 --- /dev/null +++ b/checker/report.go @@ -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 = ` + + + +Legacy DNS records — {{if .Zone}}{{.Zone}}{{else}}zone report{{end}} + + + +

Legacy DNS records

+
{{if .Zone}}Zone: {{.Zone}} · {{end}}{{.ServicesScanned}} service(s) scanned · {{.Total}} legacy record(s) found
+ +
+
+
{{.OverallText}}
+
{{if .Top}}Most severe: {{.Top.TypeName}} ({{.Top.Severity}}){{else}}No legacy records detected{{end}}
+
+
+ + {{if .Top}} +

Fix this first

+
+

+ {{.Top.TypeName}} + {{.Top.Severity}} + {{.Top.Count}} occurrence{{if ne .Top.Count 1}}s{{end}} +

+
{{.Top.Reason}}{{if .Top.Replacement}} · use {{.Top.Replacement}} instead{{end}}
+
+ How to fix + {{.Top.HowToFix}} +
+ {{if .Top.Locations}} + + + + {{range .Top.Locations}} + + + + + + {{end}} + +
SubdomainOwnerService
{{display .Subdomain}}{{if .Name}}{{.Name}}{{else}}{{end}}{{if .ServiceType}}{{.ServiceType}}{{else}}{{end}}
+ {{end}} +
+ {{end}} + + {{if .Others}} +

Other legacy records

+ {{range .Others}} +
+

+ {{.TypeName}} + {{.Severity}} + {{.Count}} occurrence{{if ne .Count 1}}s{{end}} +

+
{{.Reason}}{{if .Replacement}} · use {{.Replacement}} instead{{end}}
+
How to fix: {{.HowToFix}}
+ {{if .Locations}} +
+ Owners: + {{range $i, $l := .Locations}}{{if $i}}, {{end}}{{display $l.Subdomain}}{{end}} +
+ {{end}} +
+ {{end}} + {{end}} + + {{if .CollectErrors}} +
+ {{len .CollectErrors}} service(s) skipped during scan + +
+ {{end}} + + +` diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..6edf06c --- /dev/null +++ b/checker/rule.go @@ -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 +} diff --git a/checker/rules_test.go b/checker/rules_test.go new file mode 100644 index 0000000..c176752 --- /dev/null +++ b/checker/rules_test.go @@ -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 } diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..4a3dddd --- /dev/null +++ b/checker/types.go @@ -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"). + 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"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f033556 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2a80023 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..99bc9f9 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..6b649f4 --- /dev/null +++ b/plugin/plugin.go @@ -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 +}