Initial commit
This commit is contained in:
commit
b259d9ef18
17 changed files with 809 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/checker-ns-restrictions
|
||||||
|
/checker-ns-restrictions.so
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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 -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-ns-restrictions .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /checker-ns-restrictions /checker-ns-restrictions
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/checker-ns-restrictions"]
|
||||||
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.
|
||||||
25
Makefile
Normal file
25
Makefile
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
CHECKER_NAME := checker-ns-restrictions
|
||||||
|
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 clean
|
||||||
|
|
||||||
|
all: $(CHECKER_NAME)
|
||||||
|
|
||||||
|
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||||
|
go build -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) .
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||||
20
NOTICE
Normal file
20
NOTICE
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
checker-ns-restrictions
|
||||||
|
Copyright (c) 2020-2026 happyDomain
|
||||||
|
Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Third-party notices
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
This product includes software developed as part of the checker-sdk-go
|
||||||
|
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
|
||||||
|
under the Apache License, Version 2.0:
|
||||||
|
|
||||||
|
checker-sdk-go
|
||||||
|
Copyright 2020-2026 The happyDomain Authors
|
||||||
|
|
||||||
|
You may obtain a copy of the Apache License 2.0 at:
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
This product includes software developed as part of the miekg/dns project
|
||||||
|
(https://github.com/miekg/dns), licensed under the BSD 3-Clause License.
|
||||||
68
README.md
Normal file
68
README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# checker-ns-restrictions
|
||||||
|
|
||||||
|
Authoritative nameserver security restrictions checker for [happyDomain](https://www.happydomain.org/).
|
||||||
|
|
||||||
|
For each nameserver of an `abstract.Origin` or `abstract.NSOnlyOrigin`
|
||||||
|
service, this checker verifies common security misconfigurations:
|
||||||
|
|
||||||
|
| Check | Severity on failure |
|
||||||
|
|--------------------------------|---------------------|
|
||||||
|
| AXFR zone transfer refused | CRITICAL |
|
||||||
|
| IXFR zone transfer refused | WARNING |
|
||||||
|
| Recursion not available (RA) | WARNING |
|
||||||
|
| ANY query handling (RFC 8482) | WARNING |
|
||||||
|
| Authoritative answer (AA bit) | INFO |
|
||||||
|
|
||||||
|
The checker resolves each NS host, then runs the five DNS probes against
|
||||||
|
every returned IPv4/IPv6 address. IPv6 targets are skipped gracefully if
|
||||||
|
the host has no IPv6 connectivity.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Standalone HTTP server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make
|
||||||
|
./checker-ns-restrictions -listen :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
The server exposes the standard happyDomain external checker protocol
|
||||||
|
(`/health`, `/collect`, `/evaluate`, `/definition`).
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker
|
||||||
|
docker run -p 8080:8080 happydomain/checker-ns-restrictions
|
||||||
|
```
|
||||||
|
|
||||||
|
### happyDomain plugin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make plugin
|
||||||
|
# produces checker-ns-restrictions.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
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project does **not** depend on the happyDomain core repository: the
|
||||||
|
few host types it needs (`ServiceMessage`, `abstract.Origin`,
|
||||||
|
`abstract.NSOnlyOrigin`) are mirrored as minimal local copies of their
|
||||||
|
JSON wire shapes. It only depends on
|
||||||
|
[`checker-sdk-go`](https://git.happydns.org/checker-sdk-go) (Apache 2.0)
|
||||||
|
and [`miekg/dns`](https://github.com/miekg/dns) (BSD 3-Clause).
|
||||||
46
checker/abstract.go
Normal file
46
checker/abstract.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service type identifiers as exposed by happyDomain core.
|
||||||
|
const (
|
||||||
|
serviceTypeOrigin = "abstract.Origin"
|
||||||
|
serviceTypeNSOnlyOrigin = "abstract.NSOnlyOrigin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// originPayload is a minimal local copy of services/abstract.Origin keeping
|
||||||
|
// only the field this checker reads. The JSON tag matches the upstream wire
|
||||||
|
// format ("ns").
|
||||||
|
type originPayload struct {
|
||||||
|
NameServers []*dns.NS `json:"ns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// nsOnlyOriginPayload is a minimal local copy of
|
||||||
|
// services/abstract.NSOnlyOrigin keeping only the field this checker reads.
|
||||||
|
type nsOnlyOriginPayload struct {
|
||||||
|
NameServers []*dns.NS `json:"ns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// nsFromService extracts the list of NS records from an Origin or
|
||||||
|
// NSOnlyOrigin service payload.
|
||||||
|
func nsFromService(svc *serviceMessage) []*dns.NS {
|
||||||
|
switch svc.Type {
|
||||||
|
case serviceTypeOrigin:
|
||||||
|
var o originPayload
|
||||||
|
if err := json.Unmarshal(svc.Service, &o); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return o.NameServers
|
||||||
|
case serviceTypeNSOnlyOrigin:
|
||||||
|
var o nsOnlyOriginPayload
|
||||||
|
if err := json.Unmarshal(svc.Service, &o); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return o.NameServers
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
164
checker/checks.go
Normal file
164
checker/checks.go
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkAXFR returns (ok bool, detail string).
|
||||||
|
// ok=false means the server accepted the zone transfer (CRITICAL).
|
||||||
|
func checkAXFR(ctx context.Context, domain, addr string) (bool, string) {
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
msg.SetAxfr(dns.Fqdn(domain))
|
||||||
|
|
||||||
|
t := &dns.Transfer{}
|
||||||
|
t.DialTimeout = 5 * time.Second
|
||||||
|
t.ReadTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
ch, err := t.In(msg, net.JoinHostPort(addr, "53"))
|
||||||
|
if err != nil {
|
||||||
|
return true, fmt.Sprintf("transfer refused: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for env := range ch {
|
||||||
|
if env.Error != nil {
|
||||||
|
return true, fmt.Sprintf("transfer error: %s", env.Error)
|
||||||
|
}
|
||||||
|
for _, rr := range env.RR {
|
||||||
|
if rr.Header().Rrtype == dns.TypeSOA {
|
||||||
|
return false, "AXFR zone transfer accepted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, "AXFR refused"
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkIXFR returns (ok bool, detail string).
|
||||||
|
// ok=false means the server answered with records (WARN).
|
||||||
|
func checkIXFR(ctx context.Context, domain, addr string) (bool, string) {
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
msg.SetIxfr(dns.Fqdn(domain), 0, "", "")
|
||||||
|
|
||||||
|
cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
|
||||||
|
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53"))
|
||||||
|
if err != nil {
|
||||||
|
return true, fmt.Sprintf("query failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Rcode != dns.RcodeSuccess {
|
||||||
|
return true, fmt.Sprintf("IXFR refused (rcode=%s)", dns.RcodeToString[resp.Rcode])
|
||||||
|
}
|
||||||
|
if len(resp.Answer) > 0 {
|
||||||
|
return false, fmt.Sprintf("IXFR accepted with %d answer(s)", len(resp.Answer))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, "IXFR refused or empty"
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNoRecursion returns (ok bool, detail string).
|
||||||
|
// ok=false means the server offers recursion (WARN).
|
||||||
|
func checkNoRecursion(ctx context.Context, domain, addr string) (bool, string) {
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
msg.SetQuestion(dns.Fqdn(domain), dns.TypeSOA)
|
||||||
|
msg.RecursionDesired = true
|
||||||
|
|
||||||
|
cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
|
||||||
|
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53"))
|
||||||
|
if err != nil {
|
||||||
|
return true, fmt.Sprintf("query failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.RecursionAvailable {
|
||||||
|
return false, "recursion available (RA bit set)"
|
||||||
|
}
|
||||||
|
return true, "recursion not available"
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkANYHandled returns (ok bool, detail string).
|
||||||
|
// ok=false means the server returned a full record set for ANY (WARN).
|
||||||
|
// Per RFC 8482, servers should return HINFO or a minimal response.
|
||||||
|
func checkANYHandled(ctx context.Context, domain, addr string) (bool, string) {
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
msg.SetQuestion(dns.Fqdn(domain), dns.TypeANY)
|
||||||
|
|
||||||
|
cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
|
||||||
|
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53"))
|
||||||
|
if err != nil {
|
||||||
|
return true, fmt.Sprintf("query failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Rcode != dns.RcodeSuccess {
|
||||||
|
return true, fmt.Sprintf("ANY refused (rcode=%s)", dns.RcodeToString[resp.Rcode])
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Answer) == 1 {
|
||||||
|
if _, ok := resp.Answer[0].(*dns.HINFO); ok {
|
||||||
|
return true, "RFC 8482 compliant HINFO response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Answer) == 0 {
|
||||||
|
return true, "ANY returned empty answer"
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, fmt.Sprintf("ANY returned %d records (not RFC 8482 compliant)", len(resp.Answer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkIsAuthoritative returns (ok bool, detail string).
|
||||||
|
// ok=false means the server is not authoritative for the zone (INFO).
|
||||||
|
func checkIsAuthoritative(ctx context.Context, domain, addr string) (bool, string) {
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
msg.SetQuestion(dns.Fqdn(domain), dns.TypeSOA)
|
||||||
|
|
||||||
|
cl := &dns.Client{Net: "udp", Timeout: 5 * time.Second}
|
||||||
|
resp, _, err := cl.ExchangeContext(ctx, msg, net.JoinHostPort(addr, "53"))
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Sprintf("query failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Authoritative {
|
||||||
|
return true, "server is authoritative (AA bit set)"
|
||||||
|
}
|
||||||
|
return false, "server is not authoritative (AA bit not set)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable check names. They are part of the JSON wire format of
|
||||||
|
// NSRestrictionsReport and used by individual rules to look up their
|
||||||
|
// corresponding entry, so they MUST NOT change without coordinating with
|
||||||
|
// the rule definitions.
|
||||||
|
const (
|
||||||
|
checkNameAXFR = "AXFR refused"
|
||||||
|
checkNameIXFR = "IXFR refused"
|
||||||
|
checkNameNoRecursion = "No recursion"
|
||||||
|
checkNameANYHandled = "ANY handled (RFC 8482)"
|
||||||
|
checkNameIsAuthoritative = "Is authoritative"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkServerAddr runs all NS security checks against a single IP address.
|
||||||
|
func checkServerAddr(ctx context.Context, domain, nsHost, addr string) NSServerResult {
|
||||||
|
result := NSServerResult{Name: nsHost, Address: addr}
|
||||||
|
|
||||||
|
type checkDef struct {
|
||||||
|
name string
|
||||||
|
fn func(context.Context, string, string) (bool, string)
|
||||||
|
}
|
||||||
|
checks := []checkDef{
|
||||||
|
{checkNameAXFR, checkAXFR},
|
||||||
|
{checkNameIXFR, checkIXFR},
|
||||||
|
{checkNameNoRecursion, checkNoRecursion},
|
||||||
|
{checkNameANYHandled, checkANYHandled},
|
||||||
|
{checkNameIsAuthoritative, checkIsAuthoritative},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ch := range checks {
|
||||||
|
ok, detail := ch.fn(ctx, domain, addr)
|
||||||
|
result.Checks = append(result.Checks, NSCheckItem{Name: ch.name, OK: ok, Detail: detail})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
117
checker/collect.go
Normal file
117
checker/collect.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect performs the NS security restriction checks for the configured
|
||||||
|
// service and returns an NSRestrictionsReport.
|
||||||
|
func (p *nsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||||
|
svc, err := serviceFromOptions(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if svc.Type != serviceTypeOrigin && svc.Type != serviceTypeNSOnlyOrigin {
|
||||||
|
return nil, fmt.Errorf("service is %s, expected %s or %s", svc.Type, serviceTypeOrigin, serviceTypeNSOnlyOrigin)
|
||||||
|
}
|
||||||
|
|
||||||
|
domainName := ""
|
||||||
|
if v, ok := opts["domainName"]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
domainName = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if domainName == "" {
|
||||||
|
domainName = svc.Domain
|
||||||
|
}
|
||||||
|
if domainName == "" {
|
||||||
|
return nil, fmt.Errorf("domain name not provided and not present in service")
|
||||||
|
}
|
||||||
|
|
||||||
|
nameServers := nsFromService(svc)
|
||||||
|
if len(nameServers) == 0 {
|
||||||
|
return nil, fmt.Errorf("no nameservers found in service")
|
||||||
|
}
|
||||||
|
|
||||||
|
report := &NSRestrictionsReport{}
|
||||||
|
for _, ns := range nameServers {
|
||||||
|
nsHost := strings.TrimSuffix(ns.Ns, ".")
|
||||||
|
results := checkNameServer(ctx, domainName, nsHost)
|
||||||
|
report.Servers = append(report.Servers, results...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceFromOptions extracts a *serviceMessage from the options. It accepts
|
||||||
|
// either a direct value (in-process plugin path) or a JSON-decoded
|
||||||
|
// map[string]any (HTTP path) — both are normalized via a JSON round-trip.
|
||||||
|
func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) {
|
||||||
|
v, ok := opts["service"]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("service not defined")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal service option: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var svc serviceMessage
|
||||||
|
if err := json.Unmarshal(raw, &svc); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode service option: %w", err)
|
||||||
|
}
|
||||||
|
return &svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNameServer resolves nsHost and runs checks on each address.
|
||||||
|
func checkNameServer(ctx context.Context, domain, nsHost string) []NSServerResult {
|
||||||
|
addrs, err := net.LookupHost(nsHost)
|
||||||
|
if err != nil {
|
||||||
|
return []NSServerResult{{
|
||||||
|
Name: nsHost,
|
||||||
|
Address: "",
|
||||||
|
Checks: []NSCheckItem{{
|
||||||
|
Name: "DNS resolution",
|
||||||
|
OK: false,
|
||||||
|
Detail: fmt.Sprintf("lookup failed: %s", err),
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []NSServerResult
|
||||||
|
for _, addr := range addrs {
|
||||||
|
// Skip IPv6 addresses when there is no IPv6 connectivity.
|
||||||
|
if ip := net.ParseIP(addr); ip != nil && ip.To4() == nil {
|
||||||
|
conn, err := net.DialTimeout("udp", net.JoinHostPort(addr, "53"), 3*time.Second)
|
||||||
|
if errors.Is(err, syscall.ENETUNREACH) {
|
||||||
|
results = append(results, NSServerResult{
|
||||||
|
Name: nsHost,
|
||||||
|
Address: addr,
|
||||||
|
Checks: []NSCheckItem{{
|
||||||
|
Name: "IPv6 connectivity",
|
||||||
|
OK: true,
|
||||||
|
Detail: "unable to test due to the lack of IPv6 connectivity",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if conn != nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, checkServerAddr(ctx, domain, nsHost, addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
51
checker/definition.go
Normal file
51
checker/definition.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the checker version reported in CheckerDefinition.Version.
|
||||||
|
//
|
||||||
|
// It defaults to "built-in", which is appropriate when the checker package is
|
||||||
|
// imported directly. Standalone binaries and plugin entrypoints override this
|
||||||
|
// from their own Version variable at the start of main(), which makes it easy
|
||||||
|
// for CI to inject a version with a single -ldflags "-X main.Version=..."
|
||||||
|
// flag instead of targeting the nested package path.
|
||||||
|
var Version = "built-in"
|
||||||
|
|
||||||
|
// Definition returns the CheckerDefinition for the NS security restrictions
|
||||||
|
// checker.
|
||||||
|
func Definition() *sdk.CheckerDefinition {
|
||||||
|
return &sdk.CheckerDefinition{
|
||||||
|
ID: "ns_restrictions",
|
||||||
|
Name: "NS Security Restrictions",
|
||||||
|
Version: Version,
|
||||||
|
Availability: sdk.CheckerAvailability{
|
||||||
|
ApplyToService: true,
|
||||||
|
LimitToServices: []string{serviceTypeOrigin, serviceTypeNSOnlyOrigin},
|
||||||
|
},
|
||||||
|
ObservationKeys: []sdk.ObservationKey{ObservationKeyNSRestrictions},
|
||||||
|
Options: sdk.CheckerOptionsDocumentation{
|
||||||
|
ServiceOpts: []sdk.CheckerOptionDocumentation{
|
||||||
|
{
|
||||||
|
Id: "service",
|
||||||
|
Label: "Service",
|
||||||
|
AutoFill: sdk.AutoFillService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "domainName",
|
||||||
|
Label: "Domain name",
|
||||||
|
AutoFill: sdk.AutoFillDomainName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Rules: Rules(),
|
||||||
|
Interval: &sdk.CheckIntervalSpec{
|
||||||
|
Min: 1 * time.Hour,
|
||||||
|
Max: 24 * time.Hour,
|
||||||
|
Default: 6 * time.Hour,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
21
checker/provider.go
Normal file
21
checker/provider.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider returns a new NS restrictions observation provider.
|
||||||
|
func Provider() sdk.ObservationProvider {
|
||||||
|
return &nsProvider{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type nsProvider struct{}
|
||||||
|
|
||||||
|
func (p *nsProvider) Key() sdk.ObservationKey {
|
||||||
|
return ObservationKeyNSRestrictions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definition implements sdk.CheckerDefinitionProvider.
|
||||||
|
func (p *nsProvider) Definition() *sdk.CheckerDefinition {
|
||||||
|
return Definition()
|
||||||
|
}
|
||||||
136
checker/rule.go
Normal file
136
checker/rule.go
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rules returns one rule per individual NS security check. Every rule
|
||||||
|
// reads the same shared observation produced by Collect and only looks
|
||||||
|
// at its own check entry, so a single network round trip feeds all rules.
|
||||||
|
func Rules() []sdk.CheckRule {
|
||||||
|
return []sdk.CheckRule{
|
||||||
|
&singleCheckRule{
|
||||||
|
ruleName: "ns_axfr_refused",
|
||||||
|
description: "Verifies that AXFR zone transfers are refused by every authoritative nameserver",
|
||||||
|
checkName: checkNameAXFR,
|
||||||
|
failStatus: sdk.StatusCrit,
|
||||||
|
code: "ns_axfr",
|
||||||
|
},
|
||||||
|
&singleCheckRule{
|
||||||
|
ruleName: "ns_ixfr_refused",
|
||||||
|
description: "Verifies that IXFR zone transfers are refused by every authoritative nameserver",
|
||||||
|
checkName: checkNameIXFR,
|
||||||
|
failStatus: sdk.StatusWarn,
|
||||||
|
code: "ns_ixfr",
|
||||||
|
},
|
||||||
|
&singleCheckRule{
|
||||||
|
ruleName: "ns_no_recursion",
|
||||||
|
description: "Verifies that authoritative nameservers do not advertise recursion (RA bit unset)",
|
||||||
|
checkName: checkNameNoRecursion,
|
||||||
|
failStatus: sdk.StatusWarn,
|
||||||
|
code: "ns_recursion",
|
||||||
|
},
|
||||||
|
&singleCheckRule{
|
||||||
|
ruleName: "ns_any_handled",
|
||||||
|
description: "Verifies that ANY queries are handled per RFC 8482 (HINFO or minimal answer)",
|
||||||
|
checkName: checkNameANYHandled,
|
||||||
|
failStatus: sdk.StatusWarn,
|
||||||
|
code: "ns_any",
|
||||||
|
},
|
||||||
|
&singleCheckRule{
|
||||||
|
ruleName: "ns_is_authoritative",
|
||||||
|
description: "Verifies that nameservers answer authoritatively (AA bit set) for the zone",
|
||||||
|
checkName: checkNameIsAuthoritative,
|
||||||
|
failStatus: sdk.StatusInfo,
|
||||||
|
code: "ns_authoritative",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// singleCheckRule evaluates one named check across all servers in the
|
||||||
|
// shared NSRestrictionsReport observation.
|
||||||
|
type singleCheckRule struct {
|
||||||
|
ruleName string
|
||||||
|
description string
|
||||||
|
checkName string
|
||||||
|
failStatus sdk.Status
|
||||||
|
code string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *singleCheckRule) Name() string { return r.ruleName }
|
||||||
|
func (r *singleCheckRule) Description() string { return r.description }
|
||||||
|
|
||||||
|
func (r *singleCheckRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState {
|
||||||
|
var report NSRestrictionsReport
|
||||||
|
if err := obs.Get(ctx, ObservationKeyNSRestrictions, &report); err != nil {
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Message: fmt.Sprintf("Failed to get NS restrictions data: %v", err),
|
||||||
|
Code: r.code + "_error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := sdk.StatusOK
|
||||||
|
var summaryParts []string
|
||||||
|
failingServers := make([]map[string]string, 0)
|
||||||
|
|
||||||
|
for _, srv := range report.Servers {
|
||||||
|
item, found := findCheck(srv.Checks, r.checkName)
|
||||||
|
if !found {
|
||||||
|
// The collect step did not run this check on this server
|
||||||
|
// (e.g. IPv6 unreachable, DNS resolution failure). Surface
|
||||||
|
// the reason from whichever entry the server does have.
|
||||||
|
if len(srv.Checks) > 0 {
|
||||||
|
summaryParts = append(summaryParts, fmt.Sprintf("%s: skipped (%s)", serverLabel(srv), srv.Checks[0].Detail))
|
||||||
|
} else {
|
||||||
|
summaryParts = append(summaryParts, fmt.Sprintf("%s: skipped", serverLabel(srv)))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.OK {
|
||||||
|
summaryParts = append(summaryParts, fmt.Sprintf("%s: OK", serverLabel(srv)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if status < r.failStatus {
|
||||||
|
status = r.failStatus
|
||||||
|
}
|
||||||
|
summaryParts = append(summaryParts, fmt.Sprintf("%s: FAIL (%s)", serverLabel(srv), item.Detail))
|
||||||
|
failingServers = append(failingServers, map[string]string{
|
||||||
|
"name": srv.Name,
|
||||||
|
"address": srv.Address,
|
||||||
|
"detail": item.Detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: status,
|
||||||
|
Message: strings.Join(summaryParts, " | "),
|
||||||
|
Code: r.code + "_result",
|
||||||
|
Meta: map[string]any{
|
||||||
|
"check": r.checkName,
|
||||||
|
"failing_servers": failingServers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCheck(items []NSCheckItem, name string) (NSCheckItem, bool) {
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Name == name {
|
||||||
|
return it, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NSCheckItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverLabel(srv NSServerResult) string {
|
||||||
|
if srv.Address == "" {
|
||||||
|
return srv.Name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s)", srv.Name, srv.Address)
|
||||||
|
}
|
||||||
35
checker/types.go
Normal file
35
checker/types.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// ObservationKeyNSRestrictions is the observation key for NS security
|
||||||
|
// restrictions data.
|
||||||
|
const ObservationKeyNSRestrictions = "ns_restrictions"
|
||||||
|
|
||||||
|
// NSRestrictionsReport contains the results of NS security restriction checks.
|
||||||
|
type NSRestrictionsReport struct {
|
||||||
|
Servers []NSServerResult `json:"servers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NSServerResult holds the check results for a single nameserver IP.
|
||||||
|
type NSServerResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Checks []NSCheckItem `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NSCheckItem represents one security check for an NS server.
|
||||||
|
type NSCheckItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceMessage is a minimal local copy of happydns.ServiceMessage matching
|
||||||
|
// the JSON wire shape, so this plugin does not depend on the happyDomain core
|
||||||
|
// repository.
|
||||||
|
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-ns-restrictions
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.happydns.org/checker-sdk-go v0.0.1
|
||||||
|
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 v0.0.1 h1:4RxCJr73HWKxjOyU/6NJMO8lXJmH0gMLA68EzTqLbQI=
|
||||||
|
git.happydns.org/checker-sdk-go v0.0.1/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"
|
||||||
|
|
||||||
|
nsr "git.happydns.org/checker-ns-restrictions/checker"
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the standalone binary's version. It defaults to "custom-build"
|
||||||
|
// and is meant to be overridden by the CI at link time:
|
||||||
|
//
|
||||||
|
// go build -ldflags "-X main.Version=1.2.3" .
|
||||||
|
var Version = "custom-build"
|
||||||
|
|
||||||
|
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Propagate the binary version to the checker package so it shows up in
|
||||||
|
// CheckerDefinition.Version.
|
||||||
|
nsr.Version = Version
|
||||||
|
|
||||||
|
server := sdk.NewServer(nsr.Provider())
|
||||||
|
if err := server.ListenAndServe(*listenAddr); err != nil {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
plugin/plugin.go
Normal file
27
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
// Command plugin is the happyDomain plugin entrypoint for the NS security
|
||||||
|
// restrictions checker.
|
||||||
|
//
|
||||||
|
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
|
||||||
|
// runtime by happyDomain.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
nsr "git.happydns.org/checker-ns-restrictions/checker"
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the plugin's version. It defaults to "custom-build" and is
|
||||||
|
// meant to be overridden by the CI at link time:
|
||||||
|
//
|
||||||
|
// go build -buildmode=plugin -ldflags "-X main.Version=1.2.3" -o checker-ns-restrictions.so ./plugin
|
||||||
|
var Version = "custom-build"
|
||||||
|
|
||||||
|
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
|
||||||
|
// .so file. It returns the checker definition and the observation provider
|
||||||
|
// that the host will register in its global registries.
|
||||||
|
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||||
|
// Propagate the plugin's version to the checker package so it shows up
|
||||||
|
// in CheckerDefinition.Version.
|
||||||
|
nsr.Version = Version
|
||||||
|
return nsr.Definition(), nsr.Provider(), nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue