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