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:
co-authored by
Claude Opus 5.5
parent
b461fedab3
commit
cd69ff3aaf
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user