Initial commit
This commit is contained in:
commit
1d93a25983
23 changed files with 2654 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-reverse-zone
|
||||
checker-reverse-zone.so
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal 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
21
LICENSE
Normal 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
28
Makefile
Normal 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
26
NOTICE
Normal 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
89
README.md
Normal 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
227
checker/collect.go
Normal 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
245
checker/collect_test.go
Normal 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
83
checker/definition.go
Normal 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
152
checker/dns.go
Normal 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
155
checker/dns_test.go
Normal 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
15
checker/provider.go
Normal 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
206
checker/provider_test.go
Normal 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
414
checker/report.go
Normal 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-></span> <code>{{.Target}}</code> <span class="arrow">-A/AAAA-></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
81
checker/report_test.go
Normal 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
61
checker/rule.go
Normal 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
336
checker/rules.go
Normal 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
366
checker/rules_test.go
Normal 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
60
checker/types.go
Normal 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
16
go.mod
Normal 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
16
go.sum
Normal 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
24
main.go
Normal 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
14
plugin/plugin.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue