Initial commit
This commit is contained in:
commit
67c955129d
20 changed files with 2203 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-ptr
|
||||
checker-ptr.so
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG CHECKER_VERSION=custom-build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-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
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
28
Makefile
Normal file
28
Makefile
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CHECKER_NAME := checker-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
96
README.md
Normal 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
278
checker/collect.go
Normal 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
160
checker/collect_test.go
Normal 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
87
checker/definition.go
Normal 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
207
checker/dns.go
Normal 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
111
checker/dns_test.go
Normal 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
73
checker/interactive.go
Normal 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
16
checker/provider.go
Normal 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
443
checker/report.go
Normal 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
85
checker/rule.go
Normal 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
388
checker/rules.go
Normal 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
115
checker/types.go
Normal 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
16
go.mod
Normal 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
16
go.sum
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
||||
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
30
main.go
Normal file
30
main.go
Normal 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
14
plugin/plugin.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue