diff --git a/agent/role/integration/dns_dynamic_test.go b/agent/role/integration/dns_dynamic_test.go new file mode 100644 index 0000000..29deb01 --- /dev/null +++ b/agent/role/integration/dns_dynamic_test.go @@ -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") +}