checker-legacy-records/checker/collect.go

138 lines
4 KiB
Go

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
}