diff --git a/.gitignore b/.gitignore index 193baf4..38f59cd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # Test / coverage *.test +!testdata/bind/db.example.test *.out coverage.html diff --git a/README.md b/README.md index f792a20..1c72ca8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Go library for authoritative DNS operations against BIND (and compatible) namese ## 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 diff --git a/axfr.go b/axfr.go new file mode 100644 index 0000000..d6107cd --- /dev/null +++ b/axfr.go @@ -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 +} diff --git a/bind_integration_test.go b/bind_integration_test.go new file mode 100644 index 0000000..dfd6c33 --- /dev/null +++ b/bind_integration_test.go @@ -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()) +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..cca1729 --- /dev/null +++ b/client.go @@ -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 +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..7ef1af9 --- /dev/null +++ b/client_test.go @@ -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, + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..7f88c3d --- /dev/null +++ b/doc.go @@ -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 diff --git a/docs/project-specification.md b/docs/project-specification.md index da78db5..b296ac8 100644 --- a/docs/project-specification.md +++ b/docs/project-specification.md @@ -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) | | 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 diff --git a/go.mod b/go.mod index 7639ce8..2dd77bb 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,62 @@ module gitea.neitzel.de/konrad/go-dns 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..64b3fba --- /dev/null +++ b/go.sum @@ -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= diff --git a/record.go b/record.go new file mode 100644 index 0000000..c8cae68 --- /dev/null +++ b/record.go @@ -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 +} diff --git a/record_test.go b/record_test.go new file mode 100644 index 0000000..3d1d4ab --- /dev/null +++ b/record_test.go @@ -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)) + } +} diff --git a/testdata/bind/Dockerfile b/testdata/bind/Dockerfile new file mode 100644 index 0000000..6fea7f0 --- /dev/null +++ b/testdata/bind/Dockerfile @@ -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"] diff --git a/testdata/bind/db.example.test b/testdata/bind/db.example.test new file mode 100644 index 0000000..b51b640 --- /dev/null +++ b/testdata/bind/db.example.test @@ -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 diff --git a/testdata/bind/named.conf b/testdata/bind/named.conf new file mode 100644 index 0000000..242e72a --- /dev/null +++ b/testdata/bind/named.conf @@ -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"; }; +}; diff --git a/update.go b/update.go new file mode 100644 index 0000000..046ad07 --- /dev/null +++ b/update.go @@ -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 +} diff --git a/update_test.go b/update_test.go new file mode 100644 index 0000000..a5a0ed5 --- /dev/null +++ b/update_test.go @@ -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) + } +}