Initial commit

This commit is contained in:
nemunaire 2026-04-26 11:27:12 +07:00
commit 67c955129d
20 changed files with 2203 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-ptr
checker-ptr.so

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-ptr .
FROM scratch
COPY --from=builder /checker-ptr /checker-ptr
USER 65534:65534
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-ptr", "-healthcheck"]
ENTRYPOINT ["/checker-ptr"]

21
LICENSE Normal file
View file

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

28
Makefile Normal file
View file

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

96
README.md Normal file
View file

@ -0,0 +1,96 @@
# checker-ptr
PTR / Reverse DNS checker for [happyDomain](https://www.happydomain.org/).
Validates reverse DNS for an IP: confirms the owner lies under
`in-addr.arpa` / `ip6.arpa`, locates the reverse zone, queries the
authoritative servers, and verifies PTR presence, target syntax (RFC
952/1123), forward resolution and Forward-Confirmed Reverse DNS
(FCrDNS), single-PTR hygiene (RFC 1912 §2.1), TTL hygiene, and
generic-hostname patterns commonly penalised by mail filters.
## Usage
### Standalone HTTP server
```bash
# Build and run
make
./checker-ptr -listen :8080
```
The server exposes:
- `GET /health`: health check
- `POST /collect`: collect PTR observations (happyDomain external checker protocol)
### Docker
```bash
make docker
docker run -p 8080:8080 happydomain/checker-ptr
```
### happyDomain plugin
```bash
make plugin
# produces checker-ptr.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 PTR checker to the URL of the
running checker-ptr server (e.g., `http://checker-ptr:8080`).
happyDomain will delegate observation collection to this endpoint.
## Options
| Id | Type | Default | Description |
|-----------------------|------|---------|------------------------------------------------------------------------------------------------------|
| `requireForwardMatch` | bool | `true` | When enabled, a PTR target whose A/AAAA does not include the original IP is critical (else warning). |
| `followTargetCNAME` | bool | `true` | Follow CNAME chains when resolving the PTR target before comparing A/AAAA to the original IP. |
| `allowMultiplePTR` | bool | `false` | When disabled, more than one PTR at the same owner is flagged as warning (RFC 1912 §2.1). |
| `minTTL` | uint | `300` | PTR records with a TTL below this threshold are flagged as warning. |
| `flagGenericPTR` | bool | `true` | When enabled, PTR targets embedding the IP or matching common ISP auto-generated patterns warn. |
## Rules
Each rule emits a finding code. Severity can be affected by the options above.
| Code | Default severity | Condition |
|------|-----------------|-----------|
| `ptr_not_in_reverse_zone` | critical | The PTR owner is not under `in-addr.arpa` or `ip6.arpa`. |
| `ptr_owner_malformed` | critical | The reverse-arpa owner cannot be decoded back to an IP address. |
| `ptr_no_reverse_zone` | critical | The reverse zone serving the owner cannot be located (no SOA). |
| `ptr_query_failed` | critical | The PTR query failed (network error, timeout, unreachable authoritative server). |
| `ptr_rcode` | critical | The authoritative server returned a non-NOERROR rcode (typically NXDOMAIN). |
| `ptr_missing` | critical | No PTR record is served at the owner name. |
| `ptr_multiple` | warning | More than one PTR record exists at the same owner (RFC 1912 §2.1). Suppressed when `allowMultiplePTR` is enabled. |
| `ptr_declared_mismatch` | critical | The authoritative PTR target differs from the target declared in happyDomain. |
| `ptr_target_invalid` | critical | The PTR target is not a syntactically valid hostname (RFC 952/1123). |
| `ptr_generic_hostname` | warning | The PTR target embeds the IP or matches a common ISP auto-generated pattern. Only reported when `flagGenericPTR` is enabled. |
| `ptr_target_unresolvable` | critical / warning with `requireForwardMatch=false` | The PTR target has no A or AAAA record. |
| `ptr_forward_mismatch` | critical / warning with `requireForwardMatch=false` | The PTR target's A/AAAA does not include the original IP (FCrDNS check failed). |
| `ptr_ipv6_missing` | critical | An `ip6.arpa` owner has no PTR record. |
| `ptr_low_ttl` | warning | The observed PTR TTL is below `minTTL`. |
| `ptr_declared_low_ttl` | info | The declared PTR TTL is below `minTTL`. |
## License
Licensed under the **MIT License** (see `LICENSE`).

278
checker/collect.go Normal file
View file

@ -0,0 +1,278 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"net"
"regexp"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Collect gathers raw PTR observation data. It does NOT judge: no severity,
// no pass/fail, no pre-derived findings. CheckRule implementations turn the
// raw fields into CheckStates.
func (p *ptrProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
owner, declaredTarget, declaredTTL, err := resolvePTRInputs(opts)
if err != nil {
return nil, err
}
data := &PTRData{
OwnerName: owner,
DeclaredTarget: declaredTarget,
DeclaredTTL: declaredTTL,
}
// Structural classification: is this a reverse-arpa name, and can we
// decode an IP from it?
data.InReverseArpa = isReverseArpa(owner)
data.IsIPv6 = strings.HasSuffix(strings.TrimSuffix(lowerFQDN(owner), "."), ".ip6.arpa")
ip := reverseNameToIP(owner)
if ip != nil {
data.ReverseIP = ip.String()
} else if data.InReverseArpa {
data.OwnerDecodeFailed = true
}
// Reverse zone location.
zone, servers, zerr := findReverseZone(ctx, owner)
data.ReverseZone = zone
data.ReverseNS = servers
if zerr != nil {
data.ZoneLookupError = zerr.Error()
}
// PTR query at authoritative servers (fall back to the system resolver).
observed, observedTTL, rcode, qerr := queryPTR(ctx, owner, servers)
data.Rcode = rcode
data.ObservedTargets = observed
data.ObservedTTL = observedTTL
if qerr != nil {
data.QueryError = qerr.Error()
}
// Effective target for hostname hygiene / FCrDNS: prefer observed,
// fall back to declared.
declNorm := lowerFQDN(declaredTarget)
normalizedObserved := make([]string, len(observed))
for i, o := range observed {
normalizedObserved[i] = lowerFQDN(o)
}
target := declNorm
if len(normalizedObserved) > 0 {
target = normalizedObserved[0]
}
data.EffectiveTarget = target
if target != "" {
_, syntaxOK := dns.IsDomainName(strings.TrimSuffix(target, "."))
data.TargetSyntaxValid = syntaxOK
if ip != nil {
data.TargetLooksGeneric = looksGeneric(target, ip)
}
}
// Forward-Confirmed Reverse DNS: resolve target and compare with
// ReverseIP.
if target != "" && ip != nil {
addrs, tResolves := resolveForward(ctx, target)
data.ForwardAddresses = addrs
data.TargetResolves = tResolves
for _, a := range addrs {
if ipEqual(a.Address, ip) {
data.ForwardMatch = true
break
}
}
}
return data, nil
}
// resolvePTRInputs extracts the PTR owner, declared target and TTL from the
// auto-filled options.
func resolvePTRInputs(opts sdk.CheckerOptions) (owner, target string, ttl uint32, err error) {
if svcMsg, ok := sdk.GetOption[serviceMessage](opts, "service"); ok && len(svcMsg.Service) > 0 {
if svcMsg.Type != "" && svcMsg.Type != "svcs.PTR" {
return "", "", 0, fmt.Errorf("service is %s, expected svcs.PTR", svcMsg.Type)
}
var s ptrService
if err := json.Unmarshal(svcMsg.Service, &s); err == nil && s.Record != nil {
ownerName := s.Record.Hdr.Name
if ownerName == "" || ownerName == "@" {
ownerName = svcMsg.Domain
} else if !strings.HasSuffix(ownerName, ".") {
if svcMsg.Domain != "" {
ownerName = ownerName + "." + strings.TrimSuffix(svcMsg.Domain, ".")
}
}
declared := ""
if s.Record.Ptr != "" {
declared = lowerFQDN(s.Record.Ptr)
}
return lowerFQDN(ownerName), declared, s.Record.Hdr.Ttl, nil
}
}
parent, _ := sdk.GetOption[string](opts, "domain_name")
sub, _ := sdk.GetOption[string](opts, "subdomain")
if parent == "" {
return "", "", 0, fmt.Errorf("missing 'service' and 'domain_name' options")
}
expected, _ := sdk.GetOption[string](opts, "expected_target")
expected = strings.TrimSpace(expected)
declared := ""
if expected != "" {
declared = lowerFQDN(expected)
}
parent = strings.TrimSuffix(parent, ".")
if sub == "" || sub == "@" {
return lowerFQDN(parent), declared, 0, nil
}
sub = strings.TrimSuffix(sub, ".")
return lowerFQDN(sub + "." + parent), declared, 0, nil
}
// queryPTR asks for the PTR RRset at owner. It uses the supplied authoritative
// servers when available; otherwise it falls back to the system resolver.
func queryPTR(ctx context.Context, owner string, authServers []string) ([]string, uint32, string, error) {
q := dns.Question{Name: dns.Fqdn(owner), Qtype: dns.TypePTR, Qclass: dns.ClassINET}
var r *dns.Msg
var err error
if len(authServers) > 0 {
r, _, err = queryAtAuth(ctx, authServers, q)
} else {
r, err = dnsExchange(ctx, "", systemResolver(), q, true)
}
if err != nil {
return nil, 0, "", err
}
rcode := rcodeText(r.Rcode)
var targets []string
var ttl uint32
for _, rr := range r.Answer {
if ptr, ok := rr.(*dns.PTR); ok && strings.EqualFold(dns.Fqdn(ptr.Hdr.Name), dns.Fqdn(owner)) {
targets = append(targets, lowerFQDN(ptr.Ptr))
if ttl == 0 || ptr.Hdr.Ttl < ttl {
ttl = ptr.Hdr.Ttl
}
}
}
return targets, ttl, rcode, nil
}
// resolveForward runs the forward lookup of name via the system resolver.
func resolveForward(ctx context.Context, name string) ([]ForwardAddress, bool) {
var resolver net.Resolver
var out []ForwardAddress
ips, err := resolver.LookupIP(ctx, "ip", strings.TrimSuffix(name, "."))
if err != nil || len(ips) == 0 {
// Fall back to direct DNS queries (system resolver may filter AAAA).
for _, qt := range []uint16{dns.TypeA, dns.TypeAAAA} {
q := dns.Question{Name: dns.Fqdn(name), Qtype: qt, Qclass: dns.ClassINET}
r, rerr := dnsExchange(ctx, "", systemResolver(), q, true)
if rerr != nil || 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})
}
}
}
return out, len(out) > 0
}
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
out = append(out, ForwardAddress{Type: "A", Address: v4.String()})
} else {
out = append(out, ForwardAddress{Type: "AAAA", Address: ip.String()})
}
}
return out, true
}
// ipEqual compares an address string with a net.IP (normalising IPv4-in-IPv6).
func ipEqual(addr string, ip net.IP) bool {
parsed := net.ParseIP(addr)
if parsed == nil {
return false
}
return parsed.Equal(ip)
}
// looksGeneric reports whether hostname embeds the dotted/hyphenated IP or
// matches the common ISP auto-generated patterns that mail filters penalise.
//
// The pattern requires a "-" or "." separator before the digit run so legitimate
// names like "host1.example.com" or "static-www" do not match; auto-generated
// PTRs almost always look like "dhcp-1-2-3-4", "pool.10.20", "dyn-203" etc.
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 {
// Build the 32-nibble hex form, then check the common embedded
// shapes: continuous ("20010db8…"), dash-grouped ("2001-0db8-…"),
// dot-grouped ("2001.0db8.…"), and full nibble-by-nibble ("2.0.0.1.0.d.b.8.…").
var hex [32]byte
const hexdigits = "0123456789abcdef"
for i, b := range v6 {
hex[i*2] = hexdigits[b>>4]
hex[i*2+1] = hexdigits[b&0x0f]
}
flat := string(hex[:])
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],
}
// At least four consecutive groups must be present to claim the
// hostname embeds the address (avoids false positives on short
// hex-looking labels).
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
}
}
}
// Nibble-per-label form, as appears in some ISP PTRs.
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)
}

160
checker/collect_test.go Normal file
View file

@ -0,0 +1,160 @@
package checker
import (
"encoding/json"
"net"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestLooksGeneric(t *testing.T) {
v4 := net.ParseIP("203.0.113.42")
v6 := net.ParseIP("2001:db8::1")
cases := []struct {
name string
host string
ip net.IP
want bool
}{
{"dotted ip embedded", "host-203.0.113.42.example.net", v4, true},
{"hyphenated ip embedded", "203-0-113-42.isp.example.net", v4, true},
{"dhcp pattern", "dhcp-10-20-30.isp.example.net", v4, true},
{"pool pattern", "pool.10.20.30.isp.example.net", v4, true},
{"dyn pattern", "dyn-203-0-113.isp.example.net", v4, true},
{"clean hostname", "mail.example.com", v4, false},
{"hostname with single digit suffix (not generic)", "host1.example.com", v4, false},
{"static-www should not match", "static-www.example.com", v4, false},
{"v6 short prefix only", "ipv6-2001-db8.example.net", v6, false},
{"v6 dash-grouped embedded", "host-2001-0db8-0000-0000-0000-0000-0000-0001.isp.example.net", v6, true},
{"v6 flat hex embedded", "h20010db8000000000000000000000001.isp.example.net", v6, true},
{"v6 dotted nibble embedded", "host.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.example.net", v6, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := looksGeneric(tc.host, tc.ip); got != tc.want {
t.Errorf("looksGeneric(%q, %s) = %v, want %v", tc.host, tc.ip, got, tc.want)
}
})
}
}
func TestIPEqual(t *testing.T) {
v4 := net.ParseIP("1.2.3.4")
if !ipEqual("1.2.3.4", v4) {
t.Error("expected 1.2.3.4 to equal itself")
}
if !ipEqual("::ffff:1.2.3.4", v4) {
t.Error("expected v4-mapped v6 to equal v4")
}
if ipEqual("1.2.3.5", v4) {
t.Error("different addresses should not be equal")
}
if ipEqual("not-an-ip", v4) {
t.Error("invalid input must not be equal")
}
}
func TestResolvePTRInputs_FromDomainSubdomain(t *testing.T) {
opts := sdk.CheckerOptions{
"domain_name": "3.2.1.in-addr.arpa",
"subdomain": "4",
}
owner, target, ttl, err := resolvePTRInputs(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "4.3.2.1.in-addr.arpa." {
t.Errorf("owner = %q, want 4.3.2.1.in-addr.arpa.", owner)
}
if target != "" {
t.Errorf("target = %q, want empty", target)
}
if ttl != 0 {
t.Errorf("ttl = %d, want 0", ttl)
}
}
func TestResolvePTRInputs_ApexSubdomain(t *testing.T) {
opts := sdk.CheckerOptions{
"domain_name": "4.3.2.1.in-addr.arpa.",
"subdomain": "@",
}
owner, _, _, err := resolvePTRInputs(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "4.3.2.1.in-addr.arpa." {
t.Errorf("owner = %q, want 4.3.2.1.in-addr.arpa.", owner)
}
}
func TestResolvePTRInputs_ExpectedTarget(t *testing.T) {
opts := sdk.CheckerOptions{
"domain_name": "3.2.1.in-addr.arpa",
"subdomain": "4",
"expected_target": "Mail.Example.COM",
}
_, target, _, err := resolvePTRInputs(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if target != "mail.example.com." {
t.Errorf("target = %q, want mail.example.com.", target)
}
}
func TestResolvePTRInputs_MissingDomain(t *testing.T) {
if _, _, _, err := resolvePTRInputs(sdk.CheckerOptions{}); err == nil {
t.Fatal("expected error for missing domain_name")
}
}
func TestResolvePTRInputs_FromService(t *testing.T) {
rec := map[string]any{
"Hdr": map[string]any{
"Name": "4",
"Rrtype": 12,
"Class": 1,
"Ttl": 3600,
},
"Ptr": "Mail.Example.COM.",
}
svc, _ := json.Marshal(map[string]any{"Record": rec})
envelope, _ := json.Marshal(map[string]any{
"_svctype": "svcs.PTR",
"_domain": "3.2.1.in-addr.arpa",
"Service": json.RawMessage(svc),
})
opts := sdk.CheckerOptions{"service": json.RawMessage(envelope)}
owner, target, ttl, err := resolvePTRInputs(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != "4.3.2.1.in-addr.arpa." {
t.Errorf("owner = %q, want 4.3.2.1.in-addr.arpa.", owner)
}
if target != "mail.example.com." {
t.Errorf("target = %q, want mail.example.com.", target)
}
if ttl != 3600 {
t.Errorf("ttl = %d, want 3600", ttl)
}
}
func TestResolvePTRInputs_WrongServiceType(t *testing.T) {
envelope, _ := json.Marshal(map[string]any{
"_svctype": "svcs.A",
"_domain": "example.com",
"Service": json.RawMessage(`{"Record":null}`),
})
opts := sdk.CheckerOptions{"service": json.RawMessage(envelope)}
if _, _, _, err := resolvePTRInputs(opts); err == nil {
t.Fatal("expected error for non-PTR service type")
}
}

87
checker/definition.go Normal file
View file

@ -0,0 +1,87 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
var Version = "built-in"
// Definition returns the CheckerDefinition for the PTR checker.
func (p *ptrProvider) Definition() *sdk.CheckerDefinition {
def := &sdk.CheckerDefinition{
ID: "ptr",
Name: "PTR / Reverse DNS",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"svcs.PTR"},
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyPTR},
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 servers and many SSH setups 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 name 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,
},
},
ServiceOpts: []sdk.CheckerOptionDocumentation{
{
Id: "service_type",
Label: "Service type",
AutoFill: sdk.AutoFillServiceType,
},
{
Id: "service",
Label: "Service",
AutoFill: sdk.AutoFillService,
},
{
Id: "domain_name",
Label: "Reverse zone",
AutoFill: sdk.AutoFillDomainName,
},
{
Id: "subdomain",
Label: "PTR record",
AutoFill: sdk.AutoFillSubdomain,
},
},
},
Rules: Rules(),
HasHTMLReport: true,
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 24 * time.Hour,
Default: 1 * time.Hour,
},
}
def.BuildRulesInfo()
return def
}

207
checker/dns.go Normal file
View file

@ -0,0 +1,207 @@
package checker
import (
"context"
"fmt"
"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. It can be overridden at startup (e.g. via a CLI flag) so operators
// don't silently leak lookups to a third party.
var FallbackResolver = net.JoinHostPort("1.1.1.1", "53")
// dnsExchange sends a single query. proto="" uses UDP and retries over TCP on
// truncation; recursion controls the RD flag.
func dnsExchange(ctx context.Context, proto, server string, q dns.Question, recursion bool) (*dns.Msg, error) {
client := dns.Client{Net: proto, Timeout: dnsTimeout}
m := new(dns.Msg)
m.Id = dns.Id()
m.Question = []dns.Question{q}
m.RecursionDesired = recursion
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 {
return nil, fmt.Errorf("nil response from %s", server)
}
if r.Truncated && proto == "" {
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
}
// systemResolver returns the first configured resolver of the local system.
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)
}
// hostPort returns "host:port", correctly bracketing IPv6 literals.
func hostPort(host, port string) string {
host = strings.TrimSuffix(host, ".")
return net.JoinHostPort(host, port)
}
// findReverseZone walks up the labels of fqdn until it finds a zone cut
// (SOA). Returns the apex FQDN and the list of "host:53" authoritative
// servers. The walk stops at the reverse-arpa apex (in-addr.arpa or
// ip6.arpa) so we never accept a non-reverse zone (or the root) as a match.
func findReverseZone(ctx context.Context, fqdn string) (apex string, servers []string, err error) {
resolver := systemResolver()
labels := dns.SplitDomainName(fqdn)
for i := range labels {
candidate := dns.Fqdn(strings.Join(labels[i:], "."))
if !isReverseArpa(candidate) {
break
}
q := dns.Question{Name: candidate, Qtype: dns.TypeSOA, Qclass: dns.ClassINET}
r, rerr := dnsExchange(ctx, "", resolver, q, true)
if rerr != nil {
continue
}
if r.Rcode != dns.RcodeSuccess {
continue
}
hasSOA := false
for _, rr := range r.Answer {
if _, ok := rr.(*dns.SOA); ok {
hasSOA = true
break
}
}
if !hasSOA {
continue
}
apex = candidate
// NS resolution failures are non-fatal: we still located the zone,
// and queryPTR will fall back to the system resolver. Returning an
// error here would make reverseZoneRule wrongly report "zone not
// found".
servers, _ := resolveZoneNSAddrs(ctx, apex)
return apex, servers, nil
}
return "", nil, fmt.Errorf("could not locate reverse zone of %s", fqdn)
}
// resolveZoneNSAddrs returns "host:53" entries for every NS of the zone.
func resolveZoneNSAddrs(ctx context.Context, zone string) ([]string, error) {
var resolver net.Resolver
nss, err := resolver.LookupNS(ctx, strings.TrimSuffix(zone, "."))
if err != nil {
return nil, err
}
var out []string
for _, ns := range nss {
addrs, err := resolver.LookupHost(ctx, strings.TrimSuffix(ns.Host, "."))
if err != nil || len(addrs) == 0 {
continue
}
for _, a := range addrs {
out = append(out, hostPort(a, "53"))
}
}
return out, nil
}
// queryAtAuth sends q to the first reachable server of the list.
func queryAtAuth(ctx context.Context, servers []string, q dns.Question) (*dns.Msg, string, error) {
var lastErr error
for _, s := range servers {
r, err := dnsExchange(ctx, "", s, q, false)
if err != nil {
lastErr = err
continue
}
return r, s, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no servers provided")
}
return nil, "", lastErr
}
// rcodeText returns the textual name of an rcode or a fallback string.
func rcodeText(r int) string {
if s, ok := dns.RcodeToString[r]; ok {
return s
}
return fmt.Sprintf("RCODE(%d)", r)
}
// lowerFQDN returns the canonical lowercase FQDN form of name.
func lowerFQDN(name string) string {
return strings.ToLower(dns.Fqdn(name))
}
// reverseNameToIP decodes a reverse-arpa name back to a net.IP. It accepts
// both in-addr.arpa (IPv4) and ip6.arpa (IPv6). Returns nil if the name is
// malformed.
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
}
// Reverse: "4.3.2.1" -> "1.2.3.4"
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
}
// Reverse the nibbles and regroup into 8 × 4-hex blocks.
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
}
// isReverseArpa reports whether name lies inside in-addr.arpa or ip6.arpa.
func isReverseArpa(name string) bool {
n := lowerFQDN(name)
return strings.HasSuffix(n, ".in-addr.arpa.") || strings.HasSuffix(n, ".ip6.arpa.")
}

111
checker/dns_test.go Normal file
View file

@ -0,0 +1,111 @@
package checker
import (
"net"
"testing"
)
func TestReverseNameToIP(t *testing.T) {
cases := []struct {
name string
in string
want string // empty = expect nil
}{
{"ipv4 ok", "4.3.2.1.in-addr.arpa.", "1.2.3.4"},
{"ipv4 no trailing dot", "4.3.2.1.in-addr.arpa", "1.2.3.4"},
{"ipv4 uppercase suffix", "4.3.2.1.IN-ADDR.ARPA.", "1.2.3.4"},
{"ipv4 too few labels", "3.2.1.in-addr.arpa.", ""},
{"ipv4 too many labels", "5.4.3.2.1.in-addr.arpa.", ""},
{"ipv4 non-numeric label", "a.3.2.1.in-addr.arpa.", ""},
{"ipv4 octet out of range", "256.3.2.1.in-addr.arpa.", ""},
{
"ipv6 ok",
"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.",
"2001:db8::1",
},
{"ipv6 too few nibbles", "0.0.8.b.d.0.ip6.arpa.", ""},
{
"ipv6 multi-char label",
"00.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.",
"",
},
{"not arpa", "example.com.", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := reverseNameToIP(tc.in)
if tc.want == "" {
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
return
}
if got == nil {
t.Fatalf("expected %s, got nil", tc.want)
}
want := net.ParseIP(tc.want)
if !got.Equal(want) {
t.Fatalf("expected %s, got %s", want, got)
}
})
}
}
func TestIsReverseArpa(t *testing.T) {
cases := map[string]bool{
"4.3.2.1.in-addr.arpa.": true,
"4.3.2.1.in-addr.arpa": true,
"1.0.0.0.ip6.arpa.": true,
"IN-ADDR.ARPA.": false, // bare apex, no leading label
"example.com.": false,
"": false,
}
for in, want := range cases {
if got := isReverseArpa(in); got != want {
t.Errorf("isReverseArpa(%q) = %v, want %v", in, got, want)
}
}
}
func TestLowerFQDN(t *testing.T) {
cases := map[string]string{
"Example.COM": "example.com.",
"example.com.": "example.com.",
"": ".",
}
for in, want := range cases {
if got := lowerFQDN(in); got != want {
t.Errorf("lowerFQDN(%q) = %q, want %q", in, got, want)
}
}
}
func TestHostPort(t *testing.T) {
cases := []struct {
host, port, want string
}{
{"1.2.3.4", "53", "1.2.3.4:53"},
{"ns1.example.com.", "53", "ns1.example.com:53"},
{"2001:db8::1", "53", "[2001:db8::1]:53"},
}
for _, tc := range cases {
if got := hostPort(tc.host, tc.port); got != tc.want {
t.Errorf("hostPort(%q,%q) = %q, want %q", tc.host, tc.port, got, tc.want)
}
}
}
func TestRcodeText(t *testing.T) {
if got := rcodeText(0); got != "NOERROR" {
t.Errorf("rcodeText(0) = %q, want NOERROR", got)
}
if got := rcodeText(3); got != "NXDOMAIN" {
t.Errorf("rcodeText(3) = %q, want NXDOMAIN", got)
}
if got := rcodeText(999); got != "RCODE(999)" {
t.Errorf("rcodeText(999) = %q, want RCODE(999)", got)
}
}

73
checker/interactive.go Normal file
View file

@ -0,0 +1,73 @@
//go:build standalone
package checker
import (
"errors"
"net"
"net/http"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm exposes a minimal form accepting either an IP address or a
// reverse-arpa owner, plus an optional expected hostname (for FCrDNS).
func (p *ptrProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "ip",
Type: "string",
Label: "IP address or reverse name",
Placeholder: "2001:db8::1 or 4.3.2.1.in-addr.arpa",
Required: true,
Description: "IPv4, IPv6 or fully-qualified reverse name. The checker derives one from the other automatically.",
},
{
Id: "expected",
Type: "string",
Label: "Expected hostname",
Placeholder: "mail.example.com",
Description: "Optional. When set, the checker compares it to the PTR served by the reverse zone.",
},
}
}
// ParseForm turns the submitted input into a minimal CheckerOptions set
// suitable for the Collect pipeline. We encode the inputs as a synthetic
// svcs.PTR service so Collect's existing unmarshaller picks it up.
func (p *ptrProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
raw := strings.TrimSpace(r.FormValue("ip"))
if raw == "" {
return nil, errors.New("IP address or reverse name is required")
}
var owner string
if ip := net.ParseIP(raw); ip != nil {
rev, err := dns.ReverseAddr(ip.String())
if err != nil {
return nil, err
}
owner = rev
} else {
owner = dns.Fqdn(raw)
}
expected := strings.TrimSpace(r.FormValue("expected"))
if expected == "" {
// Build a service payload with no declared target: the checker will
// still do existence + FCrDNS.
return sdk.CheckerOptions{
"domain_name": strings.TrimSuffix(owner, "."),
"subdomain": "@",
}, nil
}
return sdk.CheckerOptions{
"domain_name": strings.TrimSuffix(owner, "."),
"subdomain": "@",
"expected_target": expected,
}, nil
}

16
checker/provider.go Normal file
View file

@ -0,0 +1,16 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new PTR observation provider.
func Provider() sdk.ObservationProvider {
return &ptrProvider{}
}
type ptrProvider struct{}
func (p *ptrProvider) Key() sdk.ObservationKey {
return ObservationKeyPTR
}

443
checker/report.go Normal file
View file

@ -0,0 +1,443 @@
package checker
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// GetHTMLReport renders an HTML summary of the last PTR run. Hints and fixes
// are driven exclusively by the CheckStates produced by this checker's
// rules (exposed via ctx.States()); when no states are available the report
// renders the raw PTR observation without hint sections.
func (p *ptrProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data PTRData
if raw := ctx.Data(); len(raw) > 0 {
if err := json.Unmarshal(raw, &data); err != nil {
return "", fmt.Errorf("parse PTR 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
}
// topFailureCodes orders the findings surfaced in the "Fix these first"
// section of the report. The order reflects impact: the canonical FCrDNS
// failure modes come first because they block mail delivery.
var topFailureCodes = []string{
"ptr_missing",
"ptr_rcode",
"ptr_target_unresolvable",
"ptr_forward_mismatch",
"ptr_declared_mismatch",
"ptr_not_in_reverse_zone",
"ptr_no_reverse_zone",
"ptr_owner_malformed",
"ptr_target_invalid",
"ptr_multiple",
"ptr_generic_hostname",
"ptr_query_failed",
"ptr_ipv6_missing",
}
type reportView struct {
Owner string
ReverseIP string
ReverseZone string
ReverseNS []string
DeclaredTarget string
ObservedTargets []string
ObservedTTL uint32
ForwardAddresses []ForwardAddress
ForwardMatch bool
TargetResolves bool
Rcode string
OverallStatus string
OverallStatusText string
OverallClass string
TopFailures []topFailure
OtherFindings []stateView
HasStates bool
}
type topFailure struct {
Code string
Title string
Severity string
Messages []string
Hint string
Subject string
}
type stateView struct {
Severity string
Code string
Subject string
Message string
Hint string
}
// statusToSeverity maps SDK statuses to the severity strings used by the
// HTML template. Empty string = no banner-worthy issue (pass/info/unknown
// rendered as "info" when they surface in tables, otherwise ignored).
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 meta == nil {
return ""
}
// Rules expose the fix under "hint"; also accept "fix" as an alias so
// either convention works.
for _, key := range []string{"hint", "fix"} {
if v, ok := meta[key]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
}
return ""
}
func buildReportView(data *PTRData, states []sdk.CheckState) *reportView {
v := &reportView{
Owner: data.OwnerName,
ReverseIP: data.ReverseIP,
ReverseZone: data.ReverseZone,
ReverseNS: data.ReverseNS,
DeclaredTarget: data.DeclaredTarget,
ObservedTargets: data.ObservedTargets,
ObservedTTL: data.ObservedTTL,
ForwardAddresses: data.ForwardAddresses,
ForwardMatch: data.ForwardMatch,
TargetResolves: data.TargetResolves,
Rcode: data.Rcode,
HasStates: len(states) > 0,
}
// Filter to actionable states (crit/warn/info); drop pass/unknown.
type issue struct {
code string
severity string
message string
subject string
hint string
}
var issues []issue
worst := ""
for _, st := range states {
sev := statusToSeverity(st.Status)
if sev == "" {
continue
}
if severityWeight(sev) > severityWeight(worst) {
worst = sev
}
issues = append(issues, issue{
code: st.Code,
severity: sev,
message: st.Message,
subject: st.Subject,
hint: hintFromMeta(st.Meta),
})
}
switch worst {
case "crit":
v.OverallStatus = "crit"
v.OverallStatusText = "Critical issues detected"
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 v.HasStates {
v.OverallStatusText = "PTR is healthy (FCrDNS confirmed)"
} else {
v.OverallStatusText = "PTR observation"
}
v.OverallClass = "status-ok"
}
topIndex := map[string]int{}
for i, c := range topFailureCodes {
topIndex[c] = i
}
topMap := map[string]*topFailure{}
for _, f := range issues {
if _, isTop := topIndex[f.code]; isTop {
tf, ok := topMap[f.code]
if !ok {
tf = &topFailure{
Code: f.code,
Title: titleFor(f.code),
Subject: f.subject,
}
topMap[f.code] = tf
}
tf.Messages = append(tf.Messages, f.message)
if tf.Hint == "" {
tf.Hint = f.hint
}
if severityWeight(f.severity) > severityWeight(tf.Severity) {
tf.Severity = f.severity
}
continue
}
v.OtherFindings = append(v.OtherFindings, stateView{
Severity: f.severity,
Code: f.code,
Subject: f.subject,
Message: f.message,
Hint: f.hint,
})
}
for _, code := range topFailureCodes {
if tf, ok := topMap[code]; ok {
v.TopFailures = append(v.TopFailures, *tf)
}
}
return v
}
func titleFor(code string) string {
switch code {
case "ptr_missing":
return "No PTR record published"
case "ptr_rcode":
return "Reverse zone returned an error"
case "ptr_target_unresolvable":
return "PTR target does not resolve (forward DNS missing)"
case "ptr_forward_mismatch":
return "Forward / reverse mismatch (FCrDNS fails)"
case "ptr_declared_mismatch":
return "Authoritative PTR disagrees with the declared target"
case "ptr_not_in_reverse_zone":
return "Record is not in a reverse (*.arpa) zone"
case "ptr_no_reverse_zone":
return "Reverse zone not found"
case "ptr_owner_malformed":
return "Reverse name is malformed"
case "ptr_target_invalid":
return "PTR target is not a valid hostname"
case "ptr_multiple":
return "Multiple PTR records on the same IP"
case "ptr_generic_hostname":
return "PTR target looks auto-generated"
case "ptr_query_failed":
return "Could not reach the reverse zone servers"
case "ptr_ipv6_missing":
return "IPv6 PTR record missing"
}
return strings.ReplaceAll(code, "_", " ")
}
var reportTmpl = template.Must(template.New("ptr-report").Parse(reportTemplate))
// reportTemplate is the single-file HTML report. Styles are inlined so the
// report embeds cleanly in an iframe with no asset dependencies.
const reportTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PTR / reverse DNS report {{.Owner}}</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(240px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; }
.card .k { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .03em; }
.card .v { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95rem; word-break: break-all; }
.top-failure { border-left: 4px solid var(--crit); background: #fef2f2; padding: .8rem 1rem; border-radius: 6px; margin-bottom: .6rem; }
.top-failure.severity-warn { border-color: var(--warn); background: #fffbeb; }
.top-failure.severity-info { border-color: var(--info); background: #eff6ff; }
.top-failure h3 { margin-bottom: .25rem; }
.top-failure ul { margin: .25rem 0 .35rem 1.1rem; padding: 0; font-size: .9rem; }
.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; }
.roundtrip { display: flex; align-items: center; gap: .6rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92rem; margin-bottom: .6rem; flex-wrap: wrap; }
.roundtrip .step { padding: .15rem .5rem; border-radius: 4px; background: #eef2ff; }
.roundtrip .arrow { color: var(--muted); }
.roundtrip.match { border-color: var(--ok); }
.roundtrip.miss { border-color: var(--crit); }
.badge { display: inline-block; background: #e5e7eb; padding: .05rem .4rem; border-radius: 4px; font-size: .75rem; color: var(--text); }
.badge.on { background: #dcfce7; color: #14532d; }
.badge.off { background: #fee2e2; color: #7f1d1d; }
table { width: 100%; border-collapse: collapse; font-size: .88rem; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
th, td { text-align: left; padding: .45rem .7rem; border-bottom: 1px solid var(--border); }
th { background: #f3f4f6; font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); }
tr:last-child td { border-bottom: none; }
.sev { display: inline-block; padding: .08rem .4rem; border-radius: 4px; font-size: .72rem; font-weight: 600; color: #fff; text-transform: uppercase; }
.sev-info { background: var(--info); }
.sev-warn { background: var(--warn); }
.sev-crit { background: var(--crit); }
details { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .8rem; }
details pre { max-height: 360px; overflow: auto; font-size: .8rem; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
</style>
</head>
<body>
<div class="status-banner {{.OverallClass}}">
<div>
<div class="label">{{.OverallStatusText}}</div>
<div class="sub">for <code>{{.Owner}}</code>{{if .ReverseIP}} (<code>{{.ReverseIP}}</code>){{end}}</div>
</div>
<div class="sub">
{{if .ObservedTargets}}observed PTR: <code>{{index .ObservedTargets 0}}</code>{{else if eq .OverallStatus "crit"}}no PTR served{{end}}
</div>
</div>
{{if and .ReverseIP .ObservedTargets}}
<div class="roundtrip {{if .ForwardMatch}}match{{else}}miss{{end}}">
<span class="step"><code>{{.ReverseIP}}</code></span>
<span class="arrow"> PTR </span>
<span class="step"><code>{{index .ObservedTargets 0}}</code></span>
<span class="arrow"> A/AAAA </span>
<span class="step">
{{if .ForwardAddresses}}
{{range $i, $a := .ForwardAddresses}}{{if $i}}, {{end}}<code>{{$a.Address}}</code>{{end}}
{{else}}
<span class="muted">unresolved</span>
{{end}}
</span>
<span class="arrow">·</span>
{{if .ForwardMatch}}<span class="badge on">FCrDNS match</span>{{else}}<span class="badge off">FCrDNS mismatch</span>{{end}}
</div>
{{end}}
<div class="grid">
<div class="card"><div class="k">Reverse name</div><div class="v">{{.Owner}}</div></div>
<div class="card"><div class="k">Decoded IP</div><div class="v">{{if .ReverseIP}}{{.ReverseIP}}{{else}}<span class="muted"></span>{{end}}</div></div>
<div class="card"><div class="k">Reverse zone</div><div class="v">{{if .ReverseZone}}{{.ReverseZone}}{{else}}<span class="muted"></span>{{end}}</div></div>
<div class="card"><div class="k">Declared PTR target</div><div class="v">{{if .DeclaredTarget}}{{.DeclaredTarget}}{{else}}<span class="muted"></span>{{end}}</div></div>
<div class="card"><div class="k">Observed PTR target(s)</div>
<div class="v">{{if .ObservedTargets}}{{range .ObservedTargets}}{{.}}<br>{{end}}{{else}}<span class="muted">none</span>{{end}}</div>
</div>
<div class="card"><div class="k">Observed TTL</div><div class="v">{{if .ObservedTTL}}{{.ObservedTTL}}s{{else}}<span class="muted"></span>{{end}}</div></div>
<div class="card"><div class="k">Rcode</div><div class="v">{{if .Rcode}}{{.Rcode}}{{else}}<span class="muted"></span>{{end}}</div></div>
<div class="card"><div class="k">FCrDNS</div>
<div class="v">
{{if .ForwardMatch}}<span class="badge on">match</span>
{{else if .TargetResolves}}<span class="badge off">mismatch</span>
{{else}}<span class="badge off">target unresolved</span>{{end}}
</div>
</div>
</div>
{{if .TopFailures}}
<h2>Fix these first</h2>
{{range .TopFailures}}
<div class="top-failure severity-{{.Severity}}">
<h3>{{.Title}} <span class="sev sev-{{.Severity}}">{{.Severity}}</span></h3>
<ul>
{{range .Messages}}<li>{{.}}</li>{{end}}
</ul>
{{if .Hint}}<div class="fix"><strong>How to fix</strong>{{.Hint}}</div>{{end}}
</div>
{{end}}
{{end}}
{{if .ForwardAddresses}}
<h2>Forward resolution of the PTR target</h2>
<table>
<thead><tr><th>Type</th><th>Address</th><th>TTL</th></tr></thead>
<tbody>
{{range .ForwardAddresses}}
<tr>
<td><code>{{.Type}}</code></td>
<td><code>{{.Address}}</code></td>
<td>{{if .TTL}}{{.TTL}}s{{else}}<span class="muted"></span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if .ReverseNS}}
<h2>Reverse zone name servers</h2>
<table>
<thead><tr><th>Server</th></tr></thead>
<tbody>
{{range .ReverseNS}}<tr><td><code>{{.}}</code></td></tr>{{end}}
</tbody>
</table>
{{end}}
{{if .OtherFindings}}
<h2>Additional 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="sev sev-{{.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>`

85
checker/rule.go Normal file
View file

@ -0,0 +1,85 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules returns the full list of CheckRules exposed by the PTR checker.
// Each rule covers one concern so callers can see at a glance which checks
// passed and which did not.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&reverseArpaRule{},
&ownerDecodeRule{},
&reverseZoneRule{},
&queryOutcomeRule{},
&ptrPresentRule{},
&singlePTRRule{},
&declaredMatchRule{},
&targetSyntaxRule{},
&genericHostnameRule{},
&targetResolvesRule{},
&fcrdnsMatchRule{},
&ipv6PTRRule{},
&ttlHygieneRule{},
}
}
// loadPTR fetches the raw observation. On error, the returned CheckState is
// what the caller should emit to short-circuit.
func loadPTR(ctx context.Context, obs sdk.ObservationGetter) (*PTRData, *sdk.CheckState) {
var data PTRData
if err := obs.Get(ctx, ObservationKeyPTR, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Code: "ptr.observation_error",
Message: fmt.Sprintf("failed to get PTR 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 withHint(st sdk.CheckState, hint string) sdk.CheckState {
if hint != "" {
st.Meta = map[string]any{"hint": hint}
}
return st
}

388
checker/rules.go Normal file
View file

@ -0,0 +1,388 @@
package checker
import (
"context"
"fmt"
"slices"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ---------- structural ----------
type reverseArpaRule struct{}
func (reverseArpaRule) Name() string { return "ptr.in_reverse_arpa" }
func (reverseArpaRule) Description() string {
return "Verifies the PTR owner lies under in-addr.arpa or ip6.arpa."
}
func (reverseArpaRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !data.InReverseArpa {
return []sdk.CheckState{critState(
"ptr_not_in_reverse_zone",
fmt.Sprintf("PTR owner %s is not under in-addr.arpa or ip6.arpa", data.OwnerName),
data.OwnerName,
"Move the PTR record into the appropriate reverse zone served by the IP owner (your ISP or LIR). PTR outside *.arpa is not usable for reverse DNS.",
)}
}
return []sdk.CheckState{passState("ptr.in_reverse_arpa.ok", "Owner is in a reverse (*.arpa) zone.", data.OwnerName)}
}
type ownerDecodeRule struct{}
func (ownerDecodeRule) Name() string { return "ptr.owner_decodable" }
func (ownerDecodeRule) Description() string {
return "Verifies the reverse-arpa owner name decodes back to an IP address."
}
func (ownerDecodeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !data.InReverseArpa {
return []sdk.CheckState{skipState("ptr.owner_decodable.skipped", "Owner is not in *.arpa; decoding does not apply.")}
}
if data.OwnerDecodeFailed {
return []sdk.CheckState{critState(
"ptr_owner_malformed",
fmt.Sprintf("cannot decode an IP from PTR owner %s", data.OwnerName),
data.OwnerName,
"Reverse names must use 4 numeric labels for IPv4 or 32 hexadecimal nibbles for IPv6.",
)}
}
return []sdk.CheckState{passState("ptr.owner_decodable.ok", fmt.Sprintf("Owner decodes to %s.", data.ReverseIP), data.OwnerName)}
}
// ---------- zone location & query ----------
type reverseZoneRule struct{}
func (reverseZoneRule) Name() string { return "ptr.reverse_zone_located" }
func (reverseZoneRule) Description() string {
return "Verifies the reverse zone serving the PTR owner can be located (SOA found)."
}
func (reverseZoneRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.ZoneLookupError != "" || data.ReverseZone == "" {
msg := fmt.Sprintf("could not locate the reverse zone of %s", data.OwnerName)
if data.ZoneLookupError != "" {
msg = fmt.Sprintf("%s: %s", msg, data.ZoneLookupError)
}
return []sdk.CheckState{critState(
"ptr_no_reverse_zone",
msg,
data.OwnerName,
"The reverse zone is usually delegated by your IP provider. Make sure the parent delegation exists and publishes an SOA.",
)}
}
return []sdk.CheckState{passState("ptr.reverse_zone_located.ok", fmt.Sprintf("Reverse zone is %s.", data.ReverseZone), data.OwnerName)}
}
type queryOutcomeRule struct{}
func (queryOutcomeRule) Name() string { return "ptr.query_succeeded" }
func (queryOutcomeRule) Description() string {
return "Verifies the PTR query returns NOERROR from the authoritative servers."
}
func (queryOutcomeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.QueryError != "" {
return []sdk.CheckState{critState(
"ptr_query_failed",
fmt.Sprintf("PTR query for %s failed: %s", data.OwnerName, data.QueryError),
data.OwnerName,
"Check that the reverse zone's name servers are reachable and that you can query them over UDP/53.",
)}
}
if data.Rcode != "" && data.Rcode != "NOERROR" {
return []sdk.CheckState{critState(
"ptr_rcode",
fmt.Sprintf("authoritative server answered %s for %s", data.Rcode, data.OwnerName),
data.OwnerName,
"NXDOMAIN almost always means the PTR record was never published at the reverse zone: your provider may not have delegated the sub-zone, or the record is missing.",
)}
}
return []sdk.CheckState{passState("ptr.query_succeeded.ok", "PTR query returned NOERROR.", data.OwnerName)}
}
// ---------- record content ----------
type ptrPresentRule struct{}
func (ptrPresentRule) Name() string { return "ptr.record_present" }
func (ptrPresentRule) Description() string {
return "Verifies at least one PTR record is served at the owner name."
}
func (ptrPresentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.QueryError != "" {
return []sdk.CheckState{skipState("ptr.record_present.skipped", "PTR query did not complete.")}
}
if len(data.ObservedTargets) == 0 {
return []sdk.CheckState{critState(
"ptr_missing",
fmt.Sprintf("no PTR record found at %s", data.OwnerName),
data.OwnerName,
"Add a PTR record at the reverse zone. Without it, mail servers will reject your IP and many SSH/VPN setups will refuse connections.",
)}
}
return []sdk.CheckState{passState("ptr.record_present.ok", fmt.Sprintf("PTR found: %s.", strings.Join(data.ObservedTargets, ", ")), data.OwnerName)}
}
type singlePTRRule struct{}
func (singlePTRRule) Name() string { return "ptr.single_record" }
func (singlePTRRule) Description() string {
return "Flags multiple PTR records on the same IP (RFC 1912 §2.1 recommends exactly one)."
}
func (singlePTRRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
allowMultiple := sdk.GetBoolOption(opts, "allowMultiplePTR", false)
if allowMultiple {
return []sdk.CheckState{skipState("ptr.single_record.skipped", "Multiple PTRs are explicitly allowed by configuration.")}
}
if len(data.ObservedTargets) == 0 {
return []sdk.CheckState{skipState("ptr.single_record.skipped", "No PTR record observed.")}
}
if len(data.ObservedTargets) > 1 {
return []sdk.CheckState{warnState(
"ptr_multiple",
fmt.Sprintf("%d PTR records at %s (%s)", len(data.ObservedTargets), data.OwnerName, strings.Join(data.ObservedTargets, ", ")),
data.OwnerName,
"RFC 1912 §2.1 recommends a single PTR per IP. Multiple PTRs confuse reverse-lookup consumers (mail filters, logs): keep exactly one canonical hostname.",
)}
}
return []sdk.CheckState{passState("ptr.single_record.ok", "Exactly one PTR record is published.", data.OwnerName)}
}
type declaredMatchRule struct{}
func (declaredMatchRule) Name() string { return "ptr.declared_match" }
func (declaredMatchRule) Description() string {
return "Verifies the PTR target served by the authoritative servers matches the declared target."
}
func (declaredMatchRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.DeclaredTarget == "" {
return []sdk.CheckState{skipState("ptr.declared_match.skipped", "No declared PTR target to compare against.")}
}
if len(data.ObservedTargets) == 0 {
return []sdk.CheckState{skipState("ptr.declared_match.skipped", "No PTR record observed.")}
}
if slices.Contains(data.ObservedTargets, data.DeclaredTarget) {
return []sdk.CheckState{passState("ptr.declared_match.ok", "Authoritative PTR matches the declared target.", data.OwnerName)}
}
return []sdk.CheckState{critState(
"ptr_declared_mismatch",
fmt.Sprintf("declared PTR target %s not served; authoritative answer: %s", data.DeclaredTarget, strings.Join(data.ObservedTargets, ", ")),
data.OwnerName,
"The zone served by the authoritative servers disagrees with what happyDomain has for this record: push the current version of the zone, or refresh the imported state.",
)}
}
// ---------- target hygiene ----------
type targetSyntaxRule struct{}
func (targetSyntaxRule) Name() string { return "ptr.target_syntax_valid" }
func (targetSyntaxRule) Description() string {
return "Verifies the PTR target is a syntactically valid hostname (RFC 952/1123)."
}
func (targetSyntaxRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.EffectiveTarget == "" {
return []sdk.CheckState{skipState("ptr.target_syntax_valid.skipped", "No PTR target available.")}
}
if !data.TargetSyntaxValid {
return []sdk.CheckState{critState(
"ptr_target_invalid",
fmt.Sprintf("PTR target %q is not a valid hostname", data.EffectiveTarget),
data.OwnerName,
"PTR targets must be syntactically valid domain names (RFC 952/1123 letters, digits, hyphens; labels 1-63 chars).",
)}
}
return []sdk.CheckState{passState("ptr.target_syntax_valid.ok", "PTR target is a valid hostname.", data.EffectiveTarget)}
}
type genericHostnameRule struct{}
func (genericHostnameRule) Name() string { return "ptr.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 := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !sdk.GetBoolOption(opts, "flagGenericPTR", true) {
return []sdk.CheckState{skipState("ptr.generic_hostname.skipped", "Generic-hostname check disabled by configuration.")}
}
if data.EffectiveTarget == "" || data.ReverseIP == "" {
return []sdk.CheckState{skipState("ptr.generic_hostname.skipped", "No PTR target or reverse IP available.")}
}
if data.TargetLooksGeneric {
return []sdk.CheckState{warnState(
"ptr_generic_hostname",
fmt.Sprintf("PTR target %s looks auto-generated (contains the IP or a typical ISP pattern)", data.EffectiveTarget),
data.OwnerName,
"Mail servers and anti-spam filters penalise generic PTRs (those embedding the IP, or using pool/dynamic/dsl-style labels). Prefer a stable, service-specific hostname.",
)}
}
return []sdk.CheckState{passState("ptr.generic_hostname.ok", "PTR target does not look auto-generated.", data.EffectiveTarget)}
}
// ---------- FCrDNS ----------
type targetResolvesRule struct{}
func (targetResolvesRule) Name() string { return "ptr.target_resolves" }
func (targetResolvesRule) Description() string {
return "Verifies the 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 := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.EffectiveTarget == "" || data.ReverseIP == "" {
return []sdk.CheckState{skipState("ptr.target_resolves.skipped", "No PTR target or reverse IP available.")}
}
if data.TargetResolves {
return []sdk.CheckState{passState("ptr.target_resolves.ok", "PTR target resolves in the forward DNS.", data.EffectiveTarget)}
}
st := critState(
"ptr_target_unresolvable",
fmt.Sprintf("PTR target %s does not resolve to any A/AAAA record", data.EffectiveTarget),
data.EffectiveTarget,
"The hostname in the PTR must exist in the forward DNS. Publish an A and/or AAAA record matching the IP at that name; this is the canonical Forward-Confirmed Reverse DNS (FCrDNS) contract expected by mail servers.",
)
if !sdk.GetBoolOption(opts, "requireForwardMatch", true) {
st.Status = sdk.StatusWarn
}
return []sdk.CheckState{st}
}
type fcrdnsMatchRule struct{}
func (fcrdnsMatchRule) Name() string { return "ptr.fcrdns_match" }
func (fcrdnsMatchRule) Description() string {
return "Verifies the PTR target's A/AAAA resolves back to the original IP (Forward-Confirmed Reverse DNS)."
}
func (fcrdnsMatchRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.EffectiveTarget == "" || data.ReverseIP == "" {
return []sdk.CheckState{skipState("ptr.fcrdns_match.skipped", "No PTR target or reverse IP available.")}
}
if !data.TargetResolves {
return []sdk.CheckState{skipState("ptr.fcrdns_match.skipped", "PTR target does not resolve; FCrDNS comparison skipped.")}
}
if data.ForwardMatch {
return []sdk.CheckState{passState("ptr.fcrdns_match.ok", fmt.Sprintf("%s → %s → %s (FCrDNS confirmed)", data.ReverseIP, data.EffectiveTarget, data.ReverseIP), data.OwnerName)}
}
addrStrs := make([]string, len(data.ForwardAddresses))
for i, a := range data.ForwardAddresses {
addrStrs[i] = a.Address
}
st := critState(
"ptr_forward_mismatch",
fmt.Sprintf("PTR target %s resolves to %s, which does not include %s (FCrDNS check failed)", data.EffectiveTarget, strings.Join(addrStrs, ", "), data.ReverseIP),
data.OwnerName,
"Add the original IP to the A/AAAA RRset of the PTR target, or change the PTR to point at a hostname whose A/AAAA already includes this IP. Mail servers reject connections when the PTR does not round-trip back.",
)
if !sdk.GetBoolOption(opts, "requireForwardMatch", true) {
st.Status = sdk.StatusWarn
}
return []sdk.CheckState{st}
}
// ---------- IPv6 ----------
type ipv6PTRRule struct{}
func (ipv6PTRRule) Name() string { return "ptr.ipv6" }
func (ipv6PTRRule) Description() string {
return "Reports whether the PTR concerns an IPv6 (ip6.arpa) address."
}
func (ipv6PTRRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !data.IsIPv6 {
return []sdk.CheckState{skipState("ptr.ipv6.skipped", "Owner is not an ip6.arpa name.")}
}
if len(data.ObservedTargets) == 0 {
return []sdk.CheckState{critState(
"ptr_ipv6_missing",
fmt.Sprintf("no PTR record found for IPv6 address %s", data.ReverseIP),
data.OwnerName,
"IPv6 reverse DNS is just as important as IPv4 for mail delivery. Publish a PTR at the ip6.arpa name.",
)}
}
return []sdk.CheckState{passState("ptr.ipv6.ok", fmt.Sprintf("IPv6 PTR present for %s.", data.ReverseIP), data.OwnerName)}
}
// ---------- TTL hygiene ----------
type ttlHygieneRule struct{}
func (ttlHygieneRule) Name() string { return "ptr.ttl_hygiene" }
func (ttlHygieneRule) Description() string {
return "Verifies the PTR TTL is at or above the configured minimum."
}
func (ttlHygieneRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadPTR(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
minTTL := uint32(sdk.GetIntOption(opts, "minTTL", 300))
var out []sdk.CheckState
if data.ObservedTTL > 0 && data.ObservedTTL < minTTL {
out = append(out, warnState(
"ptr_low_ttl",
fmt.Sprintf("PTR TTL is %ds (< %d)", data.ObservedTTL, minTTL),
data.OwnerName,
"Raise the PTR TTL. Reverse lookups are cache-heavy on the consumer side (mail, SSH) and frequent changes rarely help.",
))
}
if data.DeclaredTTL > 0 && data.DeclaredTTL < minTTL {
out = append(out, sdk.CheckState{
Status: sdk.StatusInfo,
Code: "ptr_declared_low_ttl",
Message: fmt.Sprintf("declared PTR TTL is %ds (< %d)", data.DeclaredTTL, minTTL),
Subject: data.OwnerName,
})
}
if len(out) == 0 {
return []sdk.CheckState{passState("ptr.ttl_hygiene.ok", "PTR TTL is at or above the minimum.", data.OwnerName)}
}
return out
}

115
checker/types.go Normal file
View file

@ -0,0 +1,115 @@
package checker
import (
"encoding/json"
"github.com/miekg/dns"
)
// ObservationKeyPTR is the observation key for the PTR checker payload.
const ObservationKeyPTR = "ptr"
// ForwardAddress records a single A/AAAA answer collected for the PTR target.
type ForwardAddress struct {
Type string `json:"type"` // "A" or "AAAA"
Address string `json:"address"`
TTL uint32 `json:"ttl,omitempty"`
}
// PTRData is the raw observation payload persisted by the checker. It
// contains NO judgement: severity, pass/fail and derived issues are the
// responsibility of CheckRule implementations.
type PTRData struct {
// OwnerName is the FQDN of the PTR record as declared by the service
// (the reverse-arpa name, e.g. "4.3.2.1.in-addr.arpa.").
OwnerName string `json:"owner_name"`
// DeclaredTarget is the hostname the service says the PTR should point
// to. Always fully-qualified and lowercased.
DeclaredTarget string `json:"declared_target"`
// DeclaredTTL is the TTL declared by the service.
DeclaredTTL uint32 `json:"declared_ttl,omitempty"`
// InReverseArpa reports whether OwnerName lies under in-addr.arpa or
// ip6.arpa.
InReverseArpa bool `json:"in_reverse_arpa"`
// IsIPv6 reports whether OwnerName is an ip6.arpa name.
IsIPv6 bool `json:"is_ipv6"`
// ReverseIP is the IP address reconstructed from OwnerName (if parseable).
ReverseIP string `json:"reverse_ip,omitempty"`
// OwnerDecodeFailed is true when OwnerName lies under *.arpa but no IP
// could be decoded from it (malformed labels).
OwnerDecodeFailed bool `json:"owner_decode_failed,omitempty"`
// ReverseZone is the apex of the reverse zone serving OwnerName (where
// the SOA lives). Empty when it could not be located.
ReverseZone string `json:"reverse_zone,omitempty"`
// ReverseNS are the authoritative servers of the reverse zone.
ReverseNS []string `json:"reverse_ns,omitempty"`
// ZoneLookupError captures the transport/NXDOMAIN-style failure
// encountered while walking up to find the SOA. Empty on success.
ZoneLookupError string `json:"zone_lookup_error,omitempty"`
// ObservedTargets lists every PTR target observed at OwnerName. In a
// healthy setup, this has exactly one entry equal to DeclaredTarget.
ObservedTargets []string `json:"observed_targets,omitempty"`
// ObservedTTL is the TTL of the PTR RRset as seen from authoritative
// servers.
ObservedTTL uint32 `json:"observed_ttl,omitempty"`
// QueryError captures a transport-level failure while querying the PTR
// RRset (unreachable servers, timeouts, …). Empty on success.
QueryError string `json:"query_error,omitempty"`
// Rcode is the textual rcode of the PTR lookup (e.g. "NOERROR",
// "NXDOMAIN", "SERVFAIL"); empty when not applicable.
Rcode string `json:"rcode,omitempty"`
// EffectiveTarget is the hostname actually examined for hygiene and
// FCrDNS (the first observed target, or the declared one when none is
// observed). Empty when neither is available.
EffectiveTarget string `json:"effective_target,omitempty"`
// TargetSyntaxValid reports whether EffectiveTarget parses as a valid
// DNS hostname. False when EffectiveTarget is empty or malformed.
TargetSyntaxValid bool `json:"target_syntax_valid,omitempty"`
// TargetLooksGeneric reports whether EffectiveTarget embeds the IP or
// matches common ISP auto-generated patterns.
TargetLooksGeneric bool `json:"target_looks_generic,omitempty"`
// ForwardAddresses are the A/AAAA addresses the target resolves to
// (recursive resolution from the system resolver).
ForwardAddresses []ForwardAddress `json:"forward_addresses,omitempty"`
// ForwardMatch is true when ReverseIP appears among ForwardAddresses
// (Forward-Confirmed Reverse DNS).
ForwardMatch bool `json:"forward_match,omitempty"`
// TargetResolves is true when the PTR target produced at least one A or
// AAAA. Distinct from ForwardMatch: a target can resolve yet point
// somewhere else.
TargetResolves bool `json:"target_resolves,omitempty"`
}
// ptrService is the minimal local mirror of happyDomain's `svcs.PTR`. It
// carries a single *dns.PTR. The JSON key matches the Go struct field name
// used in happyDomain (`Record`).
type ptrService struct {
Record *dns.PTR `json:"Record"`
}
// serviceMessage is the minimal local mirror of happyDomain's ServiceMessage
// envelope.
type serviceMessage struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}

16
go.mod Normal file
View file

@ -0,0 +1,16 @@
module git.happydns.org/checker-ptr
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.5.0
github.com/miekg/dns v1.1.72
)
require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)

16
go.sum Normal file
View file

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

30
main.go Normal file
View file

@ -0,0 +1,30 @@
package main
import (
"flag"
"log"
ptr "git.happydns.org/checker-ptr/checker"
"git.happydns.org/checker-sdk-go/checker/server"
)
var (
listenAddr = flag.String("listen", ":8080", "HTTP listen address")
fallbackResolver = flag.String("fallback-resolver", "1.1.1.1:53", "Resolver used when /etc/resolv.conf is missing or empty (host:port)")
)
var Version = "custom-build"
func main() {
flag.Parse()
ptr.Version = Version
if *fallbackResolver != "" {
ptr.FallbackResolver = *fallbackResolver
}
srv := server.New(ptr.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

14
plugin/plugin.go Normal file
View file

@ -0,0 +1,14 @@
package main
import (
ptr "git.happydns.org/checker-ptr/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
ptr.Version = Version
prvd := ptr.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
}