Initial commit

This commit is contained in:
nemunaire 2026-04-08 04:22:00 +07:00
commit b259d9ef18
17 changed files with 809 additions and 0 deletions

46
checker/abstract.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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"`
}