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 }