commit 1d93a25983986069048f330942ed8ec6f5e861bd Author: Pierre-Olivier Mercier Date: Mon Apr 27 01:06:32 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4408c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-reverse-zone +checker-reverse-zone.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..51fca85 --- /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 -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-reverse-zone . + +FROM scratch +COPY --from=builder /checker-reverse-zone /checker-reverse-zone +USER 65534:65534 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["/checker-reverse-zone", "-healthcheck"] +ENTRYPOINT ["/checker-reverse-zone"] 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..818d9d2 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +CHECKER_NAME := checker-reverse-zone +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 -tags standalone -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..21dd0b9 --- /dev/null +++ b/NOTICE @@ -0,0 +1,26 @@ +checker-reverse-zone +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..1f334e9 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# checker-reverse-zone + +PTR coverage checker for reverse DNS zones in [happyDomain](https://www.happydomain.org/). + +Inspects every PTR record declared in an `in-addr.arpa` or `ip6.arpa` reverse zone, +validates Forward-Confirmed Reverse DNS (FCrDNS), target resolvability, hostname +syntax, generic/auto-generated hostnames, TTL hygiene, and multiple-PTR-per-IP +violations (RFC 1912 §2.1). + +## Usage + +### Standalone HTTP server + +```bash +# Build and run +make +./checker-reverse-zone -listen :8080 +``` + +The server exposes: + +- `GET /health`: health check +- `POST /collect`: collect reverse-zone observations (happyDomain external checker protocol) + +### Docker + +```bash +make docker +docker run -p 8080:8080 happydomain/checker-reverse-zone +``` + +### happyDomain plugin + +```bash +make plugin +# produces checker-reverse-zone.so, loadable by happyDomain as a Go plugin +``` + +The plugin exposes a `NewCheckerPlugin` symbol returning the checker +definition and observation provider, which happyDomain registers in its +global registries at load time. + +### Versioning + +The binary, plugin, and Docker image embed a version string overridable +at build time: + +```bash +make CHECKER_VERSION=1.2.3 +make plugin CHECKER_VERSION=1.2.3 +make docker CHECKER_VERSION=1.2.3 +``` + +### happyDomain remote endpoint + +Set the `endpoint` admin option for the reverse-zone checker to the URL of the +running checker-reverse-zone server (e.g., `http://checker-reverse-zone:8080`). +happyDomain will delegate observation collection to this endpoint. + +## Options + +| Id | Type | Default | Description | +|-----------------------|------|---------|----------------------------------------------------------------------------------------------------------------------| +| `requireForwardMatch` | bool | `true` | When enabled, a PTR whose target does not resolve back to the original IP is reported as critical (otherwise warning). Mail and SSH servers require FCrDNS. | +| `allowMultiplePTR` | bool | `false` | When enabled, more than one PTR at the same owner is allowed (RFC 1912 §2.1 recommends a single PTR per IP). | +| `minTTL` | uint | `300` | PTR records with a TTL below this threshold (in seconds) are flagged as warning. | +| `flagGenericPTR` | bool | `true` | When enabled, PTR targets that embed the dotted IP or match common ISP auto-generated patterns are reported as warning. | +| `maxPTRsToCheck` | uint | `1024` | Caps the number of PTR records inspected per run, protecting the checker against very large reverse zones. | + +## Rules + +Each rule emits a finding code. Severity can be affected by the options above. + +| Code | Default severity | Condition | +|------|-----------------|-----------| +| `reverse_zone_not_arpa` | critical | The zone is not under `in-addr.arpa` or `ip6.arpa`. | +| `reverse_zone.load_error` | error | A structural failure prevented observation collection. | +| `reverse_zone_empty` | warning | The reverse zone declares no PTR records at all. | +| `ptr_forward_mismatch` | critical / warning with `requireForwardMatch=false` | A PTR target's A/AAAA records do not include the original IP (FCrDNS mismatch). | +| `ptr_target_unresolvable` | critical / warning with `requireForwardMatch=false` | A PTR target has no A or AAAA record in the forward DNS. | +| `ptr_multiple` | warning | An IP owner carries more than one PTR record. Skipped when `allowMultiplePTR=true`. | +| `ptr_target_invalid` | critical | A PTR target is not a syntactically valid hostname (RFC 952/1123). | +| `ptr_generic_hostname` | warning | A PTR target embeds the IP address or matches common ISP auto-generated patterns. Skipped when `flagGenericPTR=false`. | +| `ptr_low_ttl` | warning | A PTR record's TTL is below `minTTL`. | +| `reverse_zone_truncated` | info | The zone has more PTR records than `maxPTRsToCheck`; only the first batch was inspected. | + +## License + +Licensed under the **MIT License** (see `LICENSE`). diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..f900a5e --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,227 @@ +package checker + +import ( + "context" + "encoding/json" + "net" + "regexp" + "strings" + "sync" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Collect runs forward resolution for each PTR; severity decisions are left to rules. +func (p *reverseZoneProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + zoneName, _ := sdk.GetOption[string](opts, "domain_name") + zoneName = lowerFQDN(zoneName) + + data := &ReverseZoneData{ + Zone: zoneName, + IsReverseZone: isReverseArpa(zoneName), + IsIPv6: isIPv6Arpa(zoneName), + } + + zoneObj, ok := sdk.GetOption[zoneMessage](opts, "zone") + if !ok || zoneObj.Services == nil { + data.LoadError = "no zone data available (missing 'zone' auto-fill)" + return data, nil + } + + maxToCheck := sdk.GetIntOption(opts, "maxPTRsToCheck", 1024) + if maxToCheck <= 0 { + maxToCheck = 1024 + } + + type rawPTR struct { + owner string + sub string + target string + ttl uint32 + } + + var raws []rawPTR + for sub, services := range zoneObj.Services { + for _, svc := range services { + if svc.Type != "svcs.PTR" || len(svc.Service) == 0 { + continue + } + var s ptrService + if err := json.Unmarshal(svc.Service, &s); err != nil || s.Record == nil { + continue + } + owner := buildOwnerName(sub, zoneName) + target := "" + if s.Record.Ptr != "" { + target = lowerFQDN(s.Record.Ptr) + } + raws = append(raws, rawPTR{ + owner: owner, + sub: sub, + target: target, + ttl: s.Record.Hdr.Ttl, + }) + } + } + + data.PTRCount = len(raws) + if len(raws) > maxToCheck { + data.Truncated = true + raws = raws[:maxToCheck] + } + + entriesByOwner := make(map[string]*PTREntry) + var ordered []string + for _, r := range raws { + entry, exists := entriesByOwner[r.owner] + if !exists { + ip := reverseNameToIP(r.owner) + ipStr := "" + if ip != nil { + ipStr = ip.String() + } + entry = &PTREntry{ + OwnerName: r.owner, + Subdomain: r.sub, + ReverseIP: ipStr, + TTL: r.ttl, + } + entriesByOwner[r.owner] = entry + ordered = append(ordered, r.owner) + } + if r.target != "" && !contains(entry.Targets, r.target) { + entry.Targets = append(entry.Targets, r.target) + } + // When several PTRs share an owner, surface the shortest non-zero TTL: + // the cache lifetime of the RRset is bounded by the smallest member. + if r.ttl > 0 && (entry.TTL == 0 || r.ttl < entry.TTL) { + entry.TTL = r.ttl + } + } + + // Forward-resolve each effective target in parallel. Bound the fan-out so + // a 1024-PTR zone does not burst into a thousand simultaneous lookups. + const maxConcurrent = 16 + sem := make(chan struct{}, maxConcurrent) + var wg sync.WaitGroup + + for _, owner := range ordered { + if ctx.Err() != nil { + break + } + entry := entriesByOwner[owner] + if len(entry.Targets) == 0 { + continue + } + target := entry.Targets[0] + if _, ok := dns.IsDomainName(strings.TrimSuffix(target, ".")); ok { + entry.TargetSyntaxValid = true + } + ip := reverseNameToIP(entry.OwnerName) + if ip != nil { + entry.TargetLooksGeneric = looksGeneric(target, ip) + } + + wg.Add(1) + sem <- struct{}{} + go func(e *PTREntry, target string, ip net.IP) { + defer wg.Done() + defer func() { <-sem }() + addrs, ferr := resolveForward(ctx, target) + match := false + for _, a := range addrs { + if ip != nil && ipEqual(a.Address, ip) { + match = true + break + } + } + e.ForwardAddresses = addrs + e.TargetResolves = len(addrs) > 0 + e.ForwardMatch = match + if ferr != "" { + e.ForwardError = ferr + } + }(entry, target, ip) + } + wg.Wait() + + data.Entries = make([]PTREntry, len(ordered)) + for i, owner := range ordered { + data.Entries[i] = *entriesByOwner[owner] + } + return data, nil +} + +// buildOwnerName joins subdomain to zone apex; "" / "@" means apex. +func buildOwnerName(sub, zone string) string { + zone = strings.TrimSuffix(lowerFQDN(zone), ".") + if sub == "" || sub == "@" { + return dns.Fqdn(zone) + } + sub = strings.TrimSuffix(strings.ToLower(sub), ".") + if zone == "" { + return dns.Fqdn(sub) + } + return dns.Fqdn(sub + "." + zone) +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +var genericHints = regexp.MustCompile(`(?i)\b(dhcp|dyn(amic)?|dsl|cable|ppp|pool|client|broadband|static|user|host|ip)[-.]\d+([-.]\d+){1,3}\b`) + +func looksGeneric(hostname string, ip net.IP) bool { + h := strings.ToLower(hostname) + + if v4 := ip.To4(); v4 != nil { + ipStr := v4.String() + if strings.Contains(h, ipStr) { + return true + } + if strings.Contains(h, strings.ReplaceAll(ipStr, ".", "-")) { + return true + } + } else if v6 := ip.To16(); v6 != nil { + var hexBuf [32]byte + const hexdigits = "0123456789abcdef" + for i, b := range v6 { + hexBuf[i*2] = hexdigits[b>>4] + hexBuf[i*2+1] = hexdigits[b&0x0f] + } + flat := string(hexBuf[:]) + if strings.Contains(h, flat) { + return true + } + groups := []string{ + flat[0:4], flat[4:8], flat[8:12], flat[12:16], + flat[16:20], flat[20:24], flat[24:28], flat[28:32], + } + for _, sep := range []string{"-", "."} { + for start := 0; start <= 4; start++ { + probe := strings.Join(groups[start:start+4], sep) + if strings.Contains(h, probe) { + return true + } + } + } + nibbles := make([]string, 32) + for i, c := range flat { + nibbles[i] = string(c) + } + for start := 0; start <= 32-16; start++ { + probe := strings.Join(nibbles[start:start+16], ".") + if strings.Contains(h, probe) { + return true + } + } + } + return genericHints.MatchString(h) +} diff --git a/checker/collect_test.go b/checker/collect_test.go new file mode 100644 index 0000000..17931dc --- /dev/null +++ b/checker/collect_test.go @@ -0,0 +1,245 @@ +package checker + +import ( + "context" + "encoding/json" + "net" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func TestContains(t *testing.T) { + if !contains([]string{"a", "b", "c"}, "b") { + t.Error("contains: expected true for present element") + } + if contains([]string{"a", "b"}, "z") { + t.Error("contains: expected false for missing element") + } + if contains(nil, "x") { + t.Error("contains: expected false for nil slice") + } + if contains([]string{}, "") { + t.Error("contains: expected false for empty slice") + } +} + +func TestLooksGeneric_IPv4(t *testing.T) { + ip := net.ParseIP("203.0.113.42") + cases := []struct { + host string + want bool + }{ + {"203.0.113.42.example.net", true}, // dotted IP embedded + {"host-203-0-113-42.isp.example", true}, // dashed IP embedded + {"dhcp-1-2-3-4.client.example.com", true}, // ISP pattern + {"static.203.0.113.42.rev.example", true}, // dotted form + {"pool-100-200-1-2.broadband.example", true}, // pool pattern (different IP but matches regex) + {"mail.example.com", false}, // clean + {"customer.example.com", false}, // clean + } + for _, c := range cases { + if got := looksGeneric(c.host, ip); got != c.want { + t.Errorf("looksGeneric(%q, %v)=%v, want %v", c.host, ip, got, c.want) + } + } +} + +func TestLooksGeneric_IPv6(t *testing.T) { + ip := net.ParseIP("2001:db8::1") + if ip == nil { + t.Fatal("parse ip") + } + cases := []struct { + host string + want bool + }{ + {"20010db8000000000000000000000001.example.com", true}, // flat 32-nibble + {"2001-0db8-0000-0000.dyn.example", true}, // dashed group + {"2001.0db8.0000.0000.example", true}, // dotted group + {"mail.example.com", false}, + } + for _, c := range cases { + if got := looksGeneric(c.host, ip); got != c.want { + t.Errorf("looksGeneric(%q, %v)=%v, want %v", c.host, ip, got, c.want) + } + } +} + +func TestBuildOwnerName_Edge(t *testing.T) { + // Lowercases sub, strips trailing dot, joins to FQDN. + cases := []struct { + sub, zone, want string + }{ + {"FOO", "1.168.192.in-addr.arpa", "foo.1.168.192.in-addr.arpa."}, + {"foo.", "1.168.192.in-addr.arpa", "foo.1.168.192.in-addr.arpa."}, + {"foo", "", "foo."}, + } + for _, c := range cases { + if got := buildOwnerName(c.sub, c.zone); got != c.want { + t.Errorf("buildOwnerName(%q,%q)=%q, want %q", c.sub, c.zone, got, c.want) + } + } +} + +// TestCollect_NoZoneAutofill verifies that Collect returns a structured +// LoadError (not an error) when the host did not provide the zone autofill. +func TestCollect_NoZoneAutofill(t *testing.T) { + p := &reverseZoneProvider{} + opts := sdk.CheckerOptions{"domain_name": "1.168.192.in-addr.arpa"} + out, err := p.Collect(context.Background(), opts) + if err != nil { + t.Fatalf("Collect: %v", err) + } + data, ok := out.(*ReverseZoneData) + if !ok { + t.Fatalf("Collect returned %T, want *ReverseZoneData", out) + } + if data.LoadError == "" { + t.Errorf("expected LoadError to be set, got data=%+v", data) + } + if !data.IsReverseZone { + t.Errorf("IsReverseZone should be true for in-addr.arpa zone") + } +} + +// TestCollect_NotReverseZone exercises the path where a non-arpa zone is +// passed: zone metadata is recorded but IsReverseZone stays false. +func TestCollect_NotReverseZone(t *testing.T) { + p := &reverseZoneProvider{} + opts := sdk.CheckerOptions{"domain_name": "example.com"} + out, _ := p.Collect(context.Background(), opts) + data := out.(*ReverseZoneData) + if data.IsReverseZone { + t.Errorf("example.com should not be a reverse zone") + } + if data.IsIPv6 { + t.Errorf("example.com should not be IPv6 reverse zone") + } +} + +// TestCollect_PTRDeduplication verifies that multiple PTR entries on the +// same owner are merged into a single PTREntry with merged Targets, and +// that targets are deduplicated. +func TestCollect_PTRDeduplication(t *testing.T) { + zone := buildZoneWithPTRs(t, map[string][]string{ + "42": {"a.example.com.", "a.example.com.", "b.example.com."}, + "43": {"c.example.com."}, + }) + opts := sdk.CheckerOptions{ + "domain_name": "1.168.192.in-addr.arpa", + "zone": zone, + "maxPTRsToCheck": float64(1024), + } + p := &reverseZoneProvider{} + out, err := p.Collect(context.Background(), opts) + if err != nil { + t.Fatalf("Collect: %v", err) + } + data := out.(*ReverseZoneData) + if data.PTRCount != 4 { + t.Errorf("PTRCount=%d, want 4", data.PTRCount) + } + if len(data.Entries) != 2 { + t.Fatalf("len(Entries)=%d, want 2", len(data.Entries)) + } + byOwner := map[string]PTREntry{} + for _, e := range data.Entries { + byOwner[e.OwnerName] = e + } + e42 := byOwner["42.1.168.192.in-addr.arpa."] + if len(e42.Targets) != 2 { + t.Errorf("entry 42 Targets=%v, want 2 unique", e42.Targets) + } + if e42.ReverseIP != "192.168.1.42" { + t.Errorf("entry 42 ReverseIP=%q, want 192.168.1.42", e42.ReverseIP) + } +} + +// TestCollect_Truncation ensures the maxPTRsToCheck cap is enforced and +// reported via Truncated. +func TestCollect_Truncation(t *testing.T) { + ptrs := map[string][]string{} + for i := 0; i < 5; i++ { + ptrs[itoa(i)] = []string{"host.example.com."} + } + zone := buildZoneWithPTRs(t, ptrs) + opts := sdk.CheckerOptions{ + "domain_name": "1.168.192.in-addr.arpa", + "zone": zone, + "maxPTRsToCheck": float64(2), + } + p := &reverseZoneProvider{} + out, _ := p.Collect(context.Background(), opts) + data := out.(*ReverseZoneData) + if !data.Truncated { + t.Errorf("expected Truncated=true") + } + if data.PTRCount != 5 { + t.Errorf("PTRCount=%d, want 5", data.PTRCount) + } + if len(data.Entries) > 2 { + t.Errorf("inspected %d entries, want <=2", len(data.Entries)) + } +} + +// buildZoneWithPTRs constructs a zoneMessage round-tripped through JSON so +// that GetOption[zoneMessage] picks it up via the same path the host uses. +func buildZoneWithPTRs(t *testing.T, ptrs map[string][]string) any { + t.Helper() + services := map[string][]map[string]any{} + for sub, targets := range ptrs { + for _, target := range targets { + rec := map[string]any{ + "Hdr": map[string]any{ + "Name": sub + ".1.168.192.in-addr.arpa.", + "Rrtype": 12, + "Class": 1, + "Ttl": 3600, + }, + "Ptr": target, + } + svc := map[string]any{"Record": rec} + svcRaw, _ := json.Marshal(svc) + services[sub] = append(services[sub], map[string]any{ + "_svctype": "svcs.PTR", + "_domain": sub, + "Service": json.RawMessage(svcRaw), + }) + } + } + zone := map[string]any{ + "default_ttl": 3600, + "services": services, + } + // Round-trip through JSON so the value lives in the options map exactly + // as it would when received from the SDK. + raw, err := json.Marshal(zone) + if err != nil { + t.Fatalf("marshal zone: %v", err) + } + var generic any + if err := json.Unmarshal(raw, &generic); err != nil { + t.Fatalf("unmarshal zone: %v", err) + } + return generic +} + +func itoa(i int) string { + const digits = "0123456789" + if i == 0 { + return "0" + } + var buf [3]byte + n := 0 + for i > 0 { + buf[n] = digits[i%10] + i /= 10 + n++ + } + out := make([]byte, n) + for j := 0; j < n; j++ { + out[j] = buf[n-1-j] + } + return string(out) +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..07f232c --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,83 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var Version = "built-in" + +// Definition returns the CheckerDefinition for the reverse-zone checker. +func (p *reverseZoneProvider) Definition() *sdk.CheckerDefinition { + def := &sdk.CheckerDefinition{ + ID: "reverse-zone", + Name: "Reverse zone (PTR coverage)", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToDomain: true, + ApplyToZone: true, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKey}, + HasHTMLReport: true, + Options: sdk.CheckerOptionsDocumentation{ + UserOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "requireForwardMatch", + Type: "bool", + Label: "Require forward-confirmed reverse DNS (FCrDNS)", + Description: "When enabled, a PTR whose target does not resolve back to the original IP is reported as critical (otherwise as warning). Mail and SSH servers require FCrDNS.", + Default: true, + }, + { + Id: "allowMultiplePTR", + Type: "bool", + Label: "Allow multiple PTR records on the same IP", + Description: "When disabled, more than one PTR at the same owner is reported as warning (RFC 1912 §2.1 recommends a single PTR per IP).", + Default: false, + }, + { + Id: "minTTL", + Type: "uint", + Label: "Minimum PTR TTL (seconds)", + Description: "PTR records with a TTL below this threshold are flagged as warning. Very short TTLs degrade resolver cache efficiency.", + Default: float64(300), + }, + { + Id: "flagGenericPTR", + Type: "bool", + Label: "Flag generic-looking PTR hostnames", + Description: "When enabled, PTR targets that embed the dotted IP or match common ISP auto-generated patterns are reported as warning.", + Default: true, + }, + { + Id: "maxPTRsToCheck", + Type: "uint", + Label: "Maximum PTRs to inspect", + Description: "Caps the number of PTR records inspected per run, protecting the checker against very large reverse zones.", + Default: float64(1024), + }, + }, + DomainOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain_name", + Label: "Reverse zone", + AutoFill: sdk.AutoFillDomainName, + }, + { + Id: "zone", + Label: "Zone services", + AutoFill: sdk.AutoFillZone, + }, + }, + }, + Rules: Rules(), + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 24 * time.Hour, + Default: 1 * time.Hour, + }, + } + def.BuildRulesInfo() + return def +} diff --git a/checker/dns.go b/checker/dns.go new file mode 100644 index 0000000..db8f377 --- /dev/null +++ b/checker/dns.go @@ -0,0 +1,152 @@ +package checker + +import ( + "context" + "net" + "strconv" + "strings" + "time" + + "github.com/miekg/dns" +) + +const dnsTimeout = 5 * time.Second + +// FallbackResolver is the resolver used when /etc/resolv.conf is missing or +// empty. +var FallbackResolver = net.JoinHostPort("1.1.1.1", "53") + +func systemResolver() string { + cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil || len(cfg.Servers) == 0 { + return FallbackResolver + } + return net.JoinHostPort(cfg.Servers[0], cfg.Port) +} + +func dnsExchange(ctx context.Context, server string, q dns.Question) (*dns.Msg, error) { + client := dns.Client{Timeout: dnsTimeout} + m := new(dns.Msg) + m.Id = dns.Id() + m.Question = []dns.Question{q} + m.RecursionDesired = true + m.SetEdns0(4096, true) + + if deadline, ok := ctx.Deadline(); ok { + if d := time.Until(deadline); d > 0 && d < client.Timeout { + client.Timeout = d + } + } + + r, _, err := client.Exchange(m, server) + if err != nil { + return nil, err + } + if r != nil && r.Truncated { + if ctx.Err() != nil { + return r, nil + } + tcpClient := dns.Client{Net: "tcp", Timeout: client.Timeout} + if r2, _, err2 := tcpClient.Exchange(m, server); err2 == nil && r2 != nil { + return r2, nil + } + } + return r, nil +} + +func lowerFQDN(name string) string { + return strings.ToLower(dns.Fqdn(name)) +} + +func isReverseArpa(name string) bool { + n := lowerFQDN(name) + return strings.HasSuffix(n, ".in-addr.arpa.") || n == "in-addr.arpa." || + strings.HasSuffix(n, ".ip6.arpa.") || n == "ip6.arpa." +} + +func isIPv6Arpa(name string) bool { + n := lowerFQDN(name) + return strings.HasSuffix(n, ".ip6.arpa.") || n == "ip6.arpa." +} + +// reverseNameToIP returns nil for partial zone apexes (e.g. covering only part of an octet). +func reverseNameToIP(name string) net.IP { + n := strings.ToLower(strings.TrimSuffix(dns.Fqdn(name), ".")) + + switch { + case strings.HasSuffix(n, ".in-addr.arpa"): + labels := strings.Split(strings.TrimSuffix(n, ".in-addr.arpa"), ".") + if len(labels) != 4 { + return nil + } + out := make([]string, 4) + for i, l := range labels { + if _, err := strconv.Atoi(l); err != nil { + return nil + } + out[3-i] = l + } + return net.ParseIP(strings.Join(out, ".")) + + case strings.HasSuffix(n, ".ip6.arpa"): + labels := strings.Split(strings.TrimSuffix(n, ".ip6.arpa"), ".") + if len(labels) != 32 { + return nil + } + var sb strings.Builder + for i := len(labels) - 1; i >= 0; i-- { + if len(labels[i]) != 1 { + return nil + } + sb.WriteString(labels[i]) + if i > 0 && (len(labels)-i)%4 == 0 { + sb.WriteByte(':') + } + } + return net.ParseIP(sb.String()) + } + return nil +} + +func resolveForward(ctx context.Context, name string) ([]ForwardAddress, string) { + resolver := systemResolver() + var out []ForwardAddress + var lastErr string + anySuccess := false + + for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} { + if ctx.Err() != nil { + break + } + q := dns.Question{Name: dns.Fqdn(name), Qtype: qt, Qclass: dns.ClassINET} + r, err := dnsExchange(ctx, resolver, q) + if err != nil { + lastErr = err.Error() + continue + } + anySuccess = true + if r == nil { + continue + } + for _, rr := range r.Answer { + switch v := rr.(type) { + case *dns.A: + out = append(out, ForwardAddress{Type: "A", Address: v.A.String(), TTL: v.Hdr.Ttl}) + case *dns.AAAA: + out = append(out, ForwardAddress{Type: "AAAA", Address: v.AAAA.String(), TTL: v.Hdr.Ttl}) + } + } + } + if len(out) > 0 || anySuccess { + return out, "" + } + return out, lastErr +} + +func ipEqual(addr string, ip net.IP) bool { + parsed := net.ParseIP(addr) + if parsed == nil { + return false + } + return parsed.Equal(ip) +} diff --git a/checker/dns_test.go b/checker/dns_test.go new file mode 100644 index 0000000..1c2ea33 --- /dev/null +++ b/checker/dns_test.go @@ -0,0 +1,155 @@ +package checker + +import ( + "net" + "testing" +) + +func TestLowerFQDN(t *testing.T) { + cases := []struct { + in, want string + }{ + {"Example.COM", "example.com."}, + {"example.com.", "example.com."}, + {"", "."}, + {"X", "x."}, + {"1.168.192.IN-ADDR.ARPA.", "1.168.192.in-addr.arpa."}, + } + for _, c := range cases { + if got := lowerFQDN(c.in); got != c.want { + t.Errorf("lowerFQDN(%q)=%q, want %q", c.in, got, c.want) + } + } +} + +func TestIsReverseArpa(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"1.168.192.in-addr.arpa", true}, + {"in-addr.arpa", true}, + {"in-addr.arpa.", true}, + {"IN-ADDR.ARPA", true}, + {"ip6.arpa", true}, + {"0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", true}, + {"example.com", false}, + {"arpa", false}, + {"in-addr.arpa.example.com", false}, + {"", false}, + } + for _, c := range cases { + if got := isReverseArpa(c.in); got != c.want { + t.Errorf("isReverseArpa(%q)=%v, want %v", c.in, got, c.want) + } + } +} + +func TestIsIPv6Arpa(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"ip6.arpa", true}, + {"IP6.ARPA.", true}, + {"0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", true}, + {"1.168.192.in-addr.arpa", false}, + {"in-addr.arpa", false}, + {"example.com", false}, + } + for _, c := range cases { + if got := isIPv6Arpa(c.in); got != c.want { + t.Errorf("isIPv6Arpa(%q)=%v, want %v", c.in, got, c.want) + } + } +} + +func TestReverseNameToIP_IPv4(t *testing.T) { + cases := []struct { + in string + want string // "" means nil + }{ + {"42.1.168.192.in-addr.arpa", "192.168.1.42"}, + {"42.1.168.192.IN-ADDR.ARPA.", "192.168.1.42"}, + {"1.168.192.in-addr.arpa", ""}, // partial: only 3 labels + {"a.b.c.d.in-addr.arpa", ""}, // non-numeric + {"256.0.0.0.in-addr.arpa", ""}, // out of range parses with strconv but ParseIP fails + {"1.2.3.4.5.in-addr.arpa", ""}, // too many + {"in-addr.arpa", ""}, // apex, no labels + {"example.com", ""}, // unrelated + } + for _, c := range cases { + got := reverseNameToIP(c.in) + switch { + case c.want == "" && got != nil: + t.Errorf("reverseNameToIP(%q)=%v, want nil", c.in, got) + case c.want != "" && (got == nil || got.String() != c.want): + t.Errorf("reverseNameToIP(%q)=%v, want %s", c.in, got, c.want) + } + } +} + +func TestReverseNameToIP_IPv6(t *testing.T) { + // 2001:db8::1 expanded reverse: + // 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa. + full := "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa" + got := reverseNameToIP(full) + if got == nil { + t.Fatalf("reverseNameToIP(%q) returned nil", full) + } + want := net.ParseIP("2001:db8::1") + if !got.Equal(want) { + t.Errorf("reverseNameToIP(%q)=%v, want %v", full, got, want) + } + + // Partial / invalid IPv6 reverse names → nil. + partials := []string{ + "d.0.1.0.0.2.ip6.arpa", // /24 zone, only 6 nibbles + "ip6.arpa", // apex + "x.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", // multi-char label + "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.ip6.arpa", // 31 labels + } + for _, p := range partials { + if r := reverseNameToIP(p); r != nil { + t.Errorf("reverseNameToIP(%q)=%v, want nil", p, r) + } + } +} + +func TestIPEqual(t *testing.T) { + cases := []struct { + addr string + ip net.IP + want bool + }{ + {"192.168.1.1", net.ParseIP("192.168.1.1"), true}, + {"192.168.1.1", net.ParseIP("192.168.1.2"), false}, + {"2001:db8::1", net.ParseIP("2001:0db8:0000::1"), true}, + {"::ffff:192.168.1.1", net.ParseIP("192.168.1.1"), true}, + {"not-an-ip", net.ParseIP("192.168.1.1"), false}, + {"", net.ParseIP("192.168.1.1"), false}, + } + for _, c := range cases { + if got := ipEqual(c.addr, c.ip); got != c.want { + t.Errorf("ipEqual(%q,%v)=%v, want %v", c.addr, c.ip, got, c.want) + } + } +} + +func TestSystemResolverFallback(t *testing.T) { + // Force the fallback path: even if /etc/resolv.conf exists, the fallback + // kicks in when the file is missing or has zero servers. We can't easily + // remove /etc/resolv.conf in a test, so just assert the result is a valid + // host:port and that FallbackResolver itself is well-formed. + host, port, err := net.SplitHostPort(FallbackResolver) + if err != nil { + t.Fatalf("FallbackResolver = %q is not host:port: %v", FallbackResolver, err) + } + if host == "" || port == "" { + t.Errorf("FallbackResolver = %q has empty host or port", FallbackResolver) + } + got := systemResolver() + if _, _, err := net.SplitHostPort(got); err != nil { + t.Errorf("systemResolver() = %q is not host:port: %v", got, err) + } +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..ddc7dab --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,15 @@ +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func Provider() sdk.ObservationProvider { + return &reverseZoneProvider{} +} + +type reverseZoneProvider struct{} + +func (p *reverseZoneProvider) Key() sdk.ObservationKey { + return ObservationKey +} diff --git a/checker/provider_test.go b/checker/provider_test.go new file mode 100644 index 0000000..49c510f --- /dev/null +++ b/checker/provider_test.go @@ -0,0 +1,206 @@ +package checker + +import ( + "encoding/json" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func TestProvider(t *testing.T) { + p := Provider() + if p == nil { + t.Fatal("Provider() returned nil") + } + if p.Key() != ObservationKey { + t.Errorf("Key()=%q, want %q", p.Key(), ObservationKey) + } +} + +func TestDefinition(t *testing.T) { + p := &reverseZoneProvider{} + def := p.Definition() + if def == nil { + t.Fatal("Definition() returned nil") + } + if def.ID != "reverse-zone" { + t.Errorf("ID=%q, want reverse-zone", def.ID) + } + if def.Version == "" { + t.Error("Version is empty") + } + if !def.HasHTMLReport { + t.Error("HasHTMLReport should be true") + } + if !def.Availability.ApplyToDomain || !def.Availability.ApplyToZone { + t.Errorf("Availability=%+v, want both true", def.Availability) + } + if len(def.ObservationKeys) != 1 || def.ObservationKeys[0] != ObservationKey { + t.Errorf("ObservationKeys=%v", def.ObservationKeys) + } + if len(def.Rules) == 0 { + t.Error("Rules() should not be empty") + } + if def.Interval == nil || def.Interval.Default == 0 { + t.Errorf("Interval=%+v", def.Interval) + } + // Ensure each user option is documented with non-empty fields. + for _, opt := range def.Options.UserOpts { + if opt.Id == "" || opt.Type == "" || opt.Label == "" { + t.Errorf("incomplete user option: %+v", opt) + } + } + // Ensure domain options include both autofills. + gotKeys := map[string]bool{} + for _, opt := range def.Options.DomainOpts { + gotKeys[opt.Id] = true + } + for _, want := range []string{"domain_name", "zone"} { + if !gotKeys[want] { + t.Errorf("missing domain option %q", want) + } + } +} + +func TestVersionPropagation(t *testing.T) { + old := Version + defer func() { Version = old }() + Version = "v9.9.9-test" + p := &reverseZoneProvider{} + def := p.Definition() + if def.Version != "v9.9.9-test" { + t.Errorf("def.Version=%q, want v9.9.9-test", def.Version) + } +} + +// TestObservationRoundTrip ensures the published JSON shape stays stable for +// downstream consumers (the report renderer, related-observation consumers). +func TestObservationRoundTrip(t *testing.T) { + in := ReverseZoneData{ + Zone: "1.168.192.in-addr.arpa.", + IsReverseZone: true, + PTRCount: 1, + Entries: []PTREntry{{ + OwnerName: "42.1.168.192.in-addr.arpa.", + ReverseIP: "192.168.1.42", + Targets: []string{"a.example."}, + TargetSyntaxValid: true, + ForwardAddresses: []ForwardAddress{{Type: "A", Address: "192.168.1.42", TTL: 300}}, + ForwardMatch: true, + TargetResolves: true, + }}, + } + raw, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out ReverseZoneData + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(out.Entries) != 1 || out.Entries[0].ReverseIP != "192.168.1.42" { + t.Errorf("round-trip lost data: %+v", out) + } + + // Spot-check the JSON shape: snake_case field names that consumers rely on. + var raw2 map[string]any + if err := json.Unmarshal(raw, &raw2); err != nil { + t.Fatalf("unmarshal map: %v", err) + } + for _, key := range []string{"zone", "is_reverse_zone", "ptr_count", "entries"} { + if _, ok := raw2[key]; !ok { + t.Errorf("missing JSON key %q in %s", key, raw) + } + } +} + +// TestStaticReportContext_Empty exercises the report renderer with no data: +// it should not crash and should produce some output. +func TestReport_EmptyData(t *testing.T) { + p := &reverseZoneProvider{} + html, err := p.GetHTMLReport(sdk.StaticReportContext(nil)) + if err != nil { + t.Fatalf("GetHTMLReport: %v", err) + } + if html == "" { + t.Error("expected some HTML output even for empty data") + } +} + +func TestReport_LoadError(t *testing.T) { + p := &reverseZoneProvider{} + raw, _ := json.Marshal(ReverseZoneData{LoadError: "no zone autofill"}) + html, err := p.GetHTMLReport(sdk.StaticReportContext(raw)) + if err != nil { + t.Fatalf("GetHTMLReport: %v", err) + } + if !contains([]string{html}, html) || !containsString(html, "no zone autofill") { + t.Errorf("expected LoadError message in output:\n%s", html) + } + if !containsString(html, "Could not load zone data") { + t.Errorf("expected load-error banner in output:\n%s", html) + } +} + +func TestReport_InvalidJSON(t *testing.T) { + p := &reverseZoneProvider{} + _, err := p.GetHTMLReport(sdk.StaticReportContext([]byte("{not valid"))) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestStatusToSeverity(t *testing.T) { + cases := []struct { + s sdk.Status + want string + }{ + {sdk.StatusCrit, "crit"}, + {sdk.StatusError, "crit"}, + {sdk.StatusWarn, "warn"}, + {sdk.StatusInfo, "info"}, + {sdk.StatusOK, ""}, + {sdk.StatusUnknown, ""}, + } + for _, c := range cases { + if got := statusToSeverity(c.s); got != c.want { + t.Errorf("statusToSeverity(%v)=%q, want %q", c.s, got, c.want) + } + } +} + +func TestSeverityWeight(t *testing.T) { + if severityWeight("crit") <= severityWeight("warn") { + t.Error("crit should outweigh warn") + } + if severityWeight("warn") <= severityWeight("info") { + t.Error("warn should outweigh info") + } + if severityWeight("info") <= severityWeight("") { + t.Error("info should outweigh empty") + } +} + +func TestHintFromMeta(t *testing.T) { + if hintFromMeta(nil) != "" { + t.Error("nil meta should yield empty hint") + } + if got := hintFromMeta(map[string]any{"hint": "do this"}); got != "do this" { + t.Errorf("hint key: %q", got) + } + if got := hintFromMeta(map[string]any{"hint": 42}); got != "" { + t.Errorf("non-string hint should be ignored, got %q", got) + } + if got := hintFromMeta(map[string]any{"unrelated": "x"}); got != "" { + t.Errorf("missing hint key should yield empty, got %q", got) + } +} + +func containsString(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..cc99b2a --- /dev/null +++ b/checker/report.go @@ -0,0 +1,414 @@ +package checker + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "sort" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// GetHTMLReport foregrounds FCrDNS failures before other findings. +func (p *reverseZoneProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { + var data ReverseZoneData + if raw := ctx.Data(); len(raw) > 0 { + if err := json.Unmarshal(raw, &data); err != nil { + return "", fmt.Errorf("parse reverse-zone 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 fcrdnsFailure struct { + Owner string + IP string + Target string + ForwardAddrs []string + Reason string // "mismatch" or "unresolved" + SuggestedFix string +} + +type findingRow struct { + Severity string + Code string + Subject string + Message string + Hint string +} + +type reportView struct { + Zone string + IsReverse bool + IsIPv6 bool + PTRCount int + InspectedCount int + Truncated bool + LoadError string + + OverallStatus string + OverallStatusText string + OverallClass string + + // Stats + OK int + Mismatch int + Unresolved int + Multiple int + Generic int + LowTTL int + InvalidName int + + FCrDNSFailures []fcrdnsFailure + OtherFindings []findingRow + + // Sample of healthy entries (for context). + Sample []sampleRow +} + +type sampleRow struct { + Owner string + IP string + Target string + Forward string + Match bool + Resolved bool +} + +func statusToSeverity(s sdk.Status) string { + switch s { + case sdk.StatusCrit, sdk.StatusError: + return "crit" + case sdk.StatusWarn: + return "warn" + case sdk.StatusInfo: + return "info" + } + return "" +} + +func severityWeight(sev string) int { + switch sev { + case "crit": + return 3 + case "warn": + return 2 + case "info": + return 1 + } + return 0 +} + +func hintFromMeta(meta map[string]any) string { + if v, ok := meta["hint"].(string); ok { + return v + } + return "" +} + +func buildReportView(data *ReverseZoneData, states []sdk.CheckState) *reportView { + v := &reportView{ + Zone: data.Zone, + IsReverse: data.IsReverseZone, + IsIPv6: data.IsIPv6, + PTRCount: data.PTRCount, + InspectedCount: len(data.Entries), + Truncated: data.Truncated, + LoadError: data.LoadError, + } + + // Drive from observation data, not rule states, so FCrDNS failures always surface. + for _, e := range data.Entries { + if len(e.Targets) == 0 || e.ReverseIP == "" { + continue + } + target := e.Targets[0] + switch { + case !e.TargetResolves: + v.Unresolved++ + v.FCrDNSFailures = append(v.FCrDNSFailures, fcrdnsFailure{ + Owner: e.OwnerName, + IP: e.ReverseIP, + Target: target, + Reason: "unresolved", + SuggestedFix: fmt.Sprintf("Publish A/AAAA records for %s pointing at %s.", target, e.ReverseIP), + }) + case !e.ForwardMatch: + v.Mismatch++ + addrs := make([]string, len(e.ForwardAddresses)) + for i, a := range e.ForwardAddresses { + addrs[i] = a.Address + } + v.FCrDNSFailures = append(v.FCrDNSFailures, fcrdnsFailure{ + Owner: e.OwnerName, + IP: e.ReverseIP, + Target: target, + ForwardAddrs: addrs, + Reason: "mismatch", + SuggestedFix: fmt.Sprintf("Add %s to the A/AAAA records of %s, or repoint the PTR at a name whose forward records already include %s.", e.ReverseIP, target, e.ReverseIP), + }) + default: + v.OK++ + } + if len(e.Targets) > 1 { + v.Multiple++ + } + if e.TargetLooksGeneric { + v.Generic++ + } + if !e.TargetSyntaxValid { + v.InvalidName++ + } + } + sort.SliceStable(v.FCrDNSFailures, func(i, j int) bool { + // mismatch is more actionable than unresolved (forward zone exists, + // just needs an extra address); show those first. + if v.FCrDNSFailures[i].Reason != v.FCrDNSFailures[j].Reason { + return v.FCrDNSFailures[i].Reason == "mismatch" + } + return v.FCrDNSFailures[i].Owner < v.FCrDNSFailures[j].Owner + }) + + for _, e := range data.Entries { + if len(v.Sample) >= 10 { + break + } + if len(e.Targets) == 0 { + continue + } + fwd := "" + if len(e.ForwardAddresses) > 0 { + parts := make([]string, len(e.ForwardAddresses)) + for i, a := range e.ForwardAddresses { + parts[i] = a.Address + } + fwd = strings.Join(parts, ", ") + } + v.Sample = append(v.Sample, sampleRow{ + Owner: e.OwnerName, + IP: e.ReverseIP, + Target: e.Targets[0], + Forward: fwd, + Match: e.ForwardMatch, + Resolved: e.TargetResolves, + }) + } + + worst := "" + skipCodes := map[string]bool{ + "ptr_forward_mismatch": true, + "ptr_target_unresolvable": true, + } + for _, st := range states { + sev := statusToSeverity(st.Status) + if sev == "" { + continue + } + if severityWeight(sev) > severityWeight(worst) { + worst = sev + } + if skipCodes[st.Code] { + continue + } + v.OtherFindings = append(v.OtherFindings, findingRow{ + Severity: sev, + Code: st.Code, + Subject: st.Subject, + Message: st.Message, + Hint: hintFromMeta(st.Meta), + }) + if st.Code == "ptr_low_ttl" { + v.LowTTL++ + } + } + + if len(v.FCrDNSFailures) > 0 && severityWeight(worst) < severityWeight("crit") { + worst = "crit" + } + + switch worst { + case "crit": + v.OverallStatus = "crit" + v.OverallStatusText = fmt.Sprintf("FCrDNS failures detected (%d)", len(v.FCrDNSFailures)) + v.OverallClass = "status-crit" + case "warn": + v.OverallStatus = "warn" + v.OverallStatusText = "Warnings detected" + v.OverallClass = "status-warn" + case "info": + v.OverallStatus = "info" + v.OverallStatusText = "Informational notes" + v.OverallClass = "status-info" + default: + v.OverallStatus = "ok" + if data.LoadError != "" { + v.OverallStatusText = "Could not load zone data" + v.OverallClass = "status-warn" + } else if data.PTRCount == 0 { + v.OverallStatusText = "Reverse zone is empty" + v.OverallClass = "status-info" + } else { + v.OverallStatusText = fmt.Sprintf("All %d PTR records pass FCrDNS", v.OK) + v.OverallClass = "status-ok" + } + } + + return v +} + +var reportTmpl = template.Must(template.New("reverse-zone-report").Funcs(template.FuncMap{ + "sub": func(a, b int) int { return a - b }, +}).Parse(reportTemplate)) + +const reportTemplate = ` + + + +Reverse zone report: {{.Zone}} + + + +
+
+
{{.OverallStatusText}}
+
for {{.Zone}}
+
+
+ {{.InspectedCount}} of {{.PTRCount}} PTR{{if gt .PTRCount 1}}s{{end}} inspected{{if .Truncated}} (truncated){{end}} +
+
+ + {{if .LoadError}} +
+

Zone data could not be loaded

+

{{.LoadError}}

+
+ {{end}} + +
+
FCrDNS OK
{{.OK}}
+
FCrDNS mismatch
{{.Mismatch}}
+
Target unresolved
{{.Unresolved}}
+
Multiple PTR
{{.Multiple}}
+
Generic-looking
{{.Generic}}
+
Invalid syntax
{{.InvalidName}}
+
+ + {{if .FCrDNSFailures}} +

Fix these first: Forward / reverse round-trip ({{len .FCrDNSFailures}})

+

Mail servers (and SSH/anti-spam stacks) reject SMTP connections when the PTR target does not resolve back to the connecting IP. Address these failures first.

+ {{range .FCrDNSFailures}} +
+

{{.Owner}} + {{if eq .Reason "mismatch"}}FCrDNS mismatch + {{else}}target unresolved{{end}} +

+
+ {{.IP}} -PTR-> {{.Target}} -A/AAAA-> + {{if .ForwardAddrs}}{{range $i, $a := .ForwardAddrs}}{{if $i}}, {{end}}{{$a}}{{end}} + {{else}}unresolved{{end}} +
+
How to fix{{.SuggestedFix}}
+
+ {{end}} + {{end}} + + {{if .Sample}} +

PTR records inspected (first {{len .Sample}})

+ + + + {{range .Sample}} + + + + + + + + {{end}} + +
PTR ownerIPTargetForward A/AAAAFCrDNS
{{.Owner}}{{.IP}}{{.Target}}{{if .Forward}}{{.Forward}}{{else}}-{{end}} + {{if .Match}}match + {{else if .Resolved}}mismatch + {{else}}unresolved{{end}} +
+ {{if gt .InspectedCount (len .Sample)}}
… and {{sub .InspectedCount (len .Sample)}} more
{{end}} + {{end}} + + {{if .OtherFindings}} +

Other findings

+ + + + {{range .OtherFindings}} + + + + + + + {{end}} + +
SeverityCodeSubjectMessage
{{.Severity}}{{.Code}}{{.Subject}}{{.Message}}{{if .Hint}}
{{.Hint}}{{end}}
+ {{end}} + + +` diff --git a/checker/report_test.go b/checker/report_test.go new file mode 100644 index 0000000..b7e8871 --- /dev/null +++ b/checker/report_test.go @@ -0,0 +1,81 @@ +package checker + +import ( + "encoding/json" + "strings" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func TestReportRenders(t *testing.T) { + data := ReverseZoneData{ + Zone: "1.168.192.in-addr.arpa.", + IsReverseZone: true, + PTRCount: 3, + Entries: []PTREntry{ + { + OwnerName: "10.1.168.192.in-addr.arpa.", + ReverseIP: "192.168.1.10", + Targets: []string{"mail.example.com."}, + TargetSyntaxValid: true, + ForwardAddresses: []ForwardAddress{{Type: "A", Address: "192.168.1.10"}}, + ForwardMatch: true, + TargetResolves: true, + }, + { + OwnerName: "11.1.168.192.in-addr.arpa.", + ReverseIP: "192.168.1.11", + Targets: []string{"web.example.com."}, + TargetSyntaxValid: true, + ForwardAddresses: []ForwardAddress{{Type: "A", Address: "203.0.113.5"}}, + ForwardMatch: false, + TargetResolves: true, + }, + { + OwnerName: "12.1.168.192.in-addr.arpa.", + ReverseIP: "192.168.1.12", + Targets: []string{"ghost.example.com."}, + TargetSyntaxValid: true, + TargetResolves: false, + }, + }, + } + raw, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal: %v", err) + } + p := &reverseZoneProvider{} + html, err := p.GetHTMLReport(sdk.StaticReportContext(raw)) + if err != nil { + t.Fatalf("report: %v", err) + } + for _, want := range []string{ + "FCrDNS failures detected", + "web.example.com.", + "ghost.example.com.", + "target unresolved", + "FCrDNS mismatch", + } { + if !strings.Contains(html, want) { + t.Errorf("expected report to contain %q\n--- HTML ---\n%s", want, html) + } + } +} + +func TestBuildOwnerName(t *testing.T) { + cases := []struct { + sub, zone, want string + }{ + {"42", "1.168.192.in-addr.arpa", "42.1.168.192.in-addr.arpa."}, + {"", "1.168.192.in-addr.arpa", "1.168.192.in-addr.arpa."}, + {"@", "1.168.192.in-addr.arpa", "1.168.192.in-addr.arpa."}, + {"1.0", "0.0.10.in-addr.arpa", "1.0.0.0.10.in-addr.arpa."}, + } + for _, c := range cases { + got := buildOwnerName(c.sub, c.zone) + if got != c.want { + t.Errorf("buildOwnerName(%q,%q)=%q, want %q", c.sub, c.zone, got, c.want) + } + } +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..724ca48 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,61 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func Rules() []sdk.CheckRule { + return []sdk.CheckRule{ + &isReverseZoneRule{}, + &hasPTRsRule{}, + &fcrdnsRule{}, + &targetResolvesRule{}, + &singlePTRRule{}, + &targetSyntaxRule{}, + &genericHostnameRule{}, + &ttlHygieneRule{}, + &truncationRule{}, + } +} + +func loadData(ctx context.Context, obs sdk.ObservationGetter) (*ReverseZoneData, *sdk.CheckState) { + var data ReverseZoneData + if err := obs.Get(ctx, ObservationKey, &data); err != nil { + return nil, &sdk.CheckState{ + Status: sdk.StatusError, + Code: "reverse_zone.observation_error", + Message: fmt.Sprintf("failed to get reverse-zone data: %v", err), + } + } + return &data, nil +} + +func passState(code, message, subject string) sdk.CheckState { + return sdk.CheckState{Status: sdk.StatusOK, Code: code, Message: message, Subject: subject} +} + +func skipState(code, message string) sdk.CheckState { + return sdk.CheckState{Status: sdk.StatusUnknown, Code: code, Message: message} +} + +func critState(code, message, subject, hint string) sdk.CheckState { + return withHint(sdk.CheckState{Status: sdk.StatusCrit, Code: code, Message: message, Subject: subject}, hint) +} + +func warnState(code, message, subject, hint string) sdk.CheckState { + return withHint(sdk.CheckState{Status: sdk.StatusWarn, Code: code, Message: message, Subject: subject}, hint) +} + +func infoState(code, message, subject string) sdk.CheckState { + return sdk.CheckState{Status: sdk.StatusInfo, Code: code, Message: message, Subject: subject} +} + +func withHint(st sdk.CheckState, hint string) sdk.CheckState { + if hint != "" { + st.Meta = map[string]any{"hint": hint} + } + return st +} diff --git a/checker/rules.go b/checker/rules.go new file mode 100644 index 0000000..56f6ea8 --- /dev/null +++ b/checker/rules.go @@ -0,0 +1,336 @@ +package checker + +import ( + "context" + "fmt" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// ---------- structural ---------- + +type isReverseZoneRule struct{} + +func (isReverseZoneRule) Name() string { return "reverse_zone.is_reverse_arpa" } +func (isReverseZoneRule) Description() string { + return "Verifies the zone is under in-addr.arpa or ip6.arpa." +} +func (isReverseZoneRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if data.LoadError != "" { + return []sdk.CheckState{{ + Status: sdk.StatusError, + Code: "reverse_zone.load_error", + Message: data.LoadError, + }} + } + if !data.IsReverseZone { + return []sdk.CheckState{critState( + "reverse_zone_not_arpa", + fmt.Sprintf("zone %s is not under in-addr.arpa or ip6.arpa", data.Zone), + data.Zone, + "This checker is meant for reverse zones. Attach it to a domain whose name ends in in-addr.arpa or ip6.arpa.", + )} + } + return []sdk.CheckState{passState("reverse_zone.is_reverse_arpa.ok", fmt.Sprintf("Zone %s is a reverse zone.", data.Zone), data.Zone)} +} + +type hasPTRsRule struct{} + +func (hasPTRsRule) Name() string { return "reverse_zone.has_ptrs" } +func (hasPTRsRule) Description() string { + return "Verifies the reverse zone declares at least one PTR record." +} +func (hasPTRsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if !data.IsReverseZone { + return []sdk.CheckState{skipState("reverse_zone.has_ptrs.skipped", "Zone is not a reverse zone.")} + } + if data.PTRCount == 0 { + return []sdk.CheckState{warnState( + "reverse_zone_empty", + fmt.Sprintf("no PTR records declared in %s", data.Zone), + data.Zone, + "A reverse zone exists to publish PTR records. Add at least one PTR for an IP that lives in this delegation.", + )} + } + return []sdk.CheckState{passState("reverse_zone.has_ptrs.ok", fmt.Sprintf("%d PTR records declared.", data.PTRCount), data.Zone)} +} + +// ---------- FCrDNS (the dominant failure mode) ---------- + +type fcrdnsRule struct{} + +func (fcrdnsRule) Name() string { return "reverse_zone.fcrdns" } +func (fcrdnsRule) Description() string { + return "Verifies every PTR target's A/AAAA round-trips back to the original IP (Forward-Confirmed Reverse DNS)." +} +func (fcrdnsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Entries) == 0 { + return []sdk.CheckState{skipState("reverse_zone.fcrdns.skipped", "No PTR records to evaluate.")} + } + requireMatch := sdk.GetBoolOption(opts, "requireForwardMatch", true) + + var states []sdk.CheckState + for _, e := range data.Entries { + if len(e.Targets) == 0 || e.ReverseIP == "" { + continue + } + if !e.TargetResolves { + // targetResolvesRule reports this; skip here to avoid duplicate + // findings. + continue + } + if e.ForwardMatch { + states = append(states, passState( + "reverse_zone.fcrdns.ok", + fmt.Sprintf("%s → %s → %s (FCrDNS confirmed)", e.ReverseIP, e.Targets[0], e.ReverseIP), + e.OwnerName, + )) + continue + } + addrStrs := make([]string, len(e.ForwardAddresses)) + for i, a := range e.ForwardAddresses { + addrStrs[i] = a.Address + } + st := critState( + "ptr_forward_mismatch", + fmt.Sprintf("PTR %s → %s, but %s resolves to %s (does not include %s)", + e.OwnerName, e.Targets[0], e.Targets[0], strings.Join(addrStrs, ", "), e.ReverseIP), + e.OwnerName, + fmt.Sprintf("Add %s to the A/AAAA records of %s in the forward zone, or change the PTR to a hostname that already resolves to %s. Mail servers reject SMTP connections when reverse DNS does not round-trip.", + e.ReverseIP, e.Targets[0], e.ReverseIP), + ) + if !requireMatch { + st.Status = sdk.StatusWarn + } + states = append(states, st) + } + if len(states) == 0 { + return []sdk.CheckState{skipState("reverse_zone.fcrdns.skipped", "No PTR target had a forward resolution to compare against.")} + } + return states +} + +// ---------- target resolves ---------- + +type targetResolvesRule struct{} + +func (targetResolvesRule) Name() string { return "reverse_zone.target_resolves" } +func (targetResolvesRule) Description() string { + return "Verifies every PTR target resolves to at least one A or AAAA record." +} +func (targetResolvesRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Entries) == 0 { + return []sdk.CheckState{skipState("reverse_zone.target_resolves.skipped", "No PTR records to evaluate.")} + } + requireMatch := sdk.GetBoolOption(opts, "requireForwardMatch", true) + + var states []sdk.CheckState + for _, e := range data.Entries { + if len(e.Targets) == 0 { + continue + } + if e.TargetResolves { + continue + } + msg := fmt.Sprintf("PTR target %s does not resolve to any A/AAAA record", e.Targets[0]) + if e.ForwardError != "" { + msg = fmt.Sprintf("%s (%s)", msg, e.ForwardError) + } + st := critState( + "ptr_target_unresolvable", + msg, + e.OwnerName, + fmt.Sprintf("Publish A and/or AAAA records for %s in the forward zone, pointing at %s. Without forward records the PTR is unusable for FCrDNS-aware consumers.", e.Targets[0], e.ReverseIP), + ) + if !requireMatch { + st.Status = sdk.StatusWarn + } + states = append(states, st) + } + if len(states) == 0 { + return []sdk.CheckState{passState("reverse_zone.target_resolves.ok", "All PTR targets resolve in the forward DNS.", data.Zone)} + } + return states +} + +// ---------- single PTR per IP ---------- + +type singlePTRRule struct{} + +func (singlePTRRule) Name() string { return "reverse_zone.single_ptr_per_ip" } +func (singlePTRRule) Description() string { + return "Flags IPs with multiple PTR records (RFC 1912 §2.1 recommends exactly one)." +} +func (singlePTRRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if sdk.GetBoolOption(opts, "allowMultiplePTR", false) { + return []sdk.CheckState{skipState("reverse_zone.single_ptr_per_ip.skipped", "Multiple PTRs are explicitly allowed by configuration.")} + } + if len(data.Entries) == 0 { + return []sdk.CheckState{skipState("reverse_zone.single_ptr_per_ip.skipped", "No PTR records to evaluate.")} + } + var states []sdk.CheckState + for _, e := range data.Entries { + if len(e.Targets) > 1 { + states = append(states, warnState( + "ptr_multiple", + fmt.Sprintf("%d PTR records at %s (%s)", len(e.Targets), e.OwnerName, strings.Join(e.Targets, ", ")), + e.OwnerName, + "Keep exactly one canonical hostname per IP. Multiple PTRs confuse mail filters, log analyzers and any consumer that takes the first answer.", + )) + } + } + if len(states) == 0 { + return []sdk.CheckState{passState("reverse_zone.single_ptr_per_ip.ok", "Each IP has at most one PTR.", data.Zone)} + } + return states +} + +// ---------- target syntax ---------- + +type targetSyntaxRule struct{} + +func (targetSyntaxRule) Name() string { return "reverse_zone.target_syntax" } +func (targetSyntaxRule) Description() string { + return "Verifies every PTR target is a syntactically valid hostname." +} +func (targetSyntaxRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Entries) == 0 { + return []sdk.CheckState{skipState("reverse_zone.target_syntax.skipped", "No PTR records to evaluate.")} + } + var states []sdk.CheckState + for _, e := range data.Entries { + if len(e.Targets) == 0 { + continue + } + if !e.TargetSyntaxValid { + states = append(states, critState( + "ptr_target_invalid", + fmt.Sprintf("PTR target %q at %s is not a valid hostname", e.Targets[0], e.OwnerName), + e.OwnerName, + "PTR targets must be syntactically valid domain names (RFC 952/1123 letters, digits, hyphens; labels 1-63 chars).", + )) + } + } + if len(states) == 0 { + return []sdk.CheckState{passState("reverse_zone.target_syntax.ok", "All PTR targets are syntactically valid hostnames.", data.Zone)} + } + return states +} + +// ---------- generic hostname ---------- + +type genericHostnameRule struct{} + +func (genericHostnameRule) Name() string { return "reverse_zone.generic_hostname" } +func (genericHostnameRule) Description() string { + return "Flags PTR targets that embed the IP or match common ISP auto-generated patterns." +} +func (genericHostnameRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if !sdk.GetBoolOption(opts, "flagGenericPTR", true) { + return []sdk.CheckState{skipState("reverse_zone.generic_hostname.skipped", "Generic-hostname check disabled by configuration.")} + } + if len(data.Entries) == 0 { + return []sdk.CheckState{skipState("reverse_zone.generic_hostname.skipped", "No PTR records to evaluate.")} + } + var states []sdk.CheckState + for _, e := range data.Entries { + if e.TargetLooksGeneric { + states = append(states, warnState( + "ptr_generic_hostname", + fmt.Sprintf("PTR target %s at %s looks auto-generated", e.Targets[0], e.OwnerName), + e.OwnerName, + "Mail servers and anti-spam filters penalise generic PTRs. Prefer a stable, service-specific hostname instead of one that embeds the IP.", + )) + } + } + if len(states) == 0 { + return []sdk.CheckState{passState("reverse_zone.generic_hostname.ok", "No PTR target looks auto-generated.", data.Zone)} + } + return states +} + +// ---------- TTL hygiene ---------- + +type ttlHygieneRule struct{} + +func (ttlHygieneRule) Name() string { return "reverse_zone.ttl_hygiene" } +func (ttlHygieneRule) Description() string { + return "Flags PTR records whose TTL is below the configured minimum." +} +func (ttlHygieneRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Entries) == 0 { + return []sdk.CheckState{skipState("reverse_zone.ttl_hygiene.skipped", "No PTR records to evaluate.")} + } + minTTL := uint32(sdk.GetIntOption(opts, "minTTL", 300)) + var states []sdk.CheckState + for _, e := range data.Entries { + if e.TTL > 0 && e.TTL < minTTL { + states = append(states, warnState( + "ptr_low_ttl", + fmt.Sprintf("PTR %s has TTL %ds (< %d)", e.OwnerName, e.TTL, minTTL), + e.OwnerName, + "Raise the PTR TTL. Reverse lookups are cache-heavy on the consumer side (mail, SSH); short TTLs rarely help.", + )) + } + } + if len(states) == 0 { + return []sdk.CheckState{passState("reverse_zone.ttl_hygiene.ok", "All PTR TTLs meet the minimum.", data.Zone)} + } + return states +} + +// ---------- truncation ---------- + +type truncationRule struct{} + +func (truncationRule) Name() string { return "reverse_zone.truncated" } +func (truncationRule) Description() string { + return "Reports when the zone has more PTRs than the configured cap allows to inspect." +} +func (truncationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if !data.Truncated { + return []sdk.CheckState{skipState("reverse_zone.truncated.skipped", "Inspection covered all PTR records.")} + } + return []sdk.CheckState{infoState( + "reverse_zone_truncated", + fmt.Sprintf("only the first %d of %d PTR records were inspected", len(data.Entries), data.PTRCount), + data.Zone, + )} +} diff --git a/checker/rules_test.go b/checker/rules_test.go new file mode 100644 index 0000000..a3169fd --- /dev/null +++ b/checker/rules_test.go @@ -0,0 +1,366 @@ +package checker + +import ( + "context" + "encoding/json" + "errors" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// mockObs is a lightweight ObservationGetter for rule unit tests. +type mockObs struct { + data *ReverseZoneData + err error +} + +func (m *mockObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error { + if m.err != nil { + return m.err + } + if key != ObservationKey || m.data == nil { + return errors.New("not found") + } + b, err := json.Marshal(m.data) + if err != nil { + return err + } + return json.Unmarshal(b, dest) +} + +func (m *mockObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) { + return nil, nil +} + +func evalRule(t *testing.T, r sdk.CheckRule, data *ReverseZoneData, opts sdk.CheckerOptions) []sdk.CheckState { + t.Helper() + return r.Evaluate(context.Background(), &mockObs{data: data}, opts) +} + +func findCode(states []sdk.CheckState, code string) *sdk.CheckState { + for i := range states { + if states[i].Code == code { + return &states[i] + } + } + return nil +} + +// ---------- loadData ---------- + +func TestLoadData_Error(t *testing.T) { + obs := &mockObs{err: errors.New("boom")} + data, st := loadData(context.Background(), obs) + if data != nil { + t.Errorf("expected nil data, got %+v", data) + } + if st == nil || st.Status != sdk.StatusError { + t.Errorf("expected error CheckState, got %+v", st) + } + if st != nil && st.Code != "reverse_zone.observation_error" { + t.Errorf("Code=%q, want reverse_zone.observation_error", st.Code) + } +} + +// ---------- isReverseZoneRule ---------- + +func TestIsReverseZoneRule(t *testing.T) { + r := &isReverseZoneRule{} + + // LoadError surfaces. + st := evalRule(t, r, &ReverseZoneData{LoadError: "broken"}, nil) + if len(st) != 1 || st[0].Code != "reverse_zone.load_error" { + t.Errorf("LoadError path: %+v", st) + } + + // Not under arpa → critical. + st = evalRule(t, r, &ReverseZoneData{Zone: "example.com.", IsReverseZone: false}, nil) + if len(st) != 1 || st[0].Code != "reverse_zone_not_arpa" || st[0].Status != sdk.StatusCrit { + t.Errorf("not-arpa path: %+v", st) + } + + // Reverse zone → ok. + st = evalRule(t, r, &ReverseZoneData{Zone: "1.168.192.in-addr.arpa.", IsReverseZone: true}, nil) + if len(st) != 1 || st[0].Status != sdk.StatusOK { + t.Errorf("ok path: %+v", st) + } +} + +// ---------- hasPTRsRule ---------- + +func TestHasPTRsRule(t *testing.T) { + r := &hasPTRsRule{} + + // Not a reverse zone → skip. + st := evalRule(t, r, &ReverseZoneData{IsReverseZone: false}, nil) + if len(st) != 1 || st[0].Status != sdk.StatusUnknown { + t.Errorf("non-reverse skip: %+v", st) + } + + // Reverse zone, no PTRs → warn. + st = evalRule(t, r, &ReverseZoneData{IsReverseZone: true, Zone: "z.", PTRCount: 0}, nil) + if len(st) != 1 || st[0].Code != "reverse_zone_empty" || st[0].Status != sdk.StatusWarn { + t.Errorf("empty zone: %+v", st) + } + + // Reverse zone with PTRs → ok. + st = evalRule(t, r, &ReverseZoneData{IsReverseZone: true, Zone: "z.", PTRCount: 3}, nil) + if len(st) != 1 || st[0].Status != sdk.StatusOK { + t.Errorf("ok: %+v", st) + } +} + +// ---------- fcrdnsRule ---------- + +func TestFcrdnsRule(t *testing.T) { + r := &fcrdnsRule{} + + // No entries → skip. + st := evalRule(t, r, &ReverseZoneData{}, nil) + if len(st) != 1 || st[0].Code != "reverse_zone.fcrdns.skipped" { + t.Errorf("no entries: %+v", st) + } + + // Mix: one OK, one mismatch, one unresolved (skipped here), one with no + // targets (skipped). + data := &ReverseZoneData{ + Entries: []PTREntry{ + {OwnerName: "a", ReverseIP: "192.0.2.1", Targets: []string{"a.example."}, TargetResolves: true, ForwardMatch: true}, + {OwnerName: "b", ReverseIP: "192.0.2.2", Targets: []string{"b.example."}, TargetResolves: true, ForwardMatch: false, + ForwardAddresses: []ForwardAddress{{Address: "203.0.113.1"}}}, + {OwnerName: "c", ReverseIP: "192.0.2.3", Targets: []string{"c.example."}, TargetResolves: false}, + {OwnerName: "d", ReverseIP: "192.0.2.4", Targets: nil}, + }, + } + st = evalRule(t, r, data, nil) + if len(st) != 2 { + t.Fatalf("expected 2 states (OK + mismatch), got %d: %+v", len(st), st) + } + if findCode(st, "reverse_zone.fcrdns.ok") == nil { + t.Errorf("missing ok state: %+v", st) + } + mis := findCode(st, "ptr_forward_mismatch") + if mis == nil || mis.Status != sdk.StatusCrit { + t.Errorf("expected critical mismatch state: %+v", st) + } + + // requireForwardMatch=false downgrades mismatch to warning. + st = evalRule(t, r, data, sdk.CheckerOptions{"requireForwardMatch": false}) + mis = findCode(st, "ptr_forward_mismatch") + if mis == nil || mis.Status != sdk.StatusWarn { + t.Errorf("expected warn mismatch when requireForwardMatch=false: %+v", st) + } + + // All entries unresolved or no targets → skipped (nothing to compare). + st = evalRule(t, r, &ReverseZoneData{ + Entries: []PTREntry{ + {OwnerName: "x", ReverseIP: "192.0.2.9", Targets: []string{"x.example."}, TargetResolves: false}, + }, + }, nil) + if len(st) != 1 || st[0].Code != "reverse_zone.fcrdns.skipped" { + t.Errorf("all-unresolved skip: %+v", st) + } +} + +// ---------- targetResolvesRule ---------- + +func TestTargetResolvesRule(t *testing.T) { + r := &targetResolvesRule{} + + st := evalRule(t, r, &ReverseZoneData{}, nil) + if len(st) != 1 || st[0].Code != "reverse_zone.target_resolves.skipped" { + t.Errorf("no entries: %+v", st) + } + + data := &ReverseZoneData{ + Entries: []PTREntry{ + {OwnerName: "ok", Targets: []string{"ok.example."}, TargetResolves: true}, + {OwnerName: "bad", Targets: []string{"bad.example."}, TargetResolves: false, ForwardError: "NXDOMAIN", ReverseIP: "192.0.2.1"}, + }, + } + st = evalRule(t, r, data, nil) + if len(st) != 1 || st[0].Code != "ptr_target_unresolvable" || st[0].Status != sdk.StatusCrit { + t.Errorf("expected one critical: %+v", st) + } + + // requireForwardMatch=false → warning. + st = evalRule(t, r, data, sdk.CheckerOptions{"requireForwardMatch": false}) + if len(st) != 1 || st[0].Status != sdk.StatusWarn { + t.Errorf("expected warn when requireForwardMatch=false: %+v", st) + } + + // All resolve → ok. + st = evalRule(t, r, &ReverseZoneData{ + Entries: []PTREntry{{OwnerName: "ok", Targets: []string{"ok.example."}, TargetResolves: true}}, + }, nil) + if len(st) != 1 || st[0].Status != sdk.StatusOK { + t.Errorf("expected ok: %+v", st) + } +} + +// ---------- singlePTRRule ---------- + +func TestSinglePTRRule(t *testing.T) { + r := &singlePTRRule{} + + // Explicit allow → skip. + st := evalRule(t, r, &ReverseZoneData{}, sdk.CheckerOptions{"allowMultiplePTR": true}) + if len(st) != 1 || st[0].Status != sdk.StatusUnknown { + t.Errorf("allowMultiplePTR skip: %+v", st) + } + + // No entries → skip. + st = evalRule(t, r, &ReverseZoneData{}, nil) + if len(st) != 1 || st[0].Code != "reverse_zone.single_ptr_per_ip.skipped" { + t.Errorf("no entries: %+v", st) + } + + // One IP with two PTRs → warn. + data := &ReverseZoneData{Entries: []PTREntry{ + {OwnerName: "a", Targets: []string{"a.example.", "b.example."}}, + {OwnerName: "b", Targets: []string{"c.example."}}, + }} + st = evalRule(t, r, data, nil) + if len(st) != 1 || st[0].Code != "ptr_multiple" || st[0].Status != sdk.StatusWarn { + t.Errorf("expected one warn: %+v", st) + } +} + +// ---------- targetSyntaxRule ---------- + +func TestTargetSyntaxRule(t *testing.T) { + r := &targetSyntaxRule{} + + st := evalRule(t, r, &ReverseZoneData{}, nil) + if len(st) != 1 || st[0].Code != "reverse_zone.target_syntax.skipped" { + t.Errorf("no entries: %+v", st) + } + + data := &ReverseZoneData{Entries: []PTREntry{ + {OwnerName: "a", Targets: []string{"!!!bad"}, TargetSyntaxValid: false}, + {OwnerName: "b", Targets: []string{"good.example."}, TargetSyntaxValid: true}, + }} + st = evalRule(t, r, data, nil) + if len(st) != 1 || st[0].Code != "ptr_target_invalid" { + t.Errorf("expected one invalid: %+v", st) + } + + // All valid → ok. + st = evalRule(t, r, &ReverseZoneData{Entries: []PTREntry{ + {OwnerName: "b", Targets: []string{"good.example."}, TargetSyntaxValid: true}, + }}, nil) + if len(st) != 1 || st[0].Status != sdk.StatusOK { + t.Errorf("expected ok: %+v", st) + } +} + +// ---------- genericHostnameRule ---------- + +func TestGenericHostnameRule(t *testing.T) { + r := &genericHostnameRule{} + + // Disabled by config → skip. + st := evalRule(t, r, &ReverseZoneData{}, sdk.CheckerOptions{"flagGenericPTR": false}) + if len(st) != 1 || st[0].Status != sdk.StatusUnknown { + t.Errorf("disabled skip: %+v", st) + } + + st = evalRule(t, r, &ReverseZoneData{}, nil) + if len(st) != 1 || st[0].Code != "reverse_zone.generic_hostname.skipped" { + t.Errorf("no entries: %+v", st) + } + + data := &ReverseZoneData{Entries: []PTREntry{ + {OwnerName: "a", Targets: []string{"dhcp-1-2-3-4.example."}, TargetLooksGeneric: true}, + {OwnerName: "b", Targets: []string{"mail.example."}, TargetLooksGeneric: false}, + }} + st = evalRule(t, r, data, nil) + if len(st) != 1 || st[0].Code != "ptr_generic_hostname" || st[0].Status != sdk.StatusWarn { + t.Errorf("expected one warn: %+v", st) + } +} + +// ---------- ttlHygieneRule ---------- + +func TestTTLHygieneRule(t *testing.T) { + r := &ttlHygieneRule{} + + st := evalRule(t, r, &ReverseZoneData{}, nil) + if len(st) != 1 || st[0].Code != "reverse_zone.ttl_hygiene.skipped" { + t.Errorf("no entries: %+v", st) + } + + data := &ReverseZoneData{Entries: []PTREntry{ + {OwnerName: "a", TTL: 60}, // below default minTTL=300 → warn + {OwnerName: "b", TTL: 3600}, // ok + {OwnerName: "c", TTL: 0}, // unknown TTL → ignored + }} + st = evalRule(t, r, data, nil) + if len(st) != 1 || st[0].Code != "ptr_low_ttl" { + t.Errorf("expected one low-ttl warn: %+v", st) + } + + // Custom higher minTTL flags the previously-OK entry too. + st = evalRule(t, r, data, sdk.CheckerOptions{"minTTL": float64(7200)}) + if len(st) != 2 { + t.Errorf("expected 2 warns at minTTL=7200, got %d: %+v", len(st), st) + } +} + +// ---------- truncationRule ---------- + +func TestTruncationRule(t *testing.T) { + r := &truncationRule{} + + st := evalRule(t, r, &ReverseZoneData{Truncated: false}, nil) + if len(st) != 1 || st[0].Code != "reverse_zone.truncated.skipped" { + t.Errorf("not truncated skip: %+v", st) + } + + st = evalRule(t, r, &ReverseZoneData{Truncated: true, PTRCount: 100, Entries: make([]PTREntry, 10)}, nil) + if len(st) != 1 || st[0].Code != "reverse_zone_truncated" || st[0].Status != sdk.StatusInfo { + t.Errorf("truncated info: %+v", st) + } +} + +// ---------- helpers ---------- + +func TestStateHelpers(t *testing.T) { + if s := passState("c", "m", "sub"); s.Status != sdk.StatusOK || s.Code != "c" || s.Subject != "sub" || s.Message != "m" { + t.Errorf("passState: %+v", s) + } + if s := skipState("c", "m"); s.Status != sdk.StatusUnknown { + t.Errorf("skipState: %+v", s) + } + if s := critState("c", "m", "sub", "fix"); s.Status != sdk.StatusCrit || s.Meta["hint"] != "fix" { + t.Errorf("critState: %+v", s) + } + if s := warnState("c", "m", "sub", ""); s.Status != sdk.StatusWarn || s.Meta != nil { + t.Errorf("warnState (no hint should leave Meta nil): %+v", s) + } + if s := infoState("c", "m", "sub"); s.Status != sdk.StatusInfo { + t.Errorf("infoState: %+v", s) + } +} + +func TestRulesList(t *testing.T) { + rs := Rules() + if len(rs) == 0 { + t.Fatal("Rules() returned empty slice") + } + seen := map[string]bool{} + for _, r := range rs { + name := r.Name() + if name == "" { + t.Errorf("rule with empty Name(): %T", r) + } + if r.Description() == "" { + t.Errorf("rule %s has empty Description()", name) + } + if seen[name] { + t.Errorf("duplicate rule name: %s", name) + } + seen[name] = true + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..b3428fc --- /dev/null +++ b/checker/types.go @@ -0,0 +1,60 @@ +package checker + +import ( + "encoding/json" + + "github.com/miekg/dns" +) + +const ObservationKey = "reverse-zone" + +type ForwardAddress struct { + Type string `json:"type"` // "A" or "AAAA" + Address string `json:"address"` + TTL uint32 `json:"ttl,omitempty"` +} + +type PTREntry struct { + OwnerName string `json:"owner_name"` // FQDN of the PTR record (e.g. "42.1.168.192.in-addr.arpa.") + ReverseIP string `json:"reverse_ip,omitempty"` + Subdomain string `json:"subdomain,omitempty"` + + Targets []string `json:"targets"` + TTL uint32 `json:"ttl,omitempty"` + + TargetSyntaxValid bool `json:"target_syntax_valid,omitempty"` + TargetLooksGeneric bool `json:"target_looks_generic,omitempty"` + + ForwardAddresses []ForwardAddress `json:"forward_addresses,omitempty"` + ForwardMatch bool `json:"forward_match,omitempty"` + TargetResolves bool `json:"target_resolves,omitempty"` + ForwardError string `json:"forward_error,omitempty"` +} + +type ReverseZoneData struct { + Zone string `json:"zone"` + IsReverseZone bool `json:"is_reverse_zone"` + IsIPv6 bool `json:"is_ipv6"` + + PTRCount int `json:"ptr_count"` + Entries []PTREntry `json:"entries,omitempty"` // capped to maxPTRsToCheck + Truncated bool `json:"truncated,omitempty"` + LoadError string `json:"load_error,omitempty"` // structural failure preventing collection +} + +// ptrService mirrors happyDomain's `svcs.PTR`. +type ptrService struct { + Record *dns.PTR `json:"Record"` +} + +// serviceMessage mirrors happyDomain's per-service envelope inside a Zone. +type serviceMessage struct { + Type string `json:"_svctype"` + Service json.RawMessage `json:"Service"` +} + +// zoneMessage mirrors the subset of happyDomain's Zone needed here. +type zoneMessage struct { + DefaultTTL uint32 `json:"default_ttl"` + Services map[string][]serviceMessage `json:"services"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d457979 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module git.happydns.org/checker-reverse-zone + +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..f132748 --- /dev/null +++ b/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "flag" + "log" + + rz "git.happydns.org/checker-reverse-zone/checker" + "git.happydns.org/checker-sdk-go/checker/server" +) + +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + + rz.Version = Version + + srv := server.New(rz.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..1376c67 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,14 @@ +package main + +import ( + rz "git.happydns.org/checker-reverse-zone/checker" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var Version = "custom-build" + +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + rz.Version = Version + prvd := rz.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil +}