From 1bb7abd96bd98c38c178384e2df71473222704e6 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Tue, 29 Sep 2026 19:25:43 +0200 Subject: [PATCH] Document every exported symbol and how callers use the library. Co-authored-by: Cursor --- README.md | 71 ++++---- apidoc_test.go | 278 ++++++++++++++++++++++++++++++++ axfr.go | 8 +- client.go | 23 ++- docs/api.md | 245 ++++++++++++++++++++++++++++ docs/bind-tsig-configuration.md | 4 +- docs/guide.md | 210 ++++++++++++++++++++++++ docs/project-specification.md | 27 ++-- record.go | 38 +++-- update.go | 19 ++- 10 files changed, 842 insertions(+), 81 deletions(-) create mode 100644 apidoc_test.go create mode 100644 docs/api.md create mode 100644 docs/guide.md diff --git a/README.md b/README.md index c8c2e20..e89dee1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # go-dns -Shared Go library for authoritative DNS against BIND-compatible nameservers: full zone transfer (AXFR) and RFC 2136 dynamic UPDATE, authenticated with caller-supplied TSIG. +Shared Go library for authoritative DNS against BIND-compatible nameservers: full zone transfer (AXFR) and RFC 2136 dynamic UPDATE, authenticated with a caller-supplied TSIG key. Import: `gitea.neitzel.de/konrad/go-dns` (package `godns`). @@ -8,51 +8,44 @@ Import: `gitea.neitzel.de/konrad/go-dns` (package `godns`). go get gitea.neitzel.de/konrad/go-dns ``` -## Scope +- [Guide](docs/guide.md) — how to call each capability +- [API](docs/api.md) — every exported symbol +- [Domain language](CONTEXT.md) -**Included** +## Included -- **Session (`Client`)** — one nameserver + zone + TSIG key -- **AXFR** — full zone read over TCP -- **RFC 2136 UPDATE** — typed add/delete changes (UDP with TCP fallback) -- **TSIG** — caller-supplied key name, algorithm, and base64 secret -- **Typed RR builders** — A, AAAA, MX, TXT, CNAME, NS, SRV, PTR, CAA (ASCII names) -- **Zone / RRSet / Record** — in-memory zone model from AXFR +- [Session](docs/guide.md#session) — one nameserver, zone, and TSIG key +- [AXFR](docs/guide.md#axfr) — full zone read over TCP +- [UPDATE](docs/guide.md#update) — RFC 2136 add and delete (UDP, then TCP) +- [TSIG](docs/guide.md#tsig) — caller-supplied key name, algorithm, and base64 secret +- [Typed RR builders](docs/guide.md#typed-rr-builders) — A, AAAA, MX, TXT, CNAME, NS, SRV, PTR, CAA +- [Zone, RRSet, and Record](docs/guide.md#zone-rrset-and-record) — in-memory zone model -**Excluded** +## Excluded -- IXFR / incremental transfer -- TSIG key generation or BIND config writing (see docs for operator notes) -- DNSSEC signing / validation as a product feature -- Non-ASCII / IDN domain names (v1) - -## Usage - -```go -key := godns.TSIG{ - Name: "exapp-key.", - Algorithm: "hmac-sha256", - Secret: "base64-secret-from-bind", -} -client, err := godns.NewClient("ns.example:53", "example.com.", key) - -// AXFR -tr, err := client.AXFR(ctx) - -// UPDATE -rec, err := godns.A("www.example.com.", 300, net.ParseIP("192.0.2.10")) -_, err = client.Update(ctx, godns.Add(rec)) -``` - -Runnable package examples (construction only, no live nameserver): `go test -run Example`. - -## Domain language - -See [CONTEXT.md](./CONTEXT.md) for Session, Zone / RRSet / Record, UPDATE, AXFR, and TSIG key terminology. +- IXFR +- TSIG key generation and BIND configuration (see [BIND TSIG configuration](docs/bind-tsig-configuration.md)) +- DNSSEC signing or validation +- Non-ASCII and IDN names ## Testing -Unit tests cover message construction and record builders. Optional BIND integration tests live behind build tags / env (see `bind_integration_test.go`). +Unit tests cover message construction and record builders. They do not need a nameserver. + +Optional BIND integration tests use the `integration` build tag and Docker. See `bind_integration_test.go`. + +```bash +go test ./... +go test -tags=integration ./... +``` + +`go test ./...` fails when [`docs/api.md`](docs/api.md) does not match the exported API. Regenerate it with: + +```bash +UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1 +``` + +Runnable package examples (construction only, no live nameserver): `go test -run Example`. ## Related diff --git a/apidoc_test.go b/apidoc_test.go new file mode 100644 index 0000000..26c605c --- /dev/null +++ b/apidoc_test.go @@ -0,0 +1,278 @@ +package godns_test + +import ( + "bytes" + "fmt" + "go/ast" + "go/doc" + "go/doc/comment" + "go/parser" + "go/printer" + "go/token" + "os" + "strings" + "testing" +) + +const apiModulePath = "gitea.neitzel.de/konrad/go-dns" + +func TestAPIDoc(t *testing.T) { + got, err := renderAPIDoc(".", apiModulePath) + if err != nil { + t.Fatal(err) + } + const path = "docs/api.md" + if os.Getenv("UPDATE_API_DOCS") == "1" { + if err := os.MkdirAll("docs", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if string(want) != got { + t.Fatalf("docs/api.md is stale; regenerate with UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1\n%s", firstDiff(string(want), got)) + } +} + +func renderAPIDoc(dir, modulePath string) (string, error) { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { + return !strings.HasSuffix(fi.Name(), "_test.go") + }, parser.ParseComments) + if err != nil { + return "", err + } + if len(pkgs) != 1 { + return "", fmt.Errorf("expected 1 package in %s, found %d", dir, len(pkgs)) + } + var astPkg *ast.Package + for _, p := range pkgs { + astPkg = p + } + pkg := doc.New(astPkg, modulePath, 0) + lookup := symLookup(pkg) + + var b strings.Builder + b.WriteString("# API\n\n") + b.WriteString("Generated from the package comment and every exported declaration. Do not edit.\n") + b.WriteString("Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.\n\n") + fmt.Fprintf(&b, "## Package %s\n\n", pkg.Name) + writeDoc(&b, pkg.Doc, lookup) + writeValues(&b, fset, "Constants", pkg.Consts, "### ", lookup) + writeValues(&b, fset, "Variables", pkg.Vars, "### ", lookup) + writeFuncs(&b, fset, "Functions", pkg.Funcs, "### ", lookup) + if len(pkg.Types) > 0 { + b.WriteString("## Types\n\n") + for _, typ := range pkg.Types { + writeType(&b, fset, typ, lookup) + } + } + return b.String(), nil +} + +func writeType(b *strings.Builder, fset *token.FileSet, typ *doc.Type, lookup func(string, string) bool) { + fmt.Fprintf(b, "### %s\n\n", typ.Name) + sig, err := formatGenDecl(fset, typ.Decl) + if err != nil { + fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err) + } else { + writeCode(b, sig) + } + writeDoc(b, typ.Doc, lookup) + writeValues(b, fset, "", typ.Consts, "#### ", lookup) + writeValues(b, fset, "", typ.Vars, "#### ", lookup) + writeFuncs(b, fset, "", typ.Funcs, "#### ", lookup) + if !isInterface(typ) { + writeFuncs(b, fset, "", typ.Methods, "#### ", lookup) + } +} + +func writeValues(b *strings.Builder, fset *token.FileSet, title string, vals []*doc.Value, heading string, lookup func(string, string) bool) { + if len(vals) == 0 { + return + } + if title != "" { + fmt.Fprintf(b, "## %s\n\n", title) + } + for _, v := range vals { + for _, name := range v.Names { + fmt.Fprintf(b, "%s%s\n\n", heading, name) + } + sig, err := formatGenDecl(fset, v.Decl) + if err != nil { + fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err) + } else { + writeCode(b, sig) + } + writeDoc(b, v.Doc, lookup) + } +} + +func writeFuncs(b *strings.Builder, fset *token.FileSet, title string, fns []*doc.Func, heading string, lookup func(string, string) bool) { + if len(fns) == 0 { + return + } + if title != "" { + fmt.Fprintf(b, "## %s\n\n", title) + } + for _, fn := range fns { + name := fn.Name + if fn.Recv != "" { + name = strings.TrimPrefix(fn.Recv, "*") + "." + fn.Name + } + fmt.Fprintf(b, "%s%s\n\n", heading, name) + sig, err := formatFunc(fset, fn.Decl) + if err != nil { + fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err) + } else { + writeCode(b, sig) + } + writeDoc(b, fn.Doc, lookup) + } +} + +func writeCode(b *strings.Builder, sig string) { + b.WriteString("```go\n") + b.WriteString(sig) + b.WriteString("\n```\n\n") +} + +func writeDoc(b *strings.Builder, raw string, lookup func(string, string) bool) { + text := renderComment(raw, lookup) + if text == "" { + return + } + b.WriteString(text) + if !strings.HasSuffix(text, "\n") { + b.WriteByte('\n') + } + b.WriteByte('\n') +} + +func symLookup(pkg *doc.Package) func(string, string) bool { + syms := map[string]bool{} + methods := map[string]bool{} + addNames := func(vals []*doc.Value) { + for _, v := range vals { + for _, name := range v.Names { + syms[name] = true + } + } + } + addFuncs := func(fns []*doc.Func) { + for _, fn := range fns { + syms[fn.Name] = true + if fn.Recv != "" { + recv := strings.TrimPrefix(fn.Recv, "*") + methods[recv+"."+fn.Name] = true + } + } + } + addNames(pkg.Consts) + addNames(pkg.Vars) + addFuncs(pkg.Funcs) + for _, typ := range pkg.Types { + syms[typ.Name] = true + addNames(typ.Consts) + addNames(typ.Vars) + addFuncs(typ.Funcs) + addFuncs(typ.Methods) + } + return func(recv, name string) bool { + if recv == "" { + return syms[name] + } + return methods[recv+"."+name] + } +} + +func renderComment(raw string, lookup func(string, string) bool) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + var parser comment.Parser + parser.LookupSym = lookup + var printer comment.Printer + printer.HeadingLevel = 4 + printer.DocLinkURL = func(link *comment.DocLink) string { + if link.ImportPath != "" { + return link.DefaultURL("https://pkg.go.dev") + } + name := link.Name + if link.Recv != "" { + name = link.Recv + "." + link.Name + } + return "#" + githubAnchor(name) + } + return strings.TrimSpace(string(printer.Markdown(parser.Parse(raw)))) +} + +// githubAnchor matches GitHub heading slugs: lowercase, drop punctuation, keep +// letters, digits, hyphens, and underscores. "Client.Chat" becomes "clientchat". +func githubAnchor(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + b.WriteRune(r) + } + } + return b.String() +} + +func formatGenDecl(fset *token.FileSet, decl *ast.GenDecl) (string, error) { + if decl == nil { + return "", fmt.Errorf("nil decl") + } + copyDecl := *decl + copyDecl.Doc = nil + return formatNode(fset, ©Decl) +} + +func formatFunc(fset *token.FileSet, decl *ast.FuncDecl) (string, error) { + if decl == nil { + return "", fmt.Errorf("nil decl") + } + copyDecl := *decl + copyDecl.Doc = nil + copyDecl.Body = nil + return formatNode(fset, ©Decl) +} + +func formatNode(fset *token.FileSet, node ast.Node) (string, error) { + var buf bytes.Buffer + if err := printer.Fprint(&buf, fset, node); err != nil { + return "", err + } + return strings.TrimSpace(buf.String()), nil +} + +func isInterface(typ *doc.Type) bool { + if typ.Decl == nil || len(typ.Decl.Specs) != 1 { + return false + } + spec, ok := typ.Decl.Specs[0].(*ast.TypeSpec) + if !ok { + return false + } + _, ok = spec.Type.(*ast.InterfaceType) + return ok +} + +func firstDiff(want, got string) string { + w := strings.Split(want, "\n") + g := strings.Split(got, "\n") + n := min(len(g), len(w)) + for i := range n { + if w[i] != g[i] { + return fmt.Sprintf("line %d:\n got: %s\nwant: %s", i+1, g[i], w[i]) + } + } + return fmt.Sprintf("length got %d lines, want %d lines", len(g), len(w)) +} diff --git a/axfr.go b/axfr.go index d6107cd..2be87aa 100644 --- a/axfr.go +++ b/axfr.go @@ -9,13 +9,17 @@ import ( "github.com/miekg/dns" ) -// TransferResult is a completed AXFR plus SOA serial metadata. +// TransferResult is a completed AXFR. +// Serial is the SOA serial and is the same value stored on Zone. type TransferResult struct { Zone Zone Serial uint32 } -// AXFR fetches the full zone over TCP, signed with the session TSIG key. +// AXFR reads the full zone over TCP, signed with the session TSIG key. +// It returns an error when ctx is done, the transfer fails, a record owner +// is not ASCII, or the stream contains no SOA. +// The closing SOA of the transfer is not appended again. func (c *Client) AXFR(ctx context.Context) (*TransferResult, error) { if err := ctx.Err(); err != nil { return nil, err diff --git a/client.go b/client.go index cca1729..98caeff 100644 --- a/client.go +++ b/client.go @@ -6,22 +6,31 @@ import ( "strings" ) -// TSIG is caller-supplied key material. go-dns does not generate keys. +// TSIG is caller-supplied key material. The library does not generate keys +// or write BIND configuration. +// Name is the key name presented to the nameserver. +// Algorithm is a BIND name such as hmac-sha256 (also hmac-md5, hmac-sha1, +// hmac-sha224, hmac-sha384, and hmac-sha512). +// Secret is the base64 secret. 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 + Name string + Algorithm string + Secret string } -// Client is a session against one nameserver and zone, authenticated with TSIG. +// Client is one session: one nameserver, one zone, and one TSIG key. +// AXFR and Update both use that key. There is no unsigned mode. 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. +// NewClient starts a session for one nameserver, zone, and TSIG key. +// server is a host or host:port. A missing port becomes 53. +// An IPv6 host must be written in brackets. +// It returns an error when server, zone, or any TSIG field is empty, +// when zone or the key name is not ASCII, or when server is a bare IPv6 address. func NewClient(server, zone string, key TSIG) (*Client, error) { if strings.TrimSpace(server) == "" { return nil, fmt.Errorf("server is required") diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..d60a2ff --- /dev/null +++ b/docs/api.md @@ -0,0 +1,245 @@ +# API + +Generated from the package comment and every exported declaration. Do not edit. +Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`. + +## Package godns + +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. + +## Types + +### Action + +```go +type Action int +``` + +Action is one RFC 2136 UPDATE operation. + +#### ActionAdd + +#### ActionDelete + +```go +const ( + // ActionAdd inserts a record. + ActionAdd Action = iota + // ActionDelete removes one record that matches owner, type, and rdata. + ActionDelete +) +``` + +### Change + +```go +type Change struct { + Action Action + Record Record +} +``` + +Change is one add or delete of one record inside an Update batch. + +#### Add + +```go +func Add(rec Record) Change +``` + +Add builds an insert change for rec. + +#### Delete + +```go +func Delete(rec Record) Change +``` + +Delete builds a delete of one matching record (owner, type, and rdata). + +### Client + +```go +type Client struct { + // contains filtered or unexported fields +} +``` + +Client is one session: one nameserver, one zone, and one TSIG key. AXFR and Update both use that key. There is no unsigned mode. + +#### NewClient + +```go +func NewClient(server, zone string, key TSIG) (*Client, error) +``` + +NewClient starts a session for one nameserver, zone, and TSIG key. server is a host or host:port. A missing port becomes 53. An IPv6 host must be written in brackets. It returns an error when server, zone, or any TSIG field is empty, when zone or the key name is not ASCII, or when server is a bare IPv6 address. + +#### Client.AXFR + +```go +func (c *Client) AXFR(ctx context.Context) (*TransferResult, error) +``` + +AXFR reads the full zone over TCP, signed with the session TSIG key. It returns an error when ctx is done, the transfer fails, a record owner is not ASCII, or the stream contains no SOA. The closing SOA of the transfer is not appended again. + +#### Client.Update + +```go +func (c *Client) Update(ctx context.Context, changes ...Change) (*UpdateResult, error) +``` + +Update sends an RFC 2136 UPDATE for changes, signed with the session TSIG key. It tries UDP, then TCP when the UDP exchange fails or the response is truncated. No changes returns success and does not contact the nameserver. It returns an error when ctx is done, a record cannot be encoded, the exchange fails, the nameserver rcode is not NOERROR, or Action is neither ActionAdd nor ActionDelete. + +### RRSet + +```go +type RRSet struct { + Owner string + Class string + Type string + Records []Record +} +``` + +RRSet groups records that share owner, class, and type. + +### Record + +```go +type Record struct { + Owner string + Class string + Type string + TTL uint32 + RData string +} +``` + +Record is one resource record. Owner is a trailing-dot FQDN. Class and Type are presentation names such as IN and A. RData is the presentation-format rdata, without the owner, TTL, class, or type. + +#### A + +```go +func A(owner string, ttl uint32, ip net.IP) (Record, error) +``` + +A builds an IN A record. It returns an error when ip is not IPv4, or when owner is empty or not ASCII. Owner is stored as a trailing-dot FQDN. + +#### AAAA + +```go +func AAAA(owner string, ttl uint32, ip net.IP) (Record, error) +``` + +AAAA builds an IN AAAA record. It returns an error when ip is missing, is IPv4, or when owner is empty or not ASCII. Owner is stored as a trailing-dot FQDN. + +#### CAA + +```go +func CAA(owner string, ttl uint32, flag uint8, tag, value string) (Record, error) +``` + +CAA builds an IN CAA record (RFC 8659) with flag, tag, and value. It returns an error when owner is empty or not ASCII. + +#### CNAME + +```go +func CNAME(owner string, ttl uint32, target string) (Record, error) +``` + +CNAME builds an IN CNAME record pointing at target. It returns an error when owner or target is empty or not ASCII. + +#### MX + +```go +func MX(owner string, ttl uint32, pref uint16, target string) (Record, error) +``` + +MX builds an IN MX record for preference pref and exchanger target. It returns an error when owner or target is empty or not ASCII. + +#### NS + +```go +func NS(owner string, ttl uint32, target string) (Record, error) +``` + +NS builds an IN NS record pointing at target. It returns an error when owner or target is empty or not ASCII. + +#### PTR + +```go +func PTR(owner string, ttl uint32, target string) (Record, error) +``` + +PTR builds an IN PTR record pointing at target. It returns an error when owner or target is empty or not ASCII. + +#### SRV + +```go +func SRV(owner string, ttl uint32, prio, weight, port uint16, target string) (Record, error) +``` + +SRV builds an IN SRV record. It returns an error when owner or target is empty or not ASCII. + +#### TXT + +```go +func TXT(owner string, ttl uint32, text string) (Record, error) +``` + +TXT builds an IN TXT record whose rdata is text. It returns an error when owner is empty or not ASCII. + +### TSIG + +```go +type TSIG struct { + Name string + Algorithm string + Secret string +} +``` + +TSIG is caller-supplied key material. The library does not generate keys or write BIND configuration. Name is the key name presented to the nameserver. Algorithm is a BIND name such as hmac-sha256 (also hmac-md5, hmac-sha1, hmac-sha224, hmac-sha384, and hmac-sha512). Secret is the base64 secret. + +### TransferResult + +```go +type TransferResult struct { + Zone Zone + Serial uint32 +} +``` + +TransferResult is a completed AXFR. Serial is the SOA serial and is the same value stored on Zone. + +### UpdateResult + +```go +type UpdateResult struct { + Rcode string +} +``` + +UpdateResult is a successful UPDATE response. Rcode is the nameserver rcode text, which is NOERROR on success. + +### Zone + +```go +type Zone struct { + Origin string + Serial uint32 + Records []Record +} +``` + +Zone is an in-memory zone: origin, SOA serial, and a flat record list. AXFR returns one. Callers can also assemble a Zone and call RRSets. + +#### Zone.RRSets + +```go +func (z Zone) RRSets() []RRSet +``` + +RRSets groups Zone records by owner, class, and type, in first-seen order. + diff --git a/docs/bind-tsig-configuration.md b/docs/bind-tsig-configuration.md index da7fba5..bc13180 100644 --- a/docs/bind-tsig-configuration.md +++ b/docs/bind-tsig-configuration.md @@ -144,6 +144,6 @@ Ensure `db.example.com` exists with a valid SOA and NS records before loading th ## Related documentation +- [Guide](guide.md) — how callers use AXFR, UPDATE, and TSIG +- [API](api.md) — exported symbols - [Project specification](project-specification.md) — library scope, TSIG model, AXFR-only transfers -- [Change 00001 spec](changes/00001-setup-project/spec.md) — full DNS operation catalog and BIND guide requirements -- [Product backlog](backlog.md) — future work (e.g. IXFR) diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..a39c183 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,210 @@ +# go-dns guide + +How to use the library. Symbol signatures and doc comments are in [API](api.md). Words for the domain are in [CONTEXT.md](../CONTEXT.md). Operators who configure BIND follow [BIND TSIG configuration](bind-tsig-configuration.md). + +## Session + +### Purpose + +A session is a `Client` bound to one nameserver address, one zone name, and one TSIG key. AXFR and UPDATE both use that session. There is no unsigned mode. + +### When to use it + +Create one client per nameserver, zone, and key. A second key, even for the same zone, is a second client. + +### Call sequence + +1. Fill a `TSIG` with the key name, algorithm, and base64 secret. +2. Call `NewClient` with the server, the zone, and that key. +3. Call `AXFR` or `Update` on the returned client. + +`server` is a host or `host:port`. A missing port becomes 53. An IPv6 host must be written in brackets, such as `[2001:db8::1]:53`. + +### Errors + +`NewClient` returns an error when the server, zone, or any TSIG field is empty, when the zone or the key name is not ASCII, or when the server is a bare IPv6 address. It does not contact the nameserver. + +### Example + +```go +key := godns.TSIG{ + Name: "exapp-key.", + Algorithm: "hmac-sha256", + Secret: "base64-secret-from-bind", +} +client, err := godns.NewClient("ns.example:53", "example.com.", key) +if err != nil { + return err +} +_ = client +``` + +## AXFR + +### Purpose + +AXFR reads the full zone over TCP and returns it as a `Zone` plus the SOA serial. + +### When to use it + +Use AXFR when the caller needs the nameserver's current records for the session's zone. Incremental transfer (IXFR) is not available. + +### Call sequence + +1. Start a session. +2. Call `Client.AXFR` with a context. +3. Read `TransferResult.Zone` and `TransferResult.Serial`. `Serial` is the same value stored on the zone. + +The request is signed with the session TSIG key. The closing SOA of the transfer is not appended a second time. + +### Errors + +`AXFR` returns an error when the context is done, the transfer fails, a record owner is not ASCII, or the stream contains no SOA. Transport errors are wrapped with the server address. + +### Example + +```go +tr, err := client.AXFR(ctx) +if err != nil { + return err +} +zone := tr.Zone +_ = zone.Serial +``` + +## UPDATE + +### Purpose + +UPDATE sends one RFC 2136 batch that adds records, deletes records, or does both. The nameserver applies the batch. + +### When to use it + +Use UPDATE to change the session's zone. Each change is one record, built with `Add` or `Delete`. + +### Call sequence + +1. Build records with the typed builders. +2. Wrap each record in `Add` or `Delete`. +3. Call `Client.Update` with a context and the changes. +4. On success, `UpdateResult.Rcode` is `NOERROR`. + +The client tries UDP first. It retries on TCP when the UDP exchange fails or the response is truncated. An empty change list returns success and does not contact the nameserver. `Delete` removes one record that matches owner, type, and rdata. It does not delete a whole RRset by type alone. + +### Errors + +`Update` returns an error when the context is done, a record cannot be encoded, the exchange fails, or the nameserver rcode is not `NOERROR`. An `Action` other than `ActionAdd` or `ActionDelete` is rejected before the exchange. A refused or badly signed update comes back as an rcode error such as `REFUSED` or `BADSIG`. + +### Example + +```go +rec, err := godns.A("www.example.com.", 300, net.ParseIP("192.0.2.10")) +if err != nil { + return err +} +result, err := client.Update(ctx, godns.Add(rec)) +if err != nil { + return err +} +_ = result.Rcode +``` + +## TSIG + +### Purpose + +TSIG is the caller-supplied key that signs AXFR and UPDATE. The library checks the fields and sends them. It does not generate keys or write BIND configuration. + +### When to use it + +Fill a `TSIG` before `NewClient`. The same value is used for every call on that client. + +### Call sequence + +1. Take the key name, algorithm, and base64 secret from the operator's BIND key. +2. Store them on `TSIG`. +3. Pass the value to `NewClient`. + +Algorithms the client maps to known TSIG names are `hmac-md5`, `hmac-sha1`, `hmac-sha224`, `hmac-sha256`, `hmac-sha384`, and `hmac-sha512`. Any other non-empty algorithm is sent as a trailing-dot name. Prefer `hmac-sha256` for new keys. How to create the key on BIND is in [BIND TSIG configuration](bind-tsig-configuration.md). + +### Errors + +A missing name, algorithm, or secret fails in `NewClient`, as does a non-ASCII key name. A wrong secret does not fail at construction. The nameserver reports it on AXFR or UPDATE, usually as `BADSIG`. + +### Example + +```go +key := godns.TSIG{ + Name: "go-dns-key.", + Algorithm: "hmac-sha256", + Secret: os.Getenv("TSIG_SECRET"), +} +client, err := godns.NewClient("192.0.2.53", "example.com", key) +``` + +## Typed RR builders + +### Purpose + +The builders turn an owner, a TTL, and typed fields into one `Record` of class `IN`. The owner is stored as a trailing-dot FQDN. `RData` is presentation-format rdata. + +### When to use it + +Use a builder when the caller has an address, a target name, or text and wants a record for UPDATE or for an in-memory zone. The builders are `A`, `AAAA`, `MX`, `TXT`, `CNAME`, `NS`, `SRV`, `PTR`, and `CAA`. + +### Call sequence + +1. Call the builder for the record type. +2. Pass the `Record` to `Add` or `Delete`, or store it on a `Zone`. + +`A` takes an IPv4 `net.IP`. `AAAA` takes an IPv6 `net.IP`. Name targets (`MX`, `CNAME`, `NS`, `SRV`, `PTR`) are ASCII domain names. `CAA` takes a flag, a tag, and a value (RFC 8659). + +### Errors + +A builder returns an error when the owner is empty or not ASCII. `A` returns an error when the address is not IPv4. `AAAA` returns an error when the address is missing or is IPv4. Builders that take a target name return an error when that name is empty or not ASCII. + +### Example + +```go +a, err := godns.A("www.example.com.", 300, net.ParseIP("192.0.2.10")) +if err != nil { + return err +} +mx, err := godns.MX("example.com.", 300, 10, "mail.example.com.") +if err != nil { + return err +} +_, err = client.Update(ctx, godns.Add(a), godns.Add(mx)) +``` + +## Zone, RRSet, and Record + +### Purpose + +`Record` is one resource record: owner, class, type, TTL, and presentation-format rdata. `Zone` is an origin, an SOA serial, and a flat record list. `RRSet` groups records that share owner, class, and type. + +### When to use it + +AXFR returns a `Zone`. Call `Zone.RRSets` when the caller wants records grouped. A caller can also assemble a `Zone` from builder output and group it the same way. Grouping does not contact the nameserver. + +### Call sequence + +1. Take `TransferResult.Zone` from AXFR, or fill `Zone.Origin`, `Zone.Serial`, and `Zone.Records`. +2. Call `RRSets` to get groups in first-seen order. +3. Read each group's `Owner`, `Class`, `Type`, and `Records`. + +### Errors + +`RRSets` does not return an error. It copies the records already on the zone. Bad names are reported earlier, by AXFR or by a builder. + +### Example + +```go +tr, err := client.AXFR(ctx) +if err != nil { + return err +} +for _, set := range tr.Zone.RRSets() { + fmt.Println(set.Owner, set.Type, len(set.Records)) +} +``` diff --git a/docs/project-specification.md b/docs/project-specification.md index b296ac8..164a050 100644 --- a/docs/project-specification.md +++ b/docs/project-specification.md @@ -4,18 +4,18 @@ **go-dns** is a Go library for authoritative DNS operations against **BIND** (and compatible servers): -- **Dynamic updates** — same semantics as `nsupdate` ([RFC 2136](https://www.rfc-editor.org/rfc/rfc2136)) +- **Dynamic updates** — RFC 2136 add and delete of records ([RFC 2136](https://www.rfc-editor.org/rfc/rfc2136)); prerequisite sections are out of scope - **Zone transfer** — full **AXFR** only ([RFC 5936](https://www.rfc-editor.org/rfc/rfc5936)); IXFR is out of scope for v1 - **TSIG authentication** — callers supply existing shared keys; key generation is out of scope -Callers perform updates and full zone reads in Go with typed **zone data structures** and **typed RR builders** instead of shelling out to `nsupdate` or `dig axfr`. +Callers perform updates and full zone reads in Go with an in-memory zone (`Record` rdata is presentation format) and **typed RR builders** instead of shelling out to `nsupdate` or `dig axfr`. ## Module metadata | Item | Value | |------|-------| | Module path | `gitea.neitzel.de/konrad/go-dns` | -| Go version | Workspace `go.work` (currently 1.26.4) | +| Go version | `go 1.27.0` in this module's `go.mod` | | Public package | **`godns`** (`package godns` at module root) | | Git remote | `ssh://git@gitea.neitzel.de:2222/konrad/go-dns.git` | @@ -34,21 +34,21 @@ This is a **Library** in the ExApp Workspace. Workspace Go rules live in `.curso ## Supported operation surface 1. **Session & TSIG key** — server, zone, transport, existing key (name, algorithm, secret) -2. **Dynamic update** — add, delete, prerequisites, batch UPDATE; typed RR builders +2. **Dynamic update** — add, delete, and batch UPDATE; typed RR builders (`A`, `AAAA`, `MX`, `TXT`, `CNAME`, `NS`, `SRV`, `PTR`, `CAA`) 3. **Zone transfer** — **AXFR** only; parse stream into zone data 4. **Zone data model** — `Record`, `RRSet`, `Zone`, `UpdateResult`, `TransferResult` 5. **BIND guide** — [bind-tsig-configuration.md](bind-tsig-configuration.md) (`allow-update` + `allow-transfer` for a TSIG key) ## API style -- **Typed builders** per RR type (e.g. A, AAAA, MX, SRV) — not a generic `(type, rdata string)` API -- Exact method names follow in later Workspace Issues after the catalog is agreed +- **Typed builders** per RR type (`A`, `AAAA`, `MX`, `TXT`, `CNAME`, `NS`, `SRV`, `PTR`, `CAA`) — not a generic `(type, rdata string)` API +- How to call them: [guide.md](guide.md). Every exported symbol: [api.md](api.md) ## Zone data model (logical) | Type | Purpose | |------|---------| -| **Record** | Single RR: owner, class, type, TTL, typed rdata | +| **Record** | Single RR: owner, class, type, TTL, presentation-format rdata | | **RRSet** | Group of RRs with same owner/class/type | | **Zone** | Origin + full record set + SOA/serial (complete zone in memory) | | **UpdateResult** | UPDATE response status | @@ -58,21 +58,24 @@ This is a **Library** in the ExApp Workspace. Workspace Go rules live in `.curso Operators configure BIND so a TSIG key may **update** and **transfer** a zone (`allow-update`, `allow-transfer`). Step-by-step instructions, security notes, and a minimal `named.conf` example: -**[docs/bind-tsig-configuration.md](bind-tsig-configuration.md)** +**[bind-tsig-configuration.md](bind-tsig-configuration.md)** ## Tests | Command | Purpose | |---------|---------| -| `go test ./...` | Unit tests (encoding, parsing, zone model, typed builders) | +| `go test ./...` | Unit tests (encoding, parsing, zone model, typed builders). Fails when `docs/api.md` is stale | +| `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1` | Rewrite `docs/api.md` after an exported API change | | `go test -tags=integration ./...` | **BIND testcontainer** (Docker required) | Follow the Workspace `.cursor/rules/go-testing.mdc`. This Library's integration image is BIND. -## Package layout (planned) +## Package layout ``` package godns # public API at module root -internal/ # RFC 2136, AXFR, TSIG, wire format -docs/ # Library spec and BIND TSIG guide +docs/guide.md # consumer guide +docs/api.md # generated exported API +docs/project-specification.md +docs/bind-tsig-configuration.md ``` diff --git a/record.go b/record.go index e09bf77..fdce9fd 100644 --- a/record.go +++ b/record.go @@ -8,7 +8,9 @@ import ( "github.com/miekg/dns" ) -// Record is a single resource record with presentation-format rdata. +// Record is one resource record. +// Owner is a trailing-dot FQDN. Class and Type are presentation names such as IN and A. +// RData is the presentation-format rdata, without the owner, TTL, class, or type. type Record struct { Owner string Class string @@ -25,14 +27,15 @@ type RRSet struct { Records []Record } -// Zone is a complete in-memory zone from AXFR (or assembled records). +// Zone is an in-memory zone: origin, SOA serial, and a flat record list. +// AXFR returns one. Callers can also assemble a Zone and call RRSets. type Zone struct { Origin string Serial uint32 Records []Record } -// RRSets groups Zone records by owner/class/type, preserving first-seen order. +// RRSets groups Zone records by owner, class, and type, in first-seen order. func (z Zone) RRSets() []RRSet { type key struct{ owner, class, typ string } order := make([]key, 0) @@ -51,7 +54,9 @@ func (z Zone) RRSets() []RRSet { return out } -// A builds an IPv4 address record. +// A builds an IN A record. +// It returns an error when ip is not IPv4, or when owner is empty or not ASCII. +// Owner is stored as a trailing-dot FQDN. func A(owner string, ttl uint32, ip net.IP) (Record, error) { v4 := ip.To4() if v4 == nil { @@ -60,7 +65,9 @@ func A(owner string, ttl uint32, ip net.IP) (Record, error) { return fromRR(&dns.A{Hdr: rrHeader(owner, dns.TypeA, ttl), A: v4}) } -// AAAA builds an IPv6 address record. +// AAAA builds an IN AAAA record. +// It returns an error when ip is missing, is IPv4, or when owner is empty or not ASCII. +// Owner is stored as a trailing-dot FQDN. 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") @@ -68,7 +75,8 @@ func AAAA(owner string, ttl uint32, ip net.IP) (Record, error) { return fromRR(&dns.AAAA{Hdr: rrHeader(owner, dns.TypeAAAA, ttl), AAAA: ip.To16()}) } -// MX builds a mail-exchanger record. +// MX builds an IN MX record for preference pref and exchanger target. +// It returns an error when owner or target is empty or not ASCII. func MX(owner string, ttl uint32, pref uint16, target string) (Record, error) { name, err := fqdnASCII(target) if err != nil { @@ -77,12 +85,14 @@ func MX(owner string, ttl uint32, pref uint16, target string) (Record, error) { return fromRR(&dns.MX{Hdr: rrHeader(owner, dns.TypeMX, ttl), Preference: pref, Mx: name}) } -// TXT builds a text record. +// TXT builds an IN TXT record whose rdata is text. +// It returns an error when owner is empty or not ASCII. 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. +// CNAME builds an IN CNAME record pointing at target. +// It returns an error when owner or target is empty or not ASCII. func CNAME(owner string, ttl uint32, target string) (Record, error) { name, err := fqdnASCII(target) if err != nil { @@ -91,7 +101,8 @@ func CNAME(owner string, ttl uint32, target string) (Record, error) { return fromRR(&dns.CNAME{Hdr: rrHeader(owner, dns.TypeCNAME, ttl), Target: name}) } -// NS builds a nameserver record. +// NS builds an IN NS record pointing at target. +// It returns an error when owner or target is empty or not ASCII. func NS(owner string, ttl uint32, target string) (Record, error) { name, err := fqdnASCII(target) if err != nil { @@ -100,7 +111,8 @@ func NS(owner string, ttl uint32, target string) (Record, error) { return fromRR(&dns.NS{Hdr: rrHeader(owner, dns.TypeNS, ttl), Ns: name}) } -// SRV builds a service locator record. +// SRV builds an IN SRV record. +// It returns an error when owner or target is empty or not ASCII. func SRV(owner string, ttl uint32, prio, weight, port uint16, target string) (Record, error) { name, err := fqdnASCII(target) if err != nil { @@ -115,7 +127,8 @@ func SRV(owner string, ttl uint32, prio, weight, port uint16, target string) (Re }) } -// PTR builds a pointer record. +// PTR builds an IN PTR record pointing at target. +// It returns an error when owner or target is empty or not ASCII. func PTR(owner string, ttl uint32, target string) (Record, error) { name, err := fqdnASCII(target) if err != nil { @@ -124,7 +137,8 @@ func PTR(owner string, ttl uint32, target string) (Record, error) { return fromRR(&dns.PTR{Hdr: rrHeader(owner, dns.TypePTR, ttl), Ptr: name}) } -// CAA builds a certification-authority authorization record (RFC 8659). +// CAA builds an IN CAA record (RFC 8659) with flag, tag, and value. +// It returns an error when owner is empty or not ASCII. func CAA(owner string, ttl uint32, flag uint8, tag, value string) (Record, error) { return fromRR(&dns.CAA{ Hdr: rrHeader(owner, dns.TypeCAA, ttl), diff --git a/update.go b/update.go index 046ad07..ae2fc89 100644 --- a/update.go +++ b/update.go @@ -14,33 +14,38 @@ type Action int const ( // ActionAdd inserts a record. ActionAdd Action = iota - // ActionDelete removes a matching RR (name, type, and rdata). + // ActionDelete removes one record that matches owner, type, and rdata. ActionDelete ) -// Change is a typed add or delete of one record. +// Change is one add or delete of one record inside an Update batch. type Change struct { Action Action Record Record } -// Add builds an insert change. +// Add builds an insert change for rec. func Add(rec Record) Change { return Change{Action: ActionAdd, Record: rec} } -// Delete builds a delete-by-rr change (name, type, and rdata). +// Delete builds a delete of one matching record (owner, type, and rdata). func Delete(rec Record) Change { return Change{Action: ActionDelete, Record: rec} } -// UpdateResult is the nameserver's response to an UPDATE. +// UpdateResult is a successful UPDATE response. +// Rcode is the nameserver rcode text, which is NOERROR on success. 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. +// Update sends an RFC 2136 UPDATE for changes, signed with the session TSIG key. +// It tries UDP, then TCP when the UDP exchange fails or the response is truncated. +// No changes returns success and does not contact the nameserver. +// It returns an error when ctx is done, a record cannot be encoded, the +// exchange fails, the nameserver rcode is not NOERROR, or Action is neither +// ActionAdd nor ActionDelete. func (c *Client) Update(ctx context.Context, changes ...Change) (*UpdateResult, error) { if err := ctx.Err(); err != nil { return nil, err