named.conf includes ddi-suite-keys.conf; a revision without keys file gets one without keys, and a revision with TSIG keys and a secondary zone whose primary is unreachable deploys while the zone waits for its transfer. The secondary directory gets the owner and mode of zone_dir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 lines
10 KiB
Go
289 lines
10 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/agent/core"
|
|
)
|
|
|
|
const (
|
|
bindConfDir = "/etc/bind/ddi-suite"
|
|
bindZoneDir = "/var/lib/bind/ddi-suite"
|
|
dnsConfig = testDir + "/agent-dns.yaml"
|
|
)
|
|
|
|
// namedConf is a distribution-like named.conf that includes the two DDI
|
|
// files. "notify no" in the options block lets a revision provoke a
|
|
// duplicate-option error that only the live configuration check sees.
|
|
const namedConf = `include "/etc/bind/rndc.key";
|
|
controls {
|
|
inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { "rndc-key"; };
|
|
};
|
|
options {
|
|
directory "/var/cache/bind";
|
|
pid-file "/run/named/named.pid";
|
|
listen-on { 127.0.0.1; };
|
|
listen-on-v6 { none; };
|
|
notify no;
|
|
include "` + bindConfDir + `/ddi-suite-options.conf";
|
|
};
|
|
include "` + bindConfDir + `/ddi-suite-keys.conf";
|
|
include "` + bindConfDir + `/ddi-suite.conf";
|
|
`
|
|
|
|
const dnsAgentConfig = `dns:
|
|
conf_dir: ` + bindConfDir + `
|
|
zone_dir: ` + bindZoneDir + `
|
|
named_conf: /etc/bind/named.conf
|
|
rndc_args: ["-k", "/etc/bind/rndc.key", "-s", "127.0.0.1", "-p", "953"]
|
|
dns_address: 127.0.0.1:53
|
|
command_timeout: 60s
|
|
`
|
|
|
|
// The container prepares the directories the way an installer would
|
|
// (configuration root:bind, zones bind:bind) and runs named in the
|
|
// foreground with logs on stderr.
|
|
const bindScript = `set -e
|
|
mkdir -p ` + bindZoneDir + `
|
|
chown bind:bind ` + bindZoneDir + `
|
|
chmod 750 ` + bindZoneDir + `
|
|
chown -R root:bind ` + bindConfDir + `
|
|
chmod 750 ` + bindConfDir + `
|
|
chmod 640 ` + bindConfDir + `/*.conf /etc/bind/rndc.key
|
|
chgrp bind /etc/bind/rndc.key
|
|
exec named -u bind -g -c /etc/bind/named.conf
|
|
`
|
|
|
|
func rndcKey(t *testing.T) []byte {
|
|
t.Helper()
|
|
secret := make([]byte, 32)
|
|
if _, err := rand.Read(secret); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return []byte(fmt.Sprintf("key \"rndc-key\" {\n\talgorithm hmac-sha256;\n\tsecret %q;\n};\n",
|
|
base64.StdEncoding.EncodeToString(secret)))
|
|
}
|
|
|
|
// dnsZone is a zone of a fixture revision.
|
|
type dnsZone struct {
|
|
name string
|
|
dynamic bool
|
|
// secondary zones transfer from 192.0.2.1 (unreachable) with the key
|
|
// "xfer"; they have no zone file.
|
|
secondary bool
|
|
records string
|
|
}
|
|
|
|
// dnsRevision renders generator-like files (see the server's bindgen
|
|
// golden files) for the zones with the given SOA serial.
|
|
func dnsRevision(zones []dnsZone, serial uint64, options string) (map[string]string, []string) {
|
|
var conf strings.Builder
|
|
conf.WriteString("// Generated by DDI Suite (test fixture). Do not edit.\n")
|
|
for _, z := range zones {
|
|
if z.dynamic {
|
|
fmt.Fprintf(&conf, "\nacl \"ddi-update-%s\" { 127.0.0.1/32; };\n", z.name)
|
|
}
|
|
}
|
|
files := map[string]string{"ddi-suite-options.conf": "// Generated by DDI Suite (test fixture).\n\n" + options}
|
|
var createOnly []string
|
|
for _, z := range zones {
|
|
if z.secondary {
|
|
fmt.Fprintf(&conf, "\nzone %q {\n type secondary;\n file \"%s/secondary/db.%s\";\n"+
|
|
" primaries { 192.0.2.1 key \"xfer\"; };\n notify no;\n};\n", z.name, bindZoneDir, z.name)
|
|
continue
|
|
}
|
|
update := "none"
|
|
if z.dynamic {
|
|
update = fmt.Sprintf("%q", "ddi-update-"+z.name)
|
|
}
|
|
fmt.Fprintf(&conf, "\nzone %q {\n type primary;\n file \"%s/db.%s\";\n notify no;\n allow-update { %s; };\n};\n",
|
|
z.name, bindZoneDir, z.name, update)
|
|
p := "zones/db." + z.name
|
|
files[p] = fmt.Sprintf("; Zone %s, generated by DDI Suite (test fixture).\n$ORIGIN %s.\n$TTL 3600\n"+
|
|
"@ IN SOA ns1.office.test. hostmaster.office.test. %d 3600 600 1209600 300\n"+
|
|
"@ IN NS ns1.office.test.\n%s", z.name, z.name, serial, z.records)
|
|
if z.dynamic {
|
|
createOnly = append(createOnly, p)
|
|
}
|
|
}
|
|
files["ddi-suite.conf"] = conf.String()
|
|
return files, createOnly
|
|
}
|
|
|
|
const defaultOptions = "recursion no;\nallow-transfer { none; };\n"
|
|
|
|
func TestDNSRole(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) { testDNSRole(t, image, driver) })
|
|
}
|
|
}
|
|
|
|
func testDNSRole(t *testing.T, image string, driver []byte) {
|
|
office := dnsZone{name: "office.test", records: "ns1 IN A 192.0.2.53\nwww IN A 192.0.2.10\n" +
|
|
"@ IN MX 10 mail.office.test.\nmail IN A 192.0.2.25\n"}
|
|
reverse := dnsZone{name: "2.0.192.in-addr.arpa", records: "10 IN PTR www.office.test.\n53 IN PTR ns1.office.test.\n"}
|
|
dynamic := dnsZone{name: "ad.office.test", dynamic: true, records: "dc1 IN A 192.0.2.20\n"}
|
|
|
|
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)
|
|
|
|
files1, co1 := dnsRevision([]dnsZone{office, reverse, dynamic}, 1, defaultOptions)
|
|
rev1 := tr.revision("1", 1, files1, co1...)
|
|
office2 := office
|
|
office2.records += "intranet IN CNAME www.office.test.\n"
|
|
dynamic2 := dynamic
|
|
dynamic2.records += "dc2 IN A 192.0.2.21\n"
|
|
files2, co2 := dnsRevision([]dnsZone{office2, dynamic2}, 2, defaultOptions) // reverse zone dropped
|
|
rev2 := tr.revision("2", 2, files2, co2...)
|
|
files2Options := files2["ddi-suite-options.conf"]
|
|
|
|
broken, coB := dnsRevision([]dnsZone{office2, dynamic2}, 3, defaultOptions)
|
|
broken["zones/db.office.test"] += "broken IN A not-an-address\n"
|
|
revBroken := tr.revision("broken", 3, broken, coB...)
|
|
|
|
mismatch, coM := dnsRevision([]dnsZone{office2}, 3, defaultOptions)
|
|
mismatch["ddi-suite.conf"] = strings.ReplaceAll(mismatch["ddi-suite.conf"], bindZoneDir, "/etc/bind/zones")
|
|
revMismatch := tr.revision("mismatch", 3, mismatch, coM...)
|
|
|
|
dup, coD := dnsRevision([]dnsZone{office2, dynamic2}, 4, defaultOptions+"notify no;\n")
|
|
revDup := tr.revision("dup", 4, dup, coD...)
|
|
|
|
// Serial 99 in revision 5: valid, but the health check must fail.
|
|
stale, coS := dnsRevision([]dnsZone{office2, dynamic2}, 99, defaultOptions)
|
|
revStale := tr.revision("stale", 5, stale, coS...)
|
|
|
|
// Revision 6 (M5) has TSIG keys and a secondary zone whose primary is
|
|
// unreachable.
|
|
keys := "// Generated by DDI Suite (test fixture).\n\nkey \"xfer\" {\n algorithm hmac-sha256;\n secret \"" +
|
|
base64.StdEncoding.EncodeToString([]byte("xfer-secret-of-the-test-cluster!")) + "\";\n};\n"
|
|
clusterFiles, coC := dnsRevision([]dnsZone{office2, dynamic2, {name: "remote.test", secondary: true}}, 6, defaultOptions)
|
|
clusterFiles["ddi-suite-keys.conf"] = keys
|
|
revCluster := tr.revision("cluster", 6, clusterFiles, coC...)
|
|
|
|
c := startContainer(t, image, tr, bindScript)
|
|
if r := c.driver("dns", dnsConfig, "wait"); r.Error != "" {
|
|
t.Fatalf("named did not start: %+v", r)
|
|
}
|
|
|
|
t.Run("version", func(t *testing.T) {
|
|
r := c.driver("dns", dnsConfig, "version")
|
|
if r.Error != "" || !strings.HasPrefix(r.Version, "BIND 9.") {
|
|
t.Fatalf("version = %q, error %q", r.Version, r.Error)
|
|
}
|
|
})
|
|
|
|
r := c.driver("dns", dnsConfig, "apply", rev1)
|
|
if r.Outcome != "succeeded" {
|
|
t.Fatalf("revision 1: %+v %+v", r, r.Health)
|
|
}
|
|
assertZones(t, r.Health, 2)
|
|
if !c.exists(bindZoneDir + "/db.2.0.192.in-addr.arpa") {
|
|
t.Fatal("reverse zone file missing after revision 1")
|
|
}
|
|
if got := c.cat(bindConfDir + "/ddi-suite-keys.conf"); !strings.Contains(got, "No TSIG keys") {
|
|
t.Errorf("revision without keys file: keys file is\n%s", got)
|
|
}
|
|
|
|
r = c.driver("dns", dnsConfig, "apply", rev2, rev1)
|
|
if r.Outcome != "succeeded" {
|
|
t.Fatalf("revision 2: %+v %+v", r, r.Health)
|
|
}
|
|
assertZones(t, r.Health, 1)
|
|
if c.exists(bindZoneDir + "/db.2.0.192.in-addr.arpa") {
|
|
t.Error("zone file of the dropped reverse zone still exists")
|
|
}
|
|
if dyn := c.cat(bindZoneDir + "/db.ad.office.test"); strings.Contains(dyn, "dc2") || !strings.Contains(dyn, "dc1") {
|
|
t.Errorf("create-only zone file was overwritten:\n%s", dyn)
|
|
}
|
|
|
|
t.Run("broken zone is rejected", func(t *testing.T) {
|
|
r := c.driver("dns", dnsConfig, "apply", revBroken, rev2)
|
|
if r.Outcome != "rejected" || !strings.Contains(r.Error, "office.test") {
|
|
t.Fatalf("got %+v", r)
|
|
}
|
|
})
|
|
t.Run("zone directory mismatch is rejected", func(t *testing.T) {
|
|
r := c.driver("dns", dnsConfig, "apply", revMismatch, rev2)
|
|
if r.Outcome != "rejected" || !strings.Contains(r.Error, "dns.zone_dir") {
|
|
t.Fatalf("got %+v", r)
|
|
}
|
|
})
|
|
t.Run("failed live check rolls back", func(t *testing.T) {
|
|
r := c.driver("dns", dnsConfig, "apply", revDup, rev2)
|
|
if r.Outcome != "rolled_back" || !strings.Contains(r.Error, "activate") {
|
|
t.Fatalf("got %+v", r)
|
|
}
|
|
if got := c.cat(bindConfDir + "/ddi-suite-options.conf"); got != files2Options {
|
|
t.Errorf("options not restored:\n%s", got)
|
|
}
|
|
})
|
|
t.Run("failed health check rolls back", func(t *testing.T) {
|
|
r := c.driver("dns", dnsConfig, "-health-timeout", "10s", "apply", revStale, rev2)
|
|
if r.Outcome != "rolled_back" || !strings.Contains(r.Error, "health") {
|
|
t.Fatalf("got %+v health %+v", r, r.Health)
|
|
}
|
|
assertZones(t, r.Rollback, 1)
|
|
})
|
|
|
|
r = c.driver("dns", dnsConfig, "health", rev2)
|
|
if r.Health == nil || r.Health.State != core.HealthOK {
|
|
t.Fatalf("health of revision 2: %+v", r.Health)
|
|
}
|
|
|
|
// The secondary zone waits for its transfer without failing the
|
|
// deployment; BIND reads the keys; the secondary directory belongs to
|
|
// bind like zone_dir.
|
|
r = c.driver("dns", dnsConfig, "apply", revCluster, rev2)
|
|
if r.Outcome != "succeeded" {
|
|
t.Fatalf("revision with keys and a secondary zone: %+v %+v", r, r.Health)
|
|
}
|
|
assertZones(t, r.Health, 1)
|
|
waiting := false
|
|
for _, ch := range r.Health.Checks {
|
|
if ch.Name == "secondary zone remote.test" && ch.State == core.HealthDegraded {
|
|
waiting = true
|
|
}
|
|
}
|
|
if !waiting {
|
|
t.Errorf("no degraded check for the secondary zone: %+v", r.Health.Checks)
|
|
}
|
|
if got := c.cat(bindConfDir + "/ddi-suite-keys.conf"); got != keys {
|
|
t.Errorf("keys file:\n%s", got)
|
|
}
|
|
if out, all, ok := c.exec("stat", "-c", "%U:%G %a", bindZoneDir+"/secondary"); !ok || strings.TrimSpace(out) != "bind:bind 750" {
|
|
t.Errorf("secondary directory: %q %s", out, all)
|
|
}
|
|
}
|
|
|
|
// assertZones checks an OK health result with n zone checks.
|
|
func assertZones(t *testing.T, h *health, n int) {
|
|
t.Helper()
|
|
if h == nil || h.State != core.HealthOK {
|
|
t.Fatalf("health = %+v", h)
|
|
}
|
|
zones := 0
|
|
for _, c := range h.Checks {
|
|
if strings.HasPrefix(c.Name, "zone ") {
|
|
zones++
|
|
}
|
|
}
|
|
if zones != n {
|
|
t.Errorf("%d zone checks, want %d: %+v", zones, n, h.Checks)
|
|
}
|
|
}
|