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