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:
co-authored by
Claude Opus 5.5
parent
174d371392
commit
3534076e1c
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user