commit b259d9ef18d994cc5474c3bb89dcf36451c90ff0 Author: Pierre-Olivier Mercier Date: Wed Apr 8 04:22:00 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90cfd6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/checker-ns-restrictions +/checker-ns-restrictions.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c502d63 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7324d03 --- /dev/null +++ b/Makefile @@ -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 diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..13851a2 --- /dev/null +++ b/NOTICE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e02673 --- /dev/null +++ b/README.md @@ -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). diff --git a/checker/abstract.go b/checker/abstract.go new file mode 100644 index 0000000..c8de1e5 --- /dev/null +++ b/checker/abstract.go @@ -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 +} diff --git a/checker/checks.go b/checker/checks.go new file mode 100644 index 0000000..89bb952 --- /dev/null +++ b/checker/checks.go @@ -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 +} diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..bc66c86 --- /dev/null +++ b/checker/collect.go @@ -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 +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..1db8583 --- /dev/null +++ b/checker/definition.go @@ -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, + }, + } +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..6dfe8a7 --- /dev/null +++ b/checker/provider.go @@ -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() +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..760db09 --- /dev/null +++ b/checker/rule.go @@ -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) +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..d84f327 --- /dev/null +++ b/checker/types.go @@ -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"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..98a5e52 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..eab57b3 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2ea54aa --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..9551878 --- /dev/null +++ b/plugin/plugin.go @@ -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 +}