Initial commit

This commit is contained in:
nemunaire 2026-04-27 01:06:32 +07:00
commit 1d93a25983
23 changed files with 2654 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-reverse-zone
checker-reverse-zone.so

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -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"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-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

26
NOTICE Normal file
View file

@ -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

89
README.md Normal file
View file

@ -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`).

227
checker/collect.go Normal file
View file

@ -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)
}

245
checker/collect_test.go Normal file
View file

@ -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)
}

83
checker/definition.go Normal file
View file

@ -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
}

152
checker/dns.go Normal file
View file

@ -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)
}

155
checker/dns_test.go Normal file
View file

@ -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)
}
}

15
checker/provider.go Normal file
View file

@ -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
}

206
checker/provider_test.go Normal file
View file

@ -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
}

414
checker/report.go Normal file
View file

@ -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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Reverse zone report: {{.Zone}}</title>
<style>
:root {
--ok: #1e9e5d;
--info: #3b82f6;
--warn: #d97706;
--crit: #dc2626;
--bg: #f7f7f8;
--card: #ffffff;
--border: #e5e7eb;
--text: #111827;
--muted: #6b7280;
}
body { margin: 0; padding: 1.2rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); line-height: 1.45; }
h1 { font-size: 1.4rem; margin: 0 0 .3rem 0; }
h2 { font-size: 1.05rem; margin: 1.5rem 0 .6rem 0; border-bottom: 1px solid var(--border); padding-bottom: .25rem; }
h3 { font-size: .95rem; margin: 0 0 .35rem 0; }
.muted { color: var(--muted); }
.status-banner { display: flex; align-items: center; justify-content: space-between; padding: .8rem 1rem; border-radius: 8px; color: #fff; margin-bottom: 1rem; }
.status-ok { background: var(--ok); }
.status-info { background: var(--info); }
.status-warn { background: var(--warn); }
.status-crit { background: var(--crit); }
.status-banner .label { font-weight: 600; font-size: 1rem; }
.status-banner .sub { opacity: .9; font-size: .85rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: .6rem; margin-bottom: 1rem; }
.stat { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .7rem .9rem; }
.stat .k { color: var(--muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .03em; }
.stat .v { font-size: 1.4rem; font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.stat.crit .v { color: var(--crit); }
.stat.warn .v { color: var(--warn); }
.stat.ok .v { color: var(--ok); }
.top-failure { border-left: 4px solid var(--crit); background: #fef2f2; padding: .8rem 1rem; border-radius: 6px; margin-bottom: .6rem; }
.top-failure h3 { margin-bottom: .25rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; }
.top-failure .roundtrip { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem; margin: .35rem 0; }
.top-failure .roundtrip .arrow { color: var(--muted); margin: 0 .25rem; }
.top-failure .fix { background: rgba(0,0,0,.04); padding: .45rem .6rem; border-radius: 4px; font-size: .9rem; }
.top-failure .fix strong { display: block; color: var(--text); margin-bottom: .15rem; }
.top-failure.unresolved { border-color: var(--warn); background: #fffbeb; }
.badge { display: inline-block; padding: .05rem .4rem; border-radius: 4px; font-size: .72rem; color: #fff; font-weight: 600; text-transform: uppercase; }
.badge.crit { background: var(--crit); }
.badge.warn { background: var(--warn); }
.badge.ok { background: var(--ok); }
table { width: 100%; border-collapse: collapse; font-size: .85rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid var(--border); }
th { background: #f3f4f6; font-weight: 600; font-size: .75rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
tr:last-child td { border-bottom: none; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.more { font-size: .8rem; color: var(--muted); margin-top: .25rem; }
</style>
</head>
<body>
<div class="status-banner {{.OverallClass}}">
<div>
<div class="label">{{.OverallStatusText}}</div>
<div class="sub">for <code>{{.Zone}}</code></div>
</div>
<div class="sub">
{{.InspectedCount}} of {{.PTRCount}} PTR{{if gt .PTRCount 1}}s{{end}} inspected{{if .Truncated}} (truncated){{end}}
</div>
</div>
{{if .LoadError}}
<div class="top-failure">
<h3>Zone data could not be loaded</h3>
<p>{{.LoadError}}</p>
</div>
{{end}}
<div class="grid">
<div class="stat ok"><div class="k">FCrDNS OK</div><div class="v">{{.OK}}</div></div>
<div class="stat crit"><div class="k">FCrDNS mismatch</div><div class="v">{{.Mismatch}}</div></div>
<div class="stat warn"><div class="k">Target unresolved</div><div class="v">{{.Unresolved}}</div></div>
<div class="stat warn"><div class="k">Multiple PTR</div><div class="v">{{.Multiple}}</div></div>
<div class="stat warn"><div class="k">Generic-looking</div><div class="v">{{.Generic}}</div></div>
<div class="stat warn"><div class="k">Invalid syntax</div><div class="v">{{.InvalidName}}</div></div>
</div>
{{if .FCrDNSFailures}}
<h2>Fix these first: Forward / reverse round-trip ({{len .FCrDNSFailures}})</h2>
<p class="muted">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.</p>
{{range .FCrDNSFailures}}
<div class="top-failure {{if eq .Reason "unresolved"}}unresolved{{end}}">
<h3><code>{{.Owner}}</code>
{{if eq .Reason "mismatch"}}<span class="badge crit">FCrDNS mismatch</span>
{{else}}<span class="badge warn">target unresolved</span>{{end}}
</h3>
<div class="roundtrip">
<code>{{.IP}}</code> <span class="arrow">-PTR-&gt;</span> <code>{{.Target}}</code> <span class="arrow">-A/AAAA-&gt;</span>
{{if .ForwardAddrs}}{{range $i, $a := .ForwardAddrs}}{{if $i}}, {{end}}<code>{{$a}}</code>{{end}}
{{else}}<span class="muted">unresolved</span>{{end}}
</div>
<div class="fix"><strong>How to fix</strong>{{.SuggestedFix}}</div>
</div>
{{end}}
{{end}}
{{if .Sample}}
<h2>PTR records inspected (first {{len .Sample}})</h2>
<table>
<thead><tr><th>PTR owner</th><th>IP</th><th>Target</th><th>Forward A/AAAA</th><th>FCrDNS</th></tr></thead>
<tbody>
{{range .Sample}}
<tr>
<td><code>{{.Owner}}</code></td>
<td><code>{{.IP}}</code></td>
<td><code>{{.Target}}</code></td>
<td>{{if .Forward}}<code>{{.Forward}}</code>{{else}}<span class="muted">-</span>{{end}}</td>
<td>
{{if .Match}}<span class="badge ok">match</span>
{{else if .Resolved}}<span class="badge crit">mismatch</span>
{{else}}<span class="badge warn">unresolved</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{if gt .InspectedCount (len .Sample)}}<div class="more"> and {{sub .InspectedCount (len .Sample)}} more</div>{{end}}
{{end}}
{{if .OtherFindings}}
<h2>Other findings</h2>
<table>
<thead><tr><th>Severity</th><th>Code</th><th>Subject</th><th>Message</th></tr></thead>
<tbody>
{{range .OtherFindings}}
<tr>
<td><span class="badge {{.Severity}}">{{.Severity}}</span></td>
<td><code>{{.Code}}</code></td>
<td><code>{{.Subject}}</code></td>
<td>{{.Message}}{{if .Hint}}<br><span class="muted">{{.Hint}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</body>
</html>`

81
checker/report_test.go Normal file
View file

@ -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)
}
}
}

61
checker/rule.go Normal file
View file

@ -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
}

336
checker/rules.go Normal file
View file

@ -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,
)}
}

366
checker/rules_test.go Normal file
View file

@ -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
}
}

60
checker/types.go Normal file
View file

@ -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"`
}

16
go.mod Normal file
View file

@ -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
)

16
go.sum Normal file
View file

@ -0,0 +1,16 @@
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=

24
main.go Normal file
View file

@ -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)
}
}

14
plugin/plugin.go Normal file
View file

@ -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
}