From 67c955129df776c39db12438167430ac733a04e8 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 26 Apr 2026 11:27:12 +0700 Subject: [PATCH] Initial commit --- .gitignore | 2 + Dockerfile | 17 ++ LICENSE | 21 ++ Makefile | 28 +++ README.md | 96 +++++++++ checker/collect.go | 278 +++++++++++++++++++++++++ checker/collect_test.go | 160 +++++++++++++++ checker/definition.go | 87 ++++++++ checker/dns.go | 207 +++++++++++++++++++ checker/dns_test.go | 111 ++++++++++ checker/interactive.go | 73 +++++++ checker/provider.go | 16 ++ checker/report.go | 443 ++++++++++++++++++++++++++++++++++++++++ checker/rule.go | 85 ++++++++ checker/rules.go | 388 +++++++++++++++++++++++++++++++++++ checker/types.go | 115 +++++++++++ go.mod | 16 ++ go.sum | 16 ++ main.go | 30 +++ plugin/plugin.go | 14 ++ 20 files changed, 2203 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 checker/collect.go create mode 100644 checker/collect_test.go create mode 100644 checker/definition.go create mode 100644 checker/dns.go create mode 100644 checker/dns_test.go create mode 100644 checker/interactive.go create mode 100644 checker/provider.go create mode 100644 checker/report.go create mode 100644 checker/rule.go create mode 100644 checker/rules.go create mode 100644 checker/types.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91ec450 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-ptr +checker-ptr.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..99c8588 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The happyDomain Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e93a768 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3211f18 --- /dev/null +++ b/README.md @@ -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`). diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..361bec2 --- /dev/null +++ b/checker/collect.go @@ -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) +} diff --git a/checker/collect_test.go b/checker/collect_test.go new file mode 100644 index 0000000..5698b52 --- /dev/null +++ b/checker/collect_test.go @@ -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") + } +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..6e3ff78 --- /dev/null +++ b/checker/definition.go @@ -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 +} diff --git a/checker/dns.go b/checker/dns.go new file mode 100644 index 0000000..08a3b8c --- /dev/null +++ b/checker/dns.go @@ -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.") +} diff --git a/checker/dns_test.go b/checker/dns_test.go new file mode 100644 index 0000000..c0cf959 --- /dev/null +++ b/checker/dns_test.go @@ -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) + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..9e68493 --- /dev/null +++ b/checker/interactive.go @@ -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 +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..3e163cd --- /dev/null +++ b/checker/provider.go @@ -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 +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..37835db --- /dev/null +++ b/checker/report.go @@ -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 = ` + + + +PTR / reverse DNS report — {{.Owner}} + + + +
+
+
{{.OverallStatusText}}
+
for {{.Owner}}{{if .ReverseIP}} ({{.ReverseIP}}){{end}}
+
+
+ {{if .ObservedTargets}}observed PTR: {{index .ObservedTargets 0}}{{else if eq .OverallStatus "crit"}}no PTR served{{end}} +
+
+ + {{if and .ReverseIP .ObservedTargets}} +
+ {{.ReverseIP}} + — PTR → + {{index .ObservedTargets 0}} + — A/AAAA → + + {{if .ForwardAddresses}} + {{range $i, $a := .ForwardAddresses}}{{if $i}}, {{end}}{{$a.Address}}{{end}} + {{else}} + unresolved + {{end}} + + · + {{if .ForwardMatch}}FCrDNS match{{else}}FCrDNS mismatch{{end}} +
+ {{end}} + +
+
Reverse name
{{.Owner}}
+
Decoded IP
{{if .ReverseIP}}{{.ReverseIP}}{{else}}{{end}}
+
Reverse zone
{{if .ReverseZone}}{{.ReverseZone}}{{else}}{{end}}
+
Declared PTR target
{{if .DeclaredTarget}}{{.DeclaredTarget}}{{else}}{{end}}
+
Observed PTR target(s)
+
{{if .ObservedTargets}}{{range .ObservedTargets}}{{.}}
{{end}}{{else}}none{{end}}
+
+
Observed TTL
{{if .ObservedTTL}}{{.ObservedTTL}}s{{else}}{{end}}
+
Rcode
{{if .Rcode}}{{.Rcode}}{{else}}{{end}}
+
FCrDNS
+
+ {{if .ForwardMatch}}match + {{else if .TargetResolves}}mismatch + {{else}}target unresolved{{end}} +
+
+
+ + {{if .TopFailures}} +

Fix these first

+ {{range .TopFailures}} +
+

{{.Title}} {{.Severity}}

+ + {{if .Hint}}
How to fix{{.Hint}}
{{end}} +
+ {{end}} + {{end}} + + {{if .ForwardAddresses}} +

Forward resolution of the PTR target

+ + + + {{range .ForwardAddresses}} + + + + + + {{end}} + +
TypeAddressTTL
{{.Type}}{{.Address}}{{if .TTL}}{{.TTL}}s{{else}}{{end}}
+ {{end}} + + {{if .ReverseNS}} +

Reverse zone name servers

+ + + + {{range .ReverseNS}}{{end}} + +
Server
{{.}}
+ {{end}} + + {{if .OtherFindings}} +

Additional findings

+ + + + {{range .OtherFindings}} + + + + + + + {{end}} + +
SeverityCodeSubjectMessage
{{.Severity}}{{.Code}}{{.Subject}}{{.Message}}{{if .Hint}}
{{.Hint}}{{end}}
+ {{end}} + + +` diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..11ba5d4 --- /dev/null +++ b/checker/rule.go @@ -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 +} diff --git a/checker/rules.go b/checker/rules.go new file mode 100644 index 0000000..6def648 --- /dev/null +++ b/checker/rules.go @@ -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 +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..5c441e0 --- /dev/null +++ b/checker/types.go @@ -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"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5ed1a7c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2a80023 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= +git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= diff --git a/main.go b/main.go new file mode 100644 index 0000000..010ef6d --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..be2a1ac --- /dev/null +++ b/plugin/plugin.go @@ -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 +}