Merge branch 'feature/dnscheck-godns-nc-files'

This commit is contained in:
Konrad Neitzel
2026-08-26 18:40:08 +02:00
17 changed files with 1257 additions and 2 deletions
+1
View File
@@ -9,6 +9,7 @@
# Test / coverage # Test / coverage
*.test *.test
!testdata/bind/db.example.test
*.out *.out
coverage.html coverage.html
+1 -1
View File
@@ -8,7 +8,7 @@ Go library for authoritative DNS operations against BIND (and compatible) namese
## Status ## Status
Skeleton. Design is in [docs/project-specification.md](docs/project-specification.md). Implementation Issues live in the ExApp Workspace (`docs/issues/`), not in this repository. Library: AXFR over TCP, RFC 2136 UPDATE (UDP with TCP fallback), caller-supplied TSIG, typed RR builders. ASCII names only.
## Module ## Module
+107
View File
@@ -0,0 +1,107 @@
package godns
import (
"context"
"fmt"
"strings"
"time"
"github.com/miekg/dns"
)
// TransferResult is a completed AXFR plus SOA serial metadata.
type TransferResult struct {
Zone Zone
Serial uint32
}
// AXFR fetches the full zone over TCP, signed with the session TSIG key.
func (c *Client) AXFR(ctx context.Context) (*TransferResult, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
zone := dns.Fqdn(c.zone)
msg := new(dns.Msg)
msg.SetAxfr(zone)
msg.SetTsig(dns.Fqdn(c.tsig.Name), tsigAlgorithm(c.tsig.Algorithm), 300, time.Now().Unix())
tr := &dns.Transfer{
TsigSecret: map[string]string{dns.Fqdn(c.tsig.Name): c.tsig.Secret},
DialTimeout: timeoutFrom(ctx),
ReadTimeout: timeoutFrom(ctx),
}
envCh, err := tr.In(msg, c.server)
if err != nil {
return nil, fmt.Errorf("AXFR to %s: %w", c.server, err)
}
var records []Record
var serial uint32
sawSOA := false
for env := range envCh {
if env.Error != nil {
return nil, fmt.Errorf("AXFR stream: %w", env.Error)
}
for _, rr := range env.RR {
if soa, ok := rr.(*dns.SOA); ok {
if sawSOA {
continue // trailing SOA ends the transfer
}
sawSOA = true
serial = soa.Serial
}
rec, err := fromRR(rr)
if err != nil {
return nil, err
}
records = append(records, rec)
}
}
if !sawSOA {
return nil, fmt.Errorf("AXFR returned no SOA for %s", zone)
}
return &TransferResult{
Zone: Zone{
Origin: zone,
Serial: serial,
Records: records,
},
Serial: serial,
}, nil
}
// tsigAlgorithm maps a BIND algorithm name to miekg/dns constants.
func tsigAlgorithm(name string) string {
trimmed := strings.ToLower(strings.TrimSpace(name))
trimmed = strings.TrimSuffix(trimmed, ".")
switch trimmed {
case "hmac-md5":
return dns.HmacMD5
case "hmac-sha1":
return dns.HmacSHA1
case "hmac-sha224":
return dns.HmacSHA224
case "hmac-sha256":
return dns.HmacSHA256
case "hmac-sha384":
return dns.HmacSHA384
case "hmac-sha512":
return dns.HmacSHA512
default:
return dns.Fqdn(trimmed)
}
}
// timeoutFrom uses the context deadline, or 30s, with a 1s floor.
func timeoutFrom(ctx context.Context) time.Duration {
deadline, ok := ctx.Deadline()
if !ok {
return 30 * time.Second
}
d := time.Until(deadline)
if d < time.Second {
return time.Second
}
return d
}
+168
View File
@@ -0,0 +1,168 @@
//go:build integration
package godns_test
import (
"context"
"io"
"net"
"net/netip"
"os"
"path/filepath"
"runtime"
"strconv"
"testing"
"time"
godns "gitea.neitzel.de/konrad/go-dns"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestAXFRFetchesFullZoneOverTCPWithTSIG(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
server := startBIND(ctx, t)
client, err := godns.NewClient(server, "example.test", fixtureTSIG())
if err != nil {
t.Fatal(err)
}
result, err := client.AXFR(ctx)
if err != nil {
t.Fatal(err)
}
if result.Serial != 1 {
t.Fatalf("serial: %d", result.Serial)
}
if !hasRecord(result.Zone.Records, "www.example.test.", "A", "192.0.2.10") {
t.Fatalf("missing www A record in %#v", result.Zone.Records)
}
if !hasRecord(result.Zone.Records, "ns.example.test.", "A", "192.0.2.1") {
t.Fatalf("missing ns A record in %#v", result.Zone.Records)
}
}
func TestUpdateAddAndDeleteAgainstBIND(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
server := startBIND(ctx, t)
client, err := godns.NewClient(server, "example.test", fixtureTSIG())
if err != nil {
t.Fatal(err)
}
added, err := godns.A("new.example.test", 300, net.ParseIP("192.0.2.99"))
if err != nil {
t.Fatal(err)
}
if _, err := client.Update(ctx, godns.Add(added)); err != nil {
t.Fatal(err)
}
afterAdd, err := client.AXFR(ctx)
if err != nil {
t.Fatal(err)
}
if !hasRecord(afterAdd.Zone.Records, "new.example.test.", "A", "192.0.2.99") {
t.Fatalf("add did not appear in AXFR: %#v", afterAdd.Zone.Records)
}
if _, err := client.Update(ctx, godns.Delete(added)); err != nil {
t.Fatal(err)
}
afterDelete, err := client.AXFR(ctx)
if err != nil {
t.Fatal(err)
}
if hasRecord(afterDelete.Zone.Records, "new.example.test.", "A", "192.0.2.99") {
t.Fatal("delete left the record in AXFR")
}
}
func startBIND(ctx context.Context, t *testing.T) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
bindDir := filepath.Join(filepath.Dir(thisFile), "testdata", "bind")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
hostPort := listener.Addr().(*net.TCPAddr).Port
if err := listener.Close(); err != nil {
t.Fatal(err)
}
tcp53 := network.MustParsePort("53/tcp")
udp53 := network.MustParsePort("53/udp")
binding := network.PortBinding{
HostIP: netip.MustParseAddr("127.0.0.1"),
HostPort: strconv.Itoa(hostPort),
}
req := testcontainers.ContainerRequest{
FromDockerfile: testcontainers.FromDockerfile{
Context: bindDir,
Dockerfile: "Dockerfile",
},
ExposedPorts: []string{"53/tcp", "53/udp"},
WaitingFor: wait.ForListeningPort("53/tcp").WithStartupTimeout(2 * time.Minute),
HostConfigModifier: func(hc *container.HostConfig) {
hc.PortBindings = network.PortMap{
tcp53: {binding},
udp53: {binding},
}
},
}
ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatalf("start BIND: %v", err)
}
t.Cleanup(func() {
if t.Failed() {
reader, logErr := ctr.Logs(context.Background())
if logErr == nil {
defer reader.Close()
data, _ := io.ReadAll(reader)
t.Logf("BIND logs:\n%s", data)
}
}
_ = ctr.Terminate(context.Background())
})
return net.JoinHostPort("127.0.0.1", strconv.Itoa(hostPort))
}
func hasRecord(records []godns.Record, owner, typ, rdata string) bool {
for _, rec := range records {
if rec.Owner == owner && rec.Type == typ && rec.RData == rdata {
return true
}
}
return false
}
func TestMain(m *testing.M) {
if os.Getenv("DOCKER_HOST") == "" {
sock := filepath.Join(os.Getenv("HOME"), ".rd", "docker.sock")
if _, err := os.Stat(sock); err == nil {
_ = os.Setenv("DOCKER_HOST", "unix://"+sock)
}
}
// Ryuk cannot mount Rancher Desktop's user-scoped docker.sock.
if os.Getenv("TESTCONTAINERS_RYUK_DISABLED") == "" {
_ = os.Setenv("TESTCONTAINERS_RYUK_DISABLED", "true")
}
os.Exit(m.Run())
}
+84
View File
@@ -0,0 +1,84 @@
package godns
import (
"fmt"
"net"
"strings"
)
// TSIG is caller-supplied key material. go-dns does not generate keys.
type TSIG struct {
Name string // key name presented to BIND
Algorithm string // BIND algorithm name, e.g. hmac-sha256
Secret string // base64 secret from the Key File
}
// Client is a session against one nameserver and zone, authenticated with TSIG.
type Client struct {
server string
zone string
tsig TSIG
}
// NewClient validates ASCII zone names and required TSIG fields.
// server is host or host:port; port 53 is assumed when omitted.
func NewClient(server, zone string, key TSIG) (*Client, error) {
if strings.TrimSpace(server) == "" {
return nil, fmt.Errorf("server is required")
}
if err := asciiName("zone", zone); err != nil {
return nil, err
}
if err := validateTSIG(key); err != nil {
return nil, err
}
addr, err := withDNSPort(server)
if err != nil {
return nil, err
}
return &Client{server: addr, zone: strings.TrimSpace(zone), tsig: key}, nil
}
// validateTSIG requires name, algorithm, and secret, and an ASCII key name.
func validateTSIG(key TSIG) error {
if strings.TrimSpace(key.Name) == "" {
return fmt.Errorf("TSIG name is required")
}
if strings.TrimSpace(key.Algorithm) == "" {
return fmt.Errorf("TSIG algorithm is required")
}
if strings.TrimSpace(key.Secret) == "" {
return fmt.Errorf("TSIG secret is required")
}
if err := asciiName("TSIG name", key.Name); err != nil {
return err
}
return nil
}
// withDNSPort appends :53 when server has no port.
func withDNSPort(server string) (string, error) {
host := strings.TrimSpace(server)
if _, _, err := net.SplitHostPort(host); err == nil {
return host, nil
}
// net.SplitHostPort fails for bare IPv6; require brackets or a hostname/IPv4.
if strings.Count(host, ":") > 1 && !strings.HasPrefix(host, "[") {
return "", fmt.Errorf("IPv6 server must be [host]:port or wrapped in brackets")
}
return net.JoinHostPort(host, "53"), nil
}
// asciiName rejects empty or non-ASCII DNS names.
func asciiName(label, name string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return fmt.Errorf("%s is required", label)
}
for i := 0; i < len(trimmed); i++ {
if trimmed[i] > 127 {
return fmt.Errorf("%s must be ASCII, got %q", label, name)
}
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package godns_test
import (
"strings"
"testing"
godns "gitea.neitzel.de/konrad/go-dns"
)
func TestNewClientRejectsNonASCIIZone(t *testing.T) {
t.Parallel()
_, err := godns.NewClient("127.0.0.1:53", "exämple.test", fixtureTSIG())
if err == nil {
t.Fatal("expected error for non-ASCII zone name")
}
if !strings.Contains(strings.ToLower(err.Error()), "ascii") {
t.Fatalf("error should mention ASCII, got %v", err)
}
}
func TestNewClientRejectsEmptyTSIG(t *testing.T) {
t.Parallel()
_, err := godns.NewClient("127.0.0.1:53", "example.test", godns.TSIG{})
if err == nil {
t.Fatal("expected error for empty TSIG")
}
}
func TestNewClientAcceptsASCIIZone(t *testing.T) {
t.Parallel()
client, err := godns.NewClient("127.0.0.1", "example.test", fixtureTSIG())
if err != nil {
t.Fatal(err)
}
if client == nil {
t.Fatal("expected client")
}
}
const fixtureSecret = "dGVzdC1maXh0dXJlLWhtYWMtc2hhMjU2LWtleSEhISE="
func fixtureTSIG() godns.TSIG {
return godns.TSIG{
Name: "go-dns-test",
Algorithm: "hmac-sha256",
Secret: fixtureSecret,
}
}
+4
View File
@@ -0,0 +1,4 @@
// Package godns is a library for authoritative DNS against BIND-compatible
// nameservers: full zone transfer (AXFR) over TCP and RFC 2136 dynamic UPDATE,
// authenticated with caller-supplied TSIG. Domain names are ASCII only in v1.
package godns
+1 -1
View File
@@ -19,7 +19,7 @@ Callers perform updates and full zone reads in Go with typed **zone data structu
| Public package | **`godns`** (`package godns` at module root) | | Public package | **`godns`** (`package godns` at module root) |
| Git remote | `ssh://git@gitea.neitzel.de:2222/konrad/go-dns.git` | | Git remote | `ssh://git@gitea.neitzel.de:2222/konrad/go-dns.git` |
This is a **Library** in the ExApp Workspace. Workspace Go rules live in `.cursor/rules/` (`go.mdc`, `go-testing.mdc`). Specs and Issues for this Library live in the Workspace under `docs/issues/`, not in this repository. This is a **Library** in the ExApp Workspace. Workspace Go rules live in `.cursor/rules/` (`go.mdc`, `go-testing.mdc`). Specs, Issues, and ADRs for this Library live in the Workspace (`docs/issues/`, `docs/adr/go-dns/`), not in this repository.
## Target environment ## Target environment
+59
View File
@@ -1,3 +1,62 @@
module gitea.neitzel.de/konrad/go-dns module gitea.neitzel.de/konrad/go-dns
go 1.26.4 go 1.26.4
require (
github.com/miekg/dns v1.1.73
github.com/moby/moby/api v1.55.0
github.com/testcontainers/testcontainers-go v0.44.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+141
View File
@@ -0,0 +1,141 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+184
View File
@@ -0,0 +1,184 @@
package godns
import (
"fmt"
"net"
"strings"
"github.com/miekg/dns"
)
// Record is a single resource record with presentation-format rdata.
type Record struct {
Owner string
Class string
Type string
TTL uint32
RData string
}
// RRSet groups records that share owner, class, and type.
type RRSet struct {
Owner string
Class string
Type string
Records []Record
}
// Zone is a complete in-memory zone from AXFR (or assembled records).
type Zone struct {
Origin string
Serial uint32
Records []Record
}
// RRSets groups Zone records by owner/class/type, preserving first-seen order.
func (z Zone) RRSets() []RRSet {
type key struct{ owner, class, typ string }
order := make([]key, 0)
grouped := make(map[key][]Record)
for _, rec := range z.Records {
k := key{rec.Owner, rec.Class, rec.Type}
if _, ok := grouped[k]; !ok {
order = append(order, k)
}
grouped[k] = append(grouped[k], rec)
}
out := make([]RRSet, 0, len(order))
for _, k := range order {
out = append(out, RRSet{Owner: k.owner, Class: k.class, Type: k.typ, Records: grouped[k]})
}
return out
}
// A builds an IPv4 address record.
func A(owner string, ttl uint32, ip net.IP) (Record, error) {
v4 := ip.To4()
if v4 == nil {
return Record{}, fmt.Errorf("A record requires an IPv4 address")
}
return fromRR(&dns.A{Hdr: rrHeader(owner, dns.TypeA, ttl), A: v4})
}
// AAAA builds an IPv6 address record.
func AAAA(owner string, ttl uint32, ip net.IP) (Record, error) {
if ip == nil || ip.To16() == nil || ip.To4() != nil {
return Record{}, fmt.Errorf("AAAA record requires an IPv6 address")
}
return fromRR(&dns.AAAA{Hdr: rrHeader(owner, dns.TypeAAAA, ttl), AAAA: ip.To16()})
}
// MX builds a mail-exchanger record.
func MX(owner string, ttl uint32, pref uint16, target string) (Record, error) {
name, err := fqdnASCII(target)
if err != nil {
return Record{}, err
}
return fromRR(&dns.MX{Hdr: rrHeader(owner, dns.TypeMX, ttl), Preference: pref, Mx: name})
}
// TXT builds a text record.
func TXT(owner string, ttl uint32, text string) (Record, error) {
return fromRR(&dns.TXT{Hdr: rrHeader(owner, dns.TypeTXT, ttl), Txt: []string{text}})
}
// CNAME builds a canonical-name record.
func CNAME(owner string, ttl uint32, target string) (Record, error) {
name, err := fqdnASCII(target)
if err != nil {
return Record{}, err
}
return fromRR(&dns.CNAME{Hdr: rrHeader(owner, dns.TypeCNAME, ttl), Target: name})
}
// NS builds a nameserver record.
func NS(owner string, ttl uint32, target string) (Record, error) {
name, err := fqdnASCII(target)
if err != nil {
return Record{}, err
}
return fromRR(&dns.NS{Hdr: rrHeader(owner, dns.TypeNS, ttl), Ns: name})
}
// SRV builds a service locator record.
func SRV(owner string, ttl uint32, prio, weight, port uint16, target string) (Record, error) {
name, err := fqdnASCII(target)
if err != nil {
return Record{}, err
}
return fromRR(&dns.SRV{
Hdr: rrHeader(owner, dns.TypeSRV, ttl),
Priority: prio,
Weight: weight,
Port: port,
Target: name,
})
}
// PTR builds a pointer record.
func PTR(owner string, ttl uint32, target string) (Record, error) {
name, err := fqdnASCII(target)
if err != nil {
return Record{}, err
}
return fromRR(&dns.PTR{Hdr: rrHeader(owner, dns.TypePTR, ttl), Ptr: name})
}
// rrHeader builds an INET header. Owner ASCII is checked in fromRR.
func rrHeader(owner string, typ uint16, ttl uint32) dns.RR_Header {
name, err := fqdnASCII(owner)
if err != nil {
// fromRR is not called on this path; builders call fqdn via fromRR after Header is set.
// Validate in fromRR instead so the error is returned to the caller.
name = owner
}
return dns.RR_Header{Name: name, Rrtype: typ, Class: dns.ClassINET, Ttl: ttl}
}
// fromRR maps a miekg/dns RR onto the public Record type.
func fromRR(rr dns.RR) (Record, error) {
hdr := rr.Header()
if err := asciiName("owner", strings.TrimSuffix(hdr.Name, ".")); err != nil {
return Record{}, err
}
if hdr.Name == "" || !isASCII(hdr.Name) {
return Record{}, fmt.Errorf("owner must be ASCII, got %q", hdr.Name)
}
return Record{
Owner: dns.Fqdn(hdr.Name),
Class: dns.ClassToString[hdr.Class],
Type: dns.TypeToString[hdr.Rrtype],
TTL: hdr.Ttl,
RData: strings.TrimSpace(strings.TrimPrefix(rr.String(), hdr.String())),
}, nil
}
// recordToRR encodes a Record as a miekg/dns RR for UPDATE.
func recordToRR(rec Record) (dns.RR, error) {
if err := asciiName("owner", rec.Owner); err != nil {
return nil, err
}
rr, err := dns.NewRR(fmt.Sprintf("%s %d %s %s %s", dns.Fqdn(rec.Owner), rec.TTL, rec.Class, rec.Type, rec.RData))
if err != nil {
return nil, fmt.Errorf("encode %s %s: %w", rec.Type, rec.Owner, err)
}
return rr, nil
}
// fqdnASCII validates ASCII and returns a trailing-dot FQDN.
func fqdnASCII(name string) (string, error) {
if err := asciiName("name", name); err != nil {
return "", err
}
return dns.Fqdn(strings.TrimSpace(name)), nil
}
// isASCII reports whether s contains only bytes <= 127.
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] > 127 {
return false
}
}
return true
}
+177
View File
@@ -0,0 +1,177 @@
package godns_test
import (
"net"
"strings"
"testing"
godns "gitea.neitzel.de/konrad/go-dns"
)
func TestABuilder(t *testing.T) {
t.Parallel()
rec, err := godns.A("www.example.test", 300, net.ParseIP("192.0.2.10"))
if err != nil {
t.Fatal(err)
}
if rec.Owner != "www.example.test." {
t.Fatalf("owner: %q", rec.Owner)
}
if rec.Type != "A" {
t.Fatalf("type: %q", rec.Type)
}
if rec.TTL != 300 {
t.Fatalf("ttl: %d", rec.TTL)
}
if rec.RData != "192.0.2.10" {
t.Fatalf("rdata: %q", rec.RData)
}
if rec.Class != "IN" {
t.Fatalf("class: %q", rec.Class)
}
}
func TestAAAABuilder(t *testing.T) {
t.Parallel()
rec, err := godns.AAAA("www.example.test", 300, net.ParseIP("2001:db8::10"))
if err != nil {
t.Fatal(err)
}
if rec.Type != "AAAA" {
t.Fatalf("type: %q", rec.Type)
}
if rec.RData != "2001:db8::10" {
t.Fatalf("rdata: %q", rec.RData)
}
}
func TestMXBuilder(t *testing.T) {
t.Parallel()
rec, err := godns.MX("example.test", 3600, 10, "mail.example.test")
if err != nil {
t.Fatal(err)
}
if rec.Type != "MX" {
t.Fatalf("type: %q", rec.Type)
}
if rec.RData != "10 mail.example.test." {
t.Fatalf("rdata: %q", rec.RData)
}
}
func TestTXTBuilder(t *testing.T) {
t.Parallel()
rec, err := godns.TXT("example.test", 3600, "v=spf1 -all")
if err != nil {
t.Fatal(err)
}
if rec.Type != "TXT" {
t.Fatalf("type: %q", rec.Type)
}
if rec.RData != `"v=spf1 -all"` {
t.Fatalf("rdata: %q", rec.RData)
}
}
func TestCNAMEBuilder(t *testing.T) {
t.Parallel()
rec, err := godns.CNAME("www.example.test", 3600, "example.test")
if err != nil {
t.Fatal(err)
}
if rec.Type != "CNAME" {
t.Fatalf("type: %q", rec.Type)
}
if rec.RData != "example.test." {
t.Fatalf("rdata: %q", rec.RData)
}
}
func TestNSBuilder(t *testing.T) {
t.Parallel()
rec, err := godns.NS("example.test", 3600, "ns.example.test")
if err != nil {
t.Fatal(err)
}
if rec.Type != "NS" {
t.Fatalf("type: %q", rec.Type)
}
if rec.RData != "ns.example.test." {
t.Fatalf("rdata: %q", rec.RData)
}
}
func TestSRVBuilder(t *testing.T) {
t.Parallel()
rec, err := godns.SRV("_sip._tcp.example.test", 3600, 10, 20, 5060, "sip.example.test")
if err != nil {
t.Fatal(err)
}
if rec.Type != "SRV" {
t.Fatalf("type: %q", rec.Type)
}
if rec.RData != "10 20 5060 sip.example.test." {
t.Fatalf("rdata: %q", rec.RData)
}
}
func TestPTRBuilder(t *testing.T) {
t.Parallel()
rec, err := godns.PTR("1.2.0.192.in-addr.arpa", 3600, "www.example.test")
if err != nil {
t.Fatal(err)
}
if rec.Type != "PTR" {
t.Fatalf("type: %q", rec.Type)
}
if rec.RData != "www.example.test." {
t.Fatalf("rdata: %q", rec.RData)
}
}
func TestBuilderRejectsNonASCIIOwner(t *testing.T) {
t.Parallel()
_, err := godns.A("wüw.example.test", 300, net.ParseIP("192.0.2.10"))
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(strings.ToLower(err.Error()), "ascii") {
t.Fatalf("error should mention ASCII, got %v", err)
}
}
func TestZoneGroupsRRSets(t *testing.T) {
t.Parallel()
a1, err := godns.A("www.example.test", 300, net.ParseIP("192.0.2.10"))
if err != nil {
t.Fatal(err)
}
a2, err := godns.A("www.example.test", 300, net.ParseIP("192.0.2.11"))
if err != nil {
t.Fatal(err)
}
mx, err := godns.MX("example.test", 3600, 10, "mail.example.test")
if err != nil {
t.Fatal(err)
}
zone := godns.Zone{
Origin: "example.test.",
Serial: 1,
Records: []godns.Record{a1, a2, mx},
}
sets := zone.RRSets()
if len(sets) != 2 {
t.Fatalf("expected 2 RRsets, got %d", len(sets))
}
}
+13
View File
@@ -0,0 +1,13 @@
FROM ubuntu:24.04
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends bind9 \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /run/named /var/cache/bind \
&& chown -R bind:bind /run/named /var/cache/bind
COPY named.conf /etc/bind/named.conf
COPY db.example.test /var/cache/bind/db.example.test
RUN chown bind:bind /etc/bind/named.conf /var/cache/bind/db.example.test \
&& chmod 644 /etc/bind/named.conf \
&& chmod 664 /var/cache/bind/db.example.test
EXPOSE 53/tcp 53/udp
CMD ["/usr/sbin/named", "-f", "-g", "-u", "bind", "-c", "/etc/bind/named.conf"]
+10
View File
@@ -0,0 +1,10 @@
$TTL 3600
@ IN SOA ns.example.test. hostmaster.example.test. (
1
3600
600
86400
3600 )
IN NS ns.example.test.
ns IN A 192.0.2.1
www IN A 192.0.2.10
+33
View File
@@ -0,0 +1,33 @@
options {
directory "/var/cache/bind";
listen-on port 53 { any; };
listen-on-v6 { none; };
allow-query { any; };
recursion no;
dnssec-validation no;
pid-file none;
};
logging {
channel stderr_log {
stderr;
severity info;
print-time yes;
print-category yes;
};
category default { stderr_log; };
category update { stderr_log; };
category update-security { stderr_log; };
};
key "go-dns-test" {
algorithm hmac-sha256;
secret "dGVzdC1maXh0dXJlLWhtYWMtc2hhMjU2LWtleSEhISE=";
};
zone "example.test" {
type master;
file "/var/cache/bind/db.example.test";
allow-update { key "go-dns-test"; };
allow-transfer { key "go-dns-test"; };
};
+98
View File
@@ -0,0 +1,98 @@
package godns
import (
"context"
"fmt"
"time"
"github.com/miekg/dns"
)
// Action is one RFC 2136 UPDATE operation.
type Action int
const (
// ActionAdd inserts a record.
ActionAdd Action = iota
// ActionDelete removes a matching RR (name, type, and rdata).
ActionDelete
)
// Change is a typed add or delete of one record.
type Change struct {
Action Action
Record Record
}
// Add builds an insert change.
func Add(rec Record) Change {
return Change{Action: ActionAdd, Record: rec}
}
// Delete builds a delete-by-rr change (name, type, and rdata).
func Delete(rec Record) Change {
return Change{Action: ActionDelete, Record: rec}
}
// UpdateResult is the nameserver's response to an UPDATE.
type UpdateResult struct {
Rcode string
}
// Update applies RFC 2136 changes. It tries UDP first and retries on TCP when
// the UDP exchange fails or the response is truncated.
func (c *Client) Update(ctx context.Context, changes ...Change) (*UpdateResult, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if len(changes) == 0 {
return &UpdateResult{Rcode: dns.RcodeToString[dns.RcodeSuccess]}, nil
}
msg, err := c.updateMessage(changes)
if err != nil {
return nil, err
}
client := &dns.Client{
Net: "udp",
TsigSecret: map[string]string{dns.Fqdn(c.tsig.Name): c.tsig.Secret},
Timeout: timeoutFrom(ctx),
}
udpMsg := msg.Copy()
resp, _, err := client.ExchangeContext(ctx, udpMsg, c.server)
if err != nil || (resp != nil && resp.Truncated) {
client.Net = "tcp"
resp, _, err = client.ExchangeContext(ctx, msg.Copy(), c.server)
}
if err != nil {
return nil, fmt.Errorf("UPDATE to %s: %w", c.server, err)
}
if resp.Rcode != dns.RcodeSuccess {
return nil, fmt.Errorf("UPDATE rcode %s", dns.RcodeToString[resp.Rcode])
}
return &UpdateResult{Rcode: dns.RcodeToString[resp.Rcode]}, nil
}
// updateMessage builds a TSIG-signed RFC 2136 UPDATE message.
func (c *Client) updateMessage(changes []Change) (*dns.Msg, error) {
zone := dns.Fqdn(c.zone)
msg := new(dns.Msg)
msg.SetUpdate(zone)
for _, change := range changes {
rr, err := recordToRR(change.Record)
if err != nil {
return nil, err
}
switch change.Action {
case ActionAdd:
msg.Insert([]dns.RR{rr})
case ActionDelete:
msg.Remove([]dns.RR{rr})
default:
return nil, fmt.Errorf("unsupported update action %d", change.Action)
}
}
msg.SetTsig(dns.Fqdn(c.tsig.Name), tsigAlgorithm(c.tsig.Algorithm), 300, time.Now().Unix())
return msg, nil
}
+125
View File
@@ -0,0 +1,125 @@
package godns_test
import (
"context"
"net"
"strings"
"sync/atomic"
"testing"
"time"
godns "gitea.neitzel.de/konrad/go-dns"
"github.com/miekg/dns"
)
func TestUpdateFallsBackToTCPWhenUDPTruncated(t *testing.T) {
t.Parallel()
var udpHits, tcpHits atomic.Int32
mux := dns.NewServeMux()
mux.HandleFunc("example.test.", func(w dns.ResponseWriter, r *dns.Msg) {
if r.IsTsig() == nil {
t.Error("UPDATE request missing TSIG")
}
msg := new(dns.Msg)
msg.SetReply(r)
if _, ok := w.RemoteAddr().(*net.UDPAddr); ok {
udpHits.Add(1)
msg.Truncated = true
_ = w.WriteMsg(msg)
return
}
tcpHits.Add(1)
_ = w.WriteMsg(msg)
})
udp, tcp := startDNSServers(t, mux)
client, err := godns.NewClient(udp, "example.test", fixtureTSIG())
if err != nil {
t.Fatal(err)
}
_ = tcp
rec, err := godns.A("new.example.test", 300, net.ParseIP("192.0.2.99"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result, err := client.Update(ctx, godns.Add(rec))
if err != nil {
t.Fatal(err)
}
if result.Rcode != "NOERROR" {
t.Fatalf("rcode: %s", result.Rcode)
}
if udpHits.Load() < 1 {
t.Fatal("expected a UDP attempt")
}
if tcpHits.Load() < 1 {
t.Fatal("expected TCP fallback")
}
}
func startDNSServers(t *testing.T, handler dns.Handler) (udpAddr, tcpAddr string) {
t.Helper()
secrets := map[string]string{"go-dns-test.": fixtureSecret}
acceptUpdate := func(dh dns.Header) dns.MsgAcceptAction {
return dns.MsgAccept
}
udpSrv := &dns.Server{Addr: "127.0.0.1:0", Net: "udp", Handler: handler, TsigSecret: secrets, MsgAcceptFunc: acceptUpdate}
udpReady := make(chan struct{})
udpSrv.NotifyStartedFunc = func() { close(udpReady) }
go func() { _ = udpSrv.ListenAndServe() }()
<-udpReady
t.Cleanup(func() { _ = udpSrv.Shutdown() })
udpAddr = udpSrv.PacketConn.LocalAddr().String()
_, port, err := net.SplitHostPort(udpAddr)
if err != nil {
t.Fatal(err)
}
tcpSrv := &dns.Server{Addr: "127.0.0.1:" + port, Net: "tcp", Handler: handler, TsigSecret: secrets, MsgAcceptFunc: acceptUpdate}
tcpReady := make(chan struct{})
tcpSrv.NotifyStartedFunc = func() { close(tcpReady) }
go func() { _ = tcpSrv.ListenAndServe() }()
<-tcpReady
t.Cleanup(func() { _ = tcpSrv.Shutdown() })
return udpAddr, tcpSrv.Listener.Addr().String()
}
func TestAddChangeDescribesInsert(t *testing.T) {
t.Parallel()
rec, err := godns.A("www.example.test", 300, net.ParseIP("192.0.2.10"))
if err != nil {
t.Fatal(err)
}
change := godns.Add(rec)
if change.Action != godns.ActionAdd {
t.Fatalf("action: %v", change.Action)
}
if change.Record.Type != "A" {
t.Fatalf("record: %+v", change.Record)
}
}
func TestDeleteChangeDescribesRemove(t *testing.T) {
t.Parallel()
rec, err := godns.TXT("bad.example.test", 300, "stale")
if err != nil {
t.Fatal(err)
}
change := godns.Delete(rec)
if change.Action != godns.ActionDelete {
t.Fatalf("action: %v", change.Action)
}
if !strings.Contains(change.Record.RData, "stale") {
t.Fatalf("rdata: %q", change.Record.RData)
}
}