test(agent): dynamic records against real BIND

Revision 1 adds managed records to a dynamic zone by update; revision 2
removes and adds one while records of a third party (also at a managed
name) stay; a revision whose update key named does not accept rolls
back; a revision without managed records deletes only DDI's records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sir_melonchen
2026-10-17 05:49:35 +02:00
co-authored by Claude Opus 5.5
parent b461fedab3
commit cd69ff3aaf
+203
View File
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: Apache-2.0
//go:build integration
package integration
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"slices"
"strings"
"testing"
)
const updateKeyName = "ddi-agent-update"
// dynRecord mirrors a record of dynamic-records.json (ADR 0026).
type dynRecord struct {
Name string `json:"name"`
TTL int `json:"ttl"`
Type string `json:"type"`
Data string `json:"data"`
}
func tsigSecret(t *testing.T) string {
t.Helper()
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(b)
}
func keyStmt(name, secret string) string {
return fmt.Sprintf("key %q {\n algorithm hmac-sha256;\n secret %q;\n};\n", name, secret)
}
// dynamicRevision renders a revision with the managed zone office.test and
// the dynamic zone ad.office.test whose updates need the key
// ddi-agent-update (the agent) or dc (a "domain controller"). records nil
// means no update files.
func dynamicRevision(t *testing.T, serial uint64, keysConf, updateKey string, records []dynRecord) (map[string]string, []string) {
t.Helper()
office := dnsZone{name: "office.test", records: "ns1 IN A 192.0.2.53\nwww IN A 192.0.2.10\n"}
ad := dnsZone{name: "ad.office.test", dynamic: true}
files, createOnly := dnsRevision([]dnsZone{office, ad}, serial, defaultOptions)
acl := `acl "ddi-update-ad.office.test" { 127.0.0.1/32; };`
if !strings.Contains(files["ddi-suite.conf"], acl) {
t.Fatalf("fixture changed: no update ACL in\n%s", files["ddi-suite.conf"])
}
files["ddi-suite.conf"] = strings.Replace(files["ddi-suite.conf"], acl,
`acl "ddi-update-ad.office.test" { key "`+updateKeyName+`"; key "dc"; };`, 1)
files["ddi-suite-keys.conf"] = "// Generated by DDI Suite (test fixture).\n\n" + keysConf
if records != nil {
files["ddi-suite-update.key"] = "// Generated by DDI Suite (test fixture).\n\n" + updateKey
doc := map[string]any{"version": 1, "key": updateKeyName,
"zones": []any{map[string]any{"zone": "ad.office.test", "records": records}}}
data, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
files["dynamic-records.json"] = string(data)
}
return files, createOnly
}
func TestDNSDynamicRecords(t *testing.T) {
driver := driverBinary(t)
for _, image := range images("DDI_BIND_IMAGES", "internetsystemsconsortium/bind9:9.18 internetsystemsconsortium/bind9:9.20") {
t.Run(image, func(t *testing.T) { testDNSDynamicRecords(t, image, driver) })
}
}
func testDNSDynamicRecords(t *testing.T, image string, driver []byte) {
agentSecret, dcSecret := tsigSecret(t), tsigSecret(t)
agentKey := keyStmt(updateKeyName, agentSecret)
keysConf := agentKey + keyStmt("dc", dcSecret)
a := func(name, ip string) dynRecord {
return dynRecord{Name: name + ".ad.office.test.", TTL: 3600, Type: "A", Data: ip}
}
srv := dynRecord{Name: "_ldap._tcp.dc._msdcs.ad.office.test.", TTL: 600, Type: "SRV", Data: "0 100 389 dc1.ad.office.test."}
txt := dynRecord{Name: "_dmarc.ad.office.test.", TTL: 300, Type: "TXT", Data: `"v=DMARC1; p=none"`}
tr := newTree(t)
tr.file("/etc/bind/named.conf", []byte(namedConf), 0o644)
tr.file("/etc/bind/rndc.key", rndcKey(t), 0o640)
tr.file(bindConfDir+"/ddi-suite.conf", []byte("// bootstrap\n"), 0o640)
tr.file(bindConfDir+"/ddi-suite-keys.conf", []byte("// bootstrap\n"), 0o640)
tr.file(bindConfDir+"/ddi-suite-options.conf", []byte("// bootstrap\n"), 0o640)
tr.file(driverPath, driver, 0o755)
tr.file(dnsConfig, []byte(dnsAgentConfig), 0o644)
// Revision 1 creates the dynamic zone (its initial file has no managed
// records: they come by dynamic update).
files1, co1 := dynamicRevision(t, 1, keysConf, agentKey, []dynRecord{a("srv1", "192.0.2.5"), a("srv2", "192.0.2.6"), srv, txt})
rev1 := tr.revision("dyn1", 1, files1, co1...)
// Revision 2 drops srv2 and adds srv3.
files2, co2 := dynamicRevision(t, 2, keysConf, agentKey, []dynRecord{a("srv1", "192.0.2.5"), a("srv3", "192.0.2.7"), srv, txt})
rev2 := tr.revision("dyn2", 2, files2, co2...)
// Revision 3 gives named another secret for the update key than
// nsupdate uses: the updates fail and the revision is rolled back.
files3, co3 := dynamicRevision(t, 3, keyStmt(updateKeyName, tsigSecret(t))+keyStmt("dc", dcSecret), agentKey,
[]dynRecord{a("srv1", "192.0.2.5"), a("srv4", "192.0.2.8")})
rev3 := tr.revision("dyn3", 3, files3, co3...)
// Revision 4 still serves the zone but manages no records in it.
files4, co4 := dynamicRevision(t, 4, keysConf, agentKey, []dynRecord{})
rev4 := tr.revision("dyn4", 4, files4, co4...)
c := startContainer(t, image, tr, bindScript)
for _, prog := range []string{"nsupdate", "dig"} {
if _, all, ok := c.exec("sh", "-c", "command -v "+prog); !ok {
t.Fatalf("%s is missing in %s: %s", prog, image, all)
}
}
if r := c.driver("dns", dnsConfig, "wait"); r.Error != "" {
t.Fatalf("named did not start: %+v", r)
}
dig := func(name, typ string) []string {
t.Helper()
out, all, ok := c.exec("dig", "@127.0.0.1", "+short", "+time=2", "+tries=2", name, typ)
if !ok {
t.Fatalf("dig %s %s: %s", name, typ, all)
}
var res []string
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
if l = strings.TrimSpace(l); l != "" {
res = append(res, l)
}
}
slices.Sort(res)
return res
}
expect := func(name, typ string, want ...string) {
t.Helper()
slices.Sort(want)
if got := dig(name, typ); !slices.Equal(got, want) {
t.Errorf("%s %s = %q, want %q", name, typ, got, want)
}
}
r := c.driver("dns", dnsConfig, "apply", rev1)
if r.Outcome != "succeeded" {
t.Fatalf("revision 1: %+v %+v", r, r.Health)
}
expect("srv1.ad.office.test", "A", "192.0.2.5")
expect("srv2.ad.office.test", "A", "192.0.2.6")
expect("_ldap._tcp.dc._msdcs.ad.office.test", "SRV", "0 100 389 dc1.ad.office.test.")
expect("_dmarc.ad.office.test", "TXT", `"v=DMARC1; p=none"`)
for _, f := range []string{"ddi-suite-update.key", "dynamic-records.json"} {
if c.exists(bindConfDir + "/" + f) {
t.Errorf("%s was installed into conf_dir", f)
}
}
if dyn := c.cat(bindZoneDir + "/db.ad.office.test"); strings.Contains(dyn, "srv1") {
t.Errorf("initial file of the dynamic zone already has srv1:\n%s", dyn)
}
// A domain controller registers itself and adds a second address to a
// managed name.
// printf expands the \n escapes of its format argument.
update := `server 127.0.0.1\nzone ad.office.test\n` +
`update add dc1.ad.office.test. 300 A 192.0.2.20\n` +
`update add srv2.ad.office.test. 300 A 192.0.2.99\nsend\n`
if _, all, ok := c.exec("sh", "-c", `printf "$1" | nsupdate -y "$2"`, "sh", update, "hmac-sha256:dc:"+dcSecret); !ok {
t.Fatalf("third-party update failed: %s", all)
}
expect("srv2.ad.office.test", "A", "192.0.2.6", "192.0.2.99")
r = c.driver("dns", dnsConfig, "apply", rev2, rev1)
if r.Outcome != "succeeded" {
t.Fatalf("revision 2: %+v %+v", r, r.Health)
}
expect("srv1.ad.office.test", "A", "192.0.2.5")
expect("srv2.ad.office.test", "A", "192.0.2.99") // only DDI's address is gone
expect("srv3.ad.office.test", "A", "192.0.2.7")
expect("dc1.ad.office.test", "A", "192.0.2.20")
expect("_dmarc.ad.office.test", "TXT", `"v=DMARC1; p=none"`)
t.Run("failed update rolls back", func(t *testing.T) {
r := c.driver("dns", dnsConfig, "apply", rev3, rev2)
if r.Outcome != "rolled_back" || !strings.Contains(r.Error, "dynamic update of zone ad.office.test") {
t.Fatalf("got %+v", r)
}
if got := c.cat(bindConfDir + "/ddi-suite-keys.conf"); !strings.Contains(got, agentSecret) {
t.Error("keys file not restored")
}
expect("srv4.ad.office.test", "A")
expect("srv3.ad.office.test", "A", "192.0.2.7")
})
r = c.driver("dns", dnsConfig, "apply", rev4, rev2)
if r.Outcome != "succeeded" {
t.Fatalf("revision 4: %+v %+v", r, r.Health)
}
expect("srv1.ad.office.test", "A")
expect("srv3.ad.office.test", "A")
expect("_ldap._tcp.dc._msdcs.ad.office.test", "SRV")
expect("_dmarc.ad.office.test", "TXT")
expect("srv2.ad.office.test", "A", "192.0.2.99")
expect("dc1.ad.office.test", "A", "192.0.2.20")
}