test(configgen): nsupdate with the generated update key against named

Starts named with a revision whose dynamic zones still hold the previous
initial files, applies dynamic-records.json with nsupdate -k as the DNS
agent will, and checks the records with dig, the per-record delete next
to a foreign record, and that unsigned updates from 127.0.0.1 are still
refused. The cluster check now covers the update key on the primary only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sir_melonchen
2026-10-17 05:55:12 +02:00
co-authored by Claude Opus 5.5
parent 174d371392
commit 3534076e1c
2 changed files with 299 additions and 3 deletions
@@ -43,7 +43,9 @@ func clusterDNSSource() configgen.Source {
src.Keys = []configgen.KeySource{
{ID: "k-xfer", Name: "xfer.ddi-test", Algorithm: "hmac-sha256", Secret: secret(1, 32)},
{ID: "k-upd", Name: "update.ddi-test", Algorithm: "hmac-sha512", Secret: secret(7, 64)},
{ID: "k-agent", Name: "ddi-agent-update", Algorithm: "hmac-sha256", Secret: secret(42, 32)},
}
src.UpdateKeyID = "k-agent"
src.Clusters = []configgen.ClusterSource{{
ID: "c-dns", Name: "dns", Role: "dns", TransferKeyID: "k-xfer",
Members: []configgen.ClusterMember{
@@ -82,9 +84,11 @@ func clusterTargets(t *testing.T) (primary, secondary configgen.DNSConfig, prima
if secondaryArts, err = bindgen.Render(secondary); err != nil {
t.Fatalf("render secondary: %v", err)
}
if len(primary.Zones) < 4 || len(primary.Keys) != 2 || len(secondary.Keys) != 1 || len(secondaryArts) != 3 {
t.Fatalf("fixture too small: %d zones, %d/%d keys, %d secondary files",
len(primary.Zones), len(primary.Keys), len(secondary.Keys), len(secondaryArts))
// Only the primary keeps the dynamic zone up to date (ADR 0026).
if len(primary.Zones) < 4 || len(primary.Keys) != 3 || primary.UpdateKey != "ddi-agent-update" ||
len(secondary.Keys) != 1 || secondary.UpdateKey != "" || len(secondaryArts) != 3 {
t.Fatalf("fixture too small: %d zones, %d/%d keys, update keys %q/%q, %d secondary files",
len(primary.Zones), len(primary.Keys), len(secondary.Keys), primary.UpdateKey, secondary.UpdateKey, len(secondaryArts))
}
return primary, secondary, primaryArts, secondaryArts
}
@@ -104,6 +108,8 @@ func TestBINDAcceptsClusterConfig(t *testing.T) {
var b strings.Builder
b.WriteString("set -e\nmkdir -p " + secZoneDir + "/secondary\n")
b.WriteString("named-checkconf -z /etc/bind/named.conf\nnamed-checkconf -z /etc/bind/secondary.conf\n")
// The update key file is a valid named.conf on its own.
b.WriteString("named-checkconf " + bindConfDir + "/" + configgen.UpdateKeyFile + "\n")
for name, file := range zones {
b.WriteString("named-checkzone -k fail -m fail -M fail -S fail -i local " + name + " " + file + "\n")
}
@@ -0,0 +1,290 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
//go:build integration
package integration
import (
"encoding/json"
"fmt"
"net/netip"
"path/filepath"
"slices"
"strings"
"testing"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen/bindgen"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen/derive"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
)
// dynamicRecords is configgen.DynamicRecordsFile (ADR 0026), decoded the
// way the DNS agent reads it.
type dynamicRecords struct {
Version int `json:"version"`
Key string `json:"key"`
Zones []struct {
Zone string `json:"zone"`
Records []dynRecords `json:"records"`
} `json:"zones"`
}
type dynRecords struct {
Name string `json:"name"`
TTL uint32 `json:"ttl"`
Type string `json:"type"`
Data string `json:"data"`
}
func (r dynRecords) key() string { return r.Name + " " + r.Type + " " + r.Data }
// nsupdateScripts turns the records of the previous and the new revision
// into one nsupdate transaction per zone of next, as the DNS agent does
// (ADR 0026): delete exactly the records only prev lists, add every record
// of next.
func nsupdateScripts(server string, prev, next dynamicRecords) map[string]string {
old := map[string][]dynRecords{}
for _, z := range prev.Zones {
old[z.Zone] = z.Records
}
out := map[string]string{}
for _, z := range next.Zones {
var b strings.Builder
fmt.Fprintf(&b, "server %s\nzone %s\n", server, z.Zone)
keep := map[string]bool{}
for _, r := range z.Records {
keep[r.key()] = true
}
for _, r := range old[z.Zone] {
if !keep[r.key()] {
fmt.Fprintf(&b, "update delete %s %s %s\n", r.Name, r.Type, r.Data)
}
}
for _, r := range z.Records {
fmt.Fprintf(&b, "update add %s %d %s %s\n", r.Name, r.TTL, r.Type, r.Data)
}
b.WriteString("send\n")
out[z.Zone] = b.String()
}
return out
}
// shellQuote quotes s for sh.
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
// dynamicSource is the sample data with a dynamic forward zone (Active
// Directory style) and a dynamic reverse zone, both kept up to date by the
// agent's update key; updated is the next revision with changed records.
func dynamicSource(updated bool) configgen.Source {
src := sampleSource()
src.Version = configgen.SourceVersion
src.Keys = []configgen.KeySource{
{ID: "k-upd", Name: "update.ddi-test", Algorithm: "hmac-sha512", Secret: secret(7, 64)},
{ID: "k-agent", Name: "ddi-agent-update", Algorithm: "hmac-sha256", Secret: secret(42, 32)},
}
src.UpdateKeyID = "k-agent"
ad := ipam.Network{ID: "n-ad", CIDR: netip.MustParsePrefix("10.20.0.0/24"), Name: "AD", Domain: "ad.office.test"}
src.Networks = append(src.Networks, ad)
// Updates from the domain controller's address only, never from
// 127.0.0.1: the agent needs its key.
allow := []netip.Prefix{netip.MustParsePrefix("192.0.2.10/32")}
src.Zones = []configgen.ZoneSource{
{ID: "z-ad", Name: "ad.office.test", Kind: configgen.ZoneForward, Mode: configgen.ZoneDynamic,
AllowUpdate: allow, UpdateKeyIDs: []string{"k-upd"}},
{ID: "z-rev", Name: "2.0.192.in-addr.arpa", Kind: configgen.ZoneReverse, Mode: configgen.ZoneDynamic, AllowUpdate: allow},
}
rec := func(r configgen.Record) configgen.RecordSource {
return configgen.RecordSource{ZoneID: "z-ad", Record: r}
}
src.Records = []configgen.RecordSource{
rec(configgen.Record{Name: "dc1", Type: configgen.TypeA, Addr: netip.MustParseAddr("10.20.0.10")}),
}
if !updated {
src.Records = append(src.Records,
rec(configgen.Record{Name: "old", Type: configgen.TypeA, Addr: netip.MustParseAddr("10.20.0.99")}))
return src
}
src.Records = append(src.Records,
rec(configgen.Record{Name: "_ldap._tcp.dc._msdcs", Type: configgen.TypeSRV, Priority: 0, Weight: 100, Port: 389,
Target: "dc1.ad.office.test"}),
// named refuses updates adding an MX whose target inside the zone
// has no address (check-integrity), so the target is dc1.
rec(configgen.Record{Name: "@", Type: configgen.TypeMX, Preference: 10, Target: "dc1.ad.office.test"}),
rec(configgen.Record{Name: "files", TTL: 600, Type: configgen.TypeCNAME, Target: "srv1.ad.office.test"}),
rec(configgen.Record{Name: "info", Type: configgen.TypeTXT, Text: []string{
"v=spf1 -all", `quote " and backslash \ caf` + "\u00e9 tab\t" + strings.Repeat("x", 300)}}),
)
src.Addresses = append(src.Addresses,
ipam.Address{NetworkID: ad.ID, IP: netip.MustParseAddr("10.20.0.5"), Type: ipam.TypeStatic, Hostname: "srv1",
FQDN: "srv1.ad.office.test"},
ipam.Address{NetworkID: "n-office", IP: netip.MustParseAddr("192.0.2.77"), Type: ipam.TypeStatic, Hostname: "new",
FQDN: "new.office.test"},
)
return src
}
// renderDynamic renders the default target of dynamicSource and decodes its
// dynamic records.
func renderDynamic(t *testing.T, updated bool) ([]configgen.Artifact, dynamicRecords) {
t.Helper()
targets, warnings := derive.BuildDNS(dynamicSource(updated))
t.Logf("derive warnings: %+v", warnings)
if targets[0].Config.UpdateKey != "ddi-agent-update" {
t.Fatalf("no update key: %+v", targets[0].Config)
}
arts, err := bindgen.Render(targets[0].Config)
if err != nil {
t.Fatalf("render: %v", err)
}
var doc dynamicRecords
for _, a := range arts {
if a.Path == configgen.DynamicRecordsFile {
if err := json.Unmarshal(a.Content, &doc); err != nil {
t.Fatal(err)
}
}
}
if doc.Version != 1 || doc.Key != "ddi-agent-update" || len(doc.Zones) != 2 {
t.Fatalf("dynamic records %+v", doc)
}
return arts, doc
}
// TestBINDDynamicUpdates runs named with the files of a revision whose
// dynamic zones still hold the initial files of the previous revision, and
// applies the new records with "nsupdate -k ddi-suite-update.key" as the
// DNS agent does (ADR 0026): new records appear, a record DDI no longer
// owns disappears, a foreign record at the same name stays, and an
// unsigned update from 127.0.0.1 is still refused.
func TestBINDDynamicUpdates(t *testing.T) {
prevArts, prev := renderDynamic(t, false)
nextArts, next := renderDynamic(t, true)
root := t.TempDir()
writeBIND(t, root, bindConfDir, prevArts)
var current []configgen.Artifact
for _, a := range nextArts {
if !a.CreateOnly { // BIND owns the files of dynamic zones
current = append(current, a)
}
}
writeBIND(t, root, bindConfDir, current)
opts := "\tdirectory \"/var/cache/bind\";\n\tlisten-on { 127.0.0.1; };\n\tlisten-on-v6 { none; };\n" +
"\tpid-file none;\n\tsession-keyfile none;\n"
writeFile(t, filepath.Join(root, "etc", "bind", "named.conf"), []byte("controls { };\n"+namedConf(bindConfDir, opts)))
scripts := nsupdateScripts("127.0.0.1", prev, next)
zones := make([]string, 0, len(scripts))
for zone, s := range scripts {
writeFile(t, filepath.Join(root, "ddi", "sync-"+zone+".txt"), []byte(s))
zones = append(zones, zone)
}
slices.Sort(zones)
var checks strings.Builder
n := 0
for _, z := range next.Zones {
for _, r := range z.Records {
fmt.Fprintf(&checks, "present %s %s %s\n", shellQuote(r.Name), r.Type, shellQuote(r.Data))
n++
}
}
if n < 10 {
t.Fatalf("only %d records to check", n)
}
keyFile := bindConfDir + "/" + configgen.UpdateKeyFile
script := `set -e
mkdir -p /var/cache/bind
named-checkconf -z /etc/bind/named.conf
named-checkconf ` + keyFile + `
named -g -c /etc/bind/named.conf >/tmp/named.log 2>&1 &
i=0
until grep -q 'running' /tmp/named.log; do
if [ $i -ge 30 ]; then
echo "--- named did not start ---"; cat /tmp/named.log; exit 1
fi
sleep 1
i=$((i + 1))
done
fail=0
present() {
out="$(dig @127.0.0.1 +short +time=2 +tries=2 "$1" "$2")"
if ! printf '%s\n' "$out" | grep -Fxq -- "$3"; then
echo "missing: $1 $2 $3"; echo "dig: $out"; fail=1
fi
}
absent() {
out="$(dig @127.0.0.1 +short +time=2 +tries=2 "$1" "$2")"
if printf '%s\n' "$out" | grep -Fxq -- "$3"; then
echo "still there: $1 $2 $3"; fail=1
fi
}
# A foreign record at a name DDI manages, signed with the zone's own key
# (like a domain controller registering itself).
nsupdate -y hmac-sha512:update.ddi-test:` + secret(7, 64) + ` <<EOF
server 127.0.0.1
zone ad.office.test
update add old.ad.office.test. 300 A 10.20.0.98
send
EOF
# The agent's synchronization, one transaction per zone.
for z in ` + strings.Join(zones, " ") + `; do
echo "--- sync $z ---"; cat /ddi/sync-$z.txt
nsupdate -k ` + keyFile + ` /ddi/sync-$z.txt
done
` + checks.String() + `absent old.ad.office.test. A 10.20.0.99
present old.ad.office.test. A 10.20.0.98
# Without the key, 127.0.0.1 may not update (it is not in allow_update).
if nsupdate <<EOF
server 127.0.0.1
zone ad.office.test
update add rogue.ad.office.test. 300 A 10.20.0.66
send
EOF
then
echo "unsigned update from 127.0.0.1 accepted"; fail=1
fi
absent rogue.ad.office.test. A 10.20.0.66
if [ $fail != 0 ]; then
echo "--- named ---"; cat /tmp/named.log; exit 1
fi
echo "all records synchronized"
`
for _, image := range images("DDI_BIND_IMAGES", "internetsystemsconsortium/bind9:9.18 internetsystemsconsortium/bind9:9.20") {
t.Run(image, func(t *testing.T) {
out, ok := runInContainer(t, image, root, script)
if !ok {
t.Fatalf("dynamic updates failed:\n%s", out)
}
t.Log(out)
})
}
}
// TestNsupdateScripts checks the helper that stands in for the agent.
func TestNsupdateScripts(t *testing.T) {
_, prev := renderDynamic(t, false)
_, next := renderDynamic(t, true)
s := nsupdateScripts("127.0.0.1", prev, next)["ad.office.test"]
t.Log(s)
for _, want := range []string{
"zone ad.office.test\n",
"update delete old.ad.office.test. A 10.20.0.99\n",
"update add dc1.ad.office.test. 3600 A 10.20.0.10\n",
"update add files.ad.office.test. 600 CNAME srv1.ad.office.test.\n",
"update add _ldap._tcp.dc._msdcs.ad.office.test. 3600 SRV 0 100 389 dc1.ad.office.test.\n",
"update add srv1.ad.office.test. 3600 A 10.20.0.5\n",
`update add info.ad.office.test. 3600 TXT "v=spf1 -all" "quote \" and backslash \\ caf\195\169 tab\009`,
} {
if !strings.Contains(s, want) {
t.Errorf("script lacks %q:\n%s", want, s)
}
}
if strings.Contains(s, "update delete dc1") {
t.Errorf("unchanged record deleted:\n%s", s)
}
if r := nsupdateScripts("127.0.0.1", prev, next)["2.0.192.in-addr.arpa"]; !strings.Contains(r,
"update add 77.2.0.192.in-addr.arpa. 3600 PTR new.office.test.\n") {
t.Errorf("reverse zone script:\n%s", r)
}
}