Files
DDI/server/internal/configgen/bindgen/bindgen_test.go
T
sir_melonchenandClaude Opus 5.5 5a6087c96e feat(configgen): update key and dynamic-records.json for dynamic zones
Targets that serve a dynamic zone as primary get the DNS agents' update
key (Source version 4, UpdateKeyID) in the update ACLs and two more
artifacts: ddi-suite-update.key (secret) and dynamic-records.json with
the records DDI owns in each dynamic zone (ADR 0026). Older sources and
targets without dynamic primary zones render byte-identically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:39:43 +02:00

1021 lines
41 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package bindgen
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"math/rand/v2"
"net/netip"
"os"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
)
var update = flag.Bool("update", false, "rewrite the golden files in testdata/")
func ip(s string) netip.Addr { return netip.MustParseAddr(s) }
func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) }
const testZoneDir = "/etc/bind/ddi-suite/zones"
func testSOA(zone string) configgen.SOA {
return configgen.SOA{
PrimaryNS: "ns1." + zone,
Hostmaster: "hostmaster." + zone,
Refresh: 3600,
Retry: 600,
Expire: 604800,
Minimum: 300,
}
}
// forwardZone covers every record type, glue, sorting and TXT escaping.
func forwardZone() configgen.Zone {
return configgen.Zone{
Name: "office.test",
Kind: configgen.ZoneForward,
Mode: configgen.ZoneManaged,
DefaultTTL: 3600,
SOA: testSOA("office.test"),
NameServers: []configgen.NameServer{
{Name: "ns1.office.test", Addrs: []netip.Addr{ip("192.0.2.53"), ip("2001:db8::53")}},
// Outside the zone: no glue.
{Name: "ns2.example.net", Addrs: []netip.Addr{ip("198.51.100.53")}},
},
Records: []configgen.Record{
{Name: "www", Type: configgen.TypeA, Addr: ip("192.0.2.10")},
{Name: "www", Type: configgen.TypeA, Addr: ip("192.0.2.9")},
{Name: "www", Type: configgen.TypeAAAA, Addr: ip("2001:db8::10")},
{Name: "@", Type: configgen.TypeMX, Preference: 20, Target: "mx.example.net"},
{Name: "@", Type: configgen.TypeMX, Preference: 10, Target: "mail.office.test"},
{Name: "@", Type: configgen.TypeTXT, Text: []string{"v=spf1 mx -all"}},
{Name: "mail", TTL: 300, Type: configgen.TypeA, Addr: ip("192.0.2.25")},
{Name: "intranet", Type: configgen.TypeCNAME, Target: "www.office.test"},
{Name: "info", TTL: 600, Type: configgen.TypeTXT, Text: []string{
strings.Repeat("0123456789", 30), // 300 bytes: 255 + 45
`say "hi" C:\temp`,
"caf\u00e9",
"tab\there",
}},
{Name: "_ldap._tcp", Type: configgen.TypeSRV, Priority: 0, Weight: 100, Port: 389, Target: "dc1.office.test"},
{Name: "dc1", Type: configgen.TypeA, Addr: ip("192.0.2.20")},
{Name: "sub", Type: configgen.TypeNS, Target: "ns1.sub.office.test"},
{Name: "ns1.sub", Type: configgen.TypeA, Addr: ip("192.0.2.60")},
// Repeats the glue record: written once.
{Name: "ns1", Type: configgen.TypeA, Addr: ip("192.0.2.53")},
},
}
}
func reverseZones() []configgen.Zone {
nss := []configgen.NameServer{{Name: "ns1.office.test", Addrs: []netip.Addr{ip("192.0.2.53")}}}
return []configgen.Zone{
{
Name: "2.0.192.in-addr.arpa",
Kind: configgen.ZoneReverse,
Mode: configgen.ZoneManaged,
DefaultTTL: 86400,
SOA: testSOA("office.test"),
NameServers: nss,
Records: []configgen.Record{
{Name: "9", Type: configgen.TypePTR, Target: "www.office.test"},
{Name: "53", Type: configgen.TypePTR, Target: "ns1.office.test"},
{Name: "10", Type: configgen.TypePTR, Target: "www.office.test"},
},
},
{
Name: "8.b.d.0.1.0.0.2.ip6.arpa",
Kind: configgen.ZoneReverse,
Mode: configgen.ZoneManaged,
DefaultTTL: 86400,
SOA: testSOA("office.test"),
NameServers: nss,
Records: []configgen.Record{
{Name: "3.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", Type: configgen.TypePTR, Target: "ns1.office.test"},
{Name: "0.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", Type: configgen.TypePTR, Target: "www.office.test"},
},
},
}
}
func dynamicZones() []configgen.Zone {
return []configgen.Zone{
{
Name: "dyn.test",
Kind: configgen.ZoneForward,
Mode: configgen.ZoneDynamic,
DefaultTTL: 1200,
SOA: testSOA("dyn.test"),
NameServers: []configgen.NameServer{{Name: "ns1.dyn.test", Addrs: []netip.Addr{ip("192.0.2.53")}}},
Records: []configgen.Record{{Name: "dc1", Type: configgen.TypeA, Addr: ip("192.0.2.20")}},
AllowUpdate: []netip.Prefix{pfx("2001:db8:1::/64"), pfx("192.0.2.11/32"), pfx("192.0.2.10/32"), pfx("192.0.2.11/32")},
},
{
Name: "static.test",
Kind: configgen.ZoneForward,
Mode: configgen.ZoneManaged,
DefaultTTL: 3600,
SOA: testSOA("dyn.test"),
NameServers: []configgen.NameServer{{Name: "ns1.dyn.test"}},
},
}
}
func recursionOptions() configgen.DNSOptions {
return configgen.DNSOptions{
Recursion: true,
AllowRecursion: []netip.Prefix{pfx("2001:db8::/32"), pfx("192.0.2.0/24"), pfx("10.0.0.0/8")},
Forwarders: []netip.Addr{ip("192.0.2.1"), ip("2001:db8::1")},
}
}
const (
xferKey = "xfer.dns-a"
xferSecret = "eGZlci1zZWNyZXQtb2YtZG5zLWEtMzItYnl0ZXMhIQ=="
)
func testKeys() []configgen.TSIGKey {
return []configgen.TSIGKey{
{Name: xferKey, Algorithm: "hmac-sha256", Secret: xferSecret},
{Name: "dhcp-update", Algorithm: "hmac-sha512", Secret: "ZGhjcC11cGRhdGUtc2VjcmV0"},
{Name: "admin.update", Algorithm: "hmac-sha384", Secret: "YWRtaW4tdXBkYXRl"},
}
}
// The update key of the DNS agents (ADR 0026).
const (
agentKey = "ddi-agent-update"
agentKeySecret = "ZGRpLWFnZW50LXVwZGF0ZS1zZWNyZXQtMzItYnl0ZXM="
)
func agentUpdateKey() configgen.TSIGKey {
return configgen.TSIGKey{Name: agentKey, Algorithm: "hmac-sha256", Secret: agentKeySecret}
}
// dynamicUpdateZones are dynamic zones with every record type that the DNS
// agent keeps up to date (ADR 0026), next to a managed and a secondary zone
// whose records it never touches.
func dynamicUpdateZones() []configgen.Zone {
fwd := forwardZone()
fwd.Mode = configgen.ZoneDynamic
fwd.AllowUpdate = []netip.Prefix{pfx("192.0.2.20/32")}
fwd.UpdateKeys = []string{"dhcp-update", agentKey} // listed once in the ACL
fwd.Records = append(fwd.Records,
// Apex NS records are never managed by the agent.
configgen.Record{Name: "@", Type: configgen.TypeNS, Target: "ns3.example.net"},
configgen.Record{Name: "@", Type: configgen.TypeNS, Target: "ns1.office.test"},
// Differs only in TTL from "www A 192.0.2.9": written once.
configgen.Record{Name: "www", TTL: 60, Type: configgen.TypeA, Addr: ip("192.0.2.9")},
)
rev := reverseZones()[0]
rev.Mode = configgen.ZoneDynamic
rev.AllowUpdate = []netip.Prefix{pfx("192.0.2.20/32")}
managed := dynamicZones()[1]
sec := secondaryZones()[0]
sec.Name = "remote.test"
return []configgen.Zone{fwd, rev, managed, sec}
}
// primaryZones are the zones of a cluster primary with two secondaries.
func primaryZones() []configgen.Zone {
notify := func() []configgen.Remote {
return []configgen.Remote{{Addr: ip("2001:db8::2"), Key: xferKey}, {Addr: ip("192.0.2.3"), Key: xferKey}}
}
fwd := forwardZone()
fwd.Type = configgen.ZonePrimary
fwd.AlsoNotify = notify()
fwd.TransferKey = xferKey
rev := reverseZones()[0]
rev.AlsoNotify = notify()
rev.TransferKey = xferKey
return []configgen.Zone{fwd, rev}
}
// secondaryZones are the same zones on a secondary.
func secondaryZones() []configgen.Zone {
primaries := func() []configgen.Remote { return []configgen.Remote{{Addr: ip("192.0.2.1"), Key: xferKey}} }
return []configgen.Zone{
{Name: "office.test", Kind: configgen.ZoneForward, Type: configgen.ZoneSecondary, Primaries: primaries()},
{Name: "2.0.192.in-addr.arpa", Kind: configgen.ZoneReverse, Type: configgen.ZoneSecondary, Primaries: primaries()},
}
}
// dynamicKeyZones are dynamic zones that accept updates signed with keys.
func dynamicKeyZones() []configgen.Zone {
zs := dynamicZones()
zs[0].UpdateKeys = []string{"dhcp-update", "admin.update"}
only := zs[0]
only.Name = "keys-only.test"
only.AllowUpdate = nil
only.UpdateKeys = []string{"dhcp-update"}
only.Records = nil
return append(zs, only)
}
func goldenCases() map[string]configgen.DNSConfig {
return map[string]configgen.DNSConfig{
"forward": {Serial: 42, ZoneDir: testZoneDir, Zones: []configgen.Zone{forwardZone()}},
"reverse": {Serial: 42, ZoneDir: testZoneDir, Zones: reverseZones()},
"dynamic": {Serial: 7, ZoneDir: testZoneDir, Zones: dynamicZones()},
"recursion": {Serial: 1, ZoneDir: testZoneDir, Options: recursionOptions()},
"cluster-primary": {Serial: 11, ZoneDir: testZoneDir, Zones: primaryZones(), Keys: testKeys()[:1]},
"secondary": {Serial: 11, ZoneDir: testZoneDir, Zones: secondaryZones(), Keys: testKeys()[:1]},
"dynamic-keys": {Serial: 8, ZoneDir: testZoneDir, Zones: dynamicKeyZones(),
Keys: append(testKeys(), agentUpdateKey()), UpdateKey: agentKey},
"dynamic-update": {Serial: 12, ZoneDir: testZoneDir, Zones: dynamicUpdateZones(),
Keys: append(testKeys(), agentUpdateKey()), UpdateKey: agentKey},
}
}
// TestGoldenFilesComplete fails for golden files no artifact produces any
// more (e.g. after a zone was removed from a case).
func TestGoldenFilesComplete(t *testing.T) {
for name, cfg := range goldenCases() {
arts, err := Render(cfg)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
files, err := filepath.Glob(filepath.Join("testdata", name, "*.golden"))
if err != nil {
t.Fatal(err)
}
if len(files) != len(arts) {
t.Errorf("%s: %d golden files for %d artifacts", name, len(files), len(arts))
}
}
}
func TestRenderGolden(t *testing.T) {
for name, cfg := range goldenCases() {
t.Run(name, func(t *testing.T) {
arts, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, a := range arts {
file := filepath.Join("testdata", name, path.Base(a.Path)+".golden")
if *update {
if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file, a.Content, 0o644); err != nil {
t.Fatal(err)
}
continue
}
want, err := os.ReadFile(file)
if err != nil {
t.Errorf("%s: %v; complete output:\n%s", a.Path, err, a.Content)
continue
}
if !bytes.Equal(a.Content, want) {
t.Errorf("%s differs from %s; complete output:\n%s", a.Path, file, a.Content)
}
}
})
}
}
func TestRenderArtifacts(t *testing.T) {
type meta struct {
Path string
Mode uint32
CreateOnly bool
Secret bool
}
cfg := configgen.DNSConfig{Serial: 3, ZoneDir: testZoneDir, Keys: testKeys()}
cfg.Zones = append(cfg.Zones, dynamicZones()...)
cfg.Zones = append(cfg.Zones, forwardZone())
cfg.Zones = append(cfg.Zones, reverseZones()...)
sec := secondaryZones()[0]
sec.Name = "remote.test"
cfg.Zones = append(cfg.Zones, sec)
arts, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
var got []meta
for _, a := range arts {
got = append(got, meta{a.Path, a.Mode, a.CreateOnly, a.Secret})
if len(a.Content) == 0 || a.Content[len(a.Content)-1] != '\n' {
t.Errorf("%s does not end with a newline", a.Path)
}
if a.Secret != bytes.Contains(a.Content, []byte("secret ")) {
t.Errorf("%s: secret flag %v does not match its content", a.Path, a.Secret)
}
}
want := []meta{
{"ddi-suite.conf", 0o640, false, false},
{"ddi-suite-options.conf", 0o640, false, false},
{"ddi-suite-keys.conf", 0o640, false, true},
{"zones/db.2.0.192.in-addr.arpa", 0o640, false, false},
{"zones/db.8.b.d.0.1.0.0.2.ip6.arpa", 0o640, false, false},
{"zones/db.dyn.test", 0o640, true, false},
{"zones/db.office.test", 0o640, false, false},
{"zones/db.static.test", 0o640, false, false},
}
if !slices.Equal(got, want) {
t.Errorf("artifacts\n got %v\nwant %v", got, want)
}
// With the update key of the DNS agents (ADR 0026).
cfg.Keys = append(cfg.Keys, agentUpdateKey())
cfg.UpdateKey = agentKey
if arts, err = Render(cfg); err != nil {
t.Fatalf("Render: %v", err)
}
got = got[:0]
for _, a := range arts[:5] {
got = append(got, meta{a.Path, a.Mode, a.CreateOnly, a.Secret})
if a.Secret != bytes.Contains(a.Content, []byte("secret ")) {
t.Errorf("%s: secret flag %v does not match its content", a.Path, a.Secret)
}
}
want = []meta{
{"ddi-suite.conf", 0o640, false, false},
{"ddi-suite-options.conf", 0o640, false, false},
{"ddi-suite-keys.conf", 0o640, false, true},
{"ddi-suite-update.key", 0o640, false, true},
{"dynamic-records.json", 0o640, false, false},
}
if !slices.Equal(got, want) || len(arts) != 10 {
t.Errorf("artifacts with update key\n got %v (%d)\nwant %v", got, len(arts), want)
}
// Without keys the keys file holds only comments.
arts, err = Render(validConfig())
if err != nil {
t.Fatal(err)
}
for _, l := range strings.Split(strings.TrimSpace(artifact(t, arts, KeysFile)), "\n") {
if !strings.HasPrefix(l, "//") {
t.Errorf("keys file without keys has line %q", l)
}
}
}
// validConfig is the base of the validation table: one zone "office.test".
func validConfig() configgen.DNSConfig {
return configgen.DNSConfig{Serial: 1, ZoneDir: testZoneDir, Zones: []configgen.Zone{forwardZone()}}
}
func TestRenderValidation(t *testing.T) {
const zp = `zones["office.test"]`
zone := func(f func(z *configgen.Zone)) func(c *configgen.DNSConfig) {
return func(c *configgen.DNSConfig) { f(&c.Zones[0]) }
}
records := func(rs ...configgen.Record) func(c *configgen.DNSConfig) {
return zone(func(z *configgen.Zone) { z.Records = rs })
}
a := func(name string) configgen.Record {
return configgen.Record{Name: name, Type: configgen.TypeA, Addr: ip("192.0.2.1")}
}
withTarget := func(typ configgen.RRType, target string) configgen.Record {
return configgen.Record{Name: "x", Type: typ, Target: target}
}
dynamic := func(allow ...netip.Prefix) func(c *configgen.DNSConfig) {
return zone(func(z *configgen.Zone) { z.Mode = configgen.ZoneDynamic; z.AllowUpdate = allow })
}
recursion := func(allow []netip.Prefix, fwd ...netip.Addr) func(c *configgen.DNSConfig) {
return func(c *configgen.DNSConfig) {
c.Options = configgen.DNSOptions{Recursion: true, AllowRecursion: allow, Forwarders: fwd}
}
}
keys := func(f func(k []configgen.TSIGKey)) func(c *configgen.DNSConfig) {
return func(c *configgen.DNSConfig) { c.Keys = testKeys(); f(c.Keys) }
}
secondary := func(primaries ...configgen.Remote) func(c *configgen.DNSConfig) {
return func(c *configgen.DNSConfig) {
c.Keys = testKeys()
c.Zones[0].Type = configgen.ZoneSecondary
c.Zones[0].Primaries = primaries
}
}
longText := make([]string, 300)
for i := range longText {
longText[i] = strings.Repeat("x", 255)
}
tests := []struct {
name string
mutate func(c *configgen.DNSConfig)
path string
}{
{"serial 0", func(c *configgen.DNSConfig) { c.Serial = 0 }, "serial"},
{"zone dir relative", func(c *configgen.DNSConfig) { c.ZoneDir = "etc/bind/zones" }, "zone_dir"},
{"zone dir empty", func(c *configgen.DNSConfig) { c.ZoneDir = "" }, "zone_dir"},
{"zone dir root", func(c *configgen.DNSConfig) { c.ZoneDir = "/" }, "zone_dir"},
{"zone dir space", func(c *configgen.DNSConfig) { c.ZoneDir = "/etc/bind zones" }, "zone_dir"},
{"zone dir quote", func(c *configgen.DNSConfig) { c.ZoneDir = `/etc/bind";` }, "zone_dir"},
{"zone dir dotdot", func(c *configgen.DNSConfig) { c.ZoneDir = "/etc/bind/../zones" }, "zone_dir"},
{"zone dir trailing slash", func(c *configgen.DNSConfig) { c.ZoneDir = "/etc/bind/" }, "zone_dir"},
{"zone name upper case", zone(func(z *configgen.Zone) { z.Name = "Office.test" }), "zones[0].name"},
{"zone name empty", zone(func(z *configgen.Zone) { z.Name = "" }), "zones[0].name"},
{"zone name trailing dot", zone(func(z *configgen.Zone) { z.Name = "office.test." }), "zones[0].name"},
{"zone name empty label", zone(func(z *configgen.Zone) { z.Name = "office..test" }), "zones[0].name"},
{"zone name leading hyphen", zone(func(z *configgen.Zone) { z.Name = "-office.test" }), "zones[0].name"},
{"zone name trailing hyphen", zone(func(z *configgen.Zone) { z.Name = "office-.test" }), "zones[0].name"},
{"zone name underscore", zone(func(z *configgen.Zone) { z.Name = "_msdcs.office.test" }), "zones[0].name"},
{"zone name label too long", zone(func(z *configgen.Zone) { z.Name = strings.Repeat("a", 64) + ".test" }), "zones[0].name"},
{"zone name too long", zone(func(z *configgen.Zone) { z.Name = strings.Repeat("abcdefgh.", 28) + "test" }), "zones[0].name"},
{"zone name injection", zone(func(z *configgen.Zone) { z.Name = "x\"; }; zone \"y" }), "zones[0].name"},
{"zone name non-ASCII", zone(func(z *configgen.Zone) { z.Name = "b\u00fcro.test" }), "zones[0].name"},
{"reverse zone not arpa", zone(func(z *configgen.Zone) { z.Kind = configgen.ZoneReverse }), zp + ".name"},
{"forward zone below arpa", zone(func(z *configgen.Zone) { z.Name = "2.0.192.in-addr.arpa" }), `zones["2.0.192.in-addr.arpa"].kind`},
{"unknown kind", zone(func(z *configgen.Zone) { z.Kind = "" }), zp + ".kind"},
{"unknown mode", zone(func(z *configgen.Zone) { z.Mode = "slave" }), zp + ".mode"},
{"duplicate zone", func(c *configgen.DNSConfig) { c.Zones = append(c.Zones, forwardZone()) }, "zones[1].name"},
{"default ttl 0", zone(func(z *configgen.Zone) { z.DefaultTTL = 0 }), zp + ".default_ttl"},
{"default ttl too large", zone(func(z *configgen.Zone) { z.DefaultTTL = 1 << 31 }), zp + ".default_ttl"},
{"soa primary ns", zone(func(z *configgen.Zone) { z.SOA.PrimaryNS = "ns1.office.test." }), zp + ".soa.primary_ns"},
{"soa hostmaster", zone(func(z *configgen.Zone) { z.SOA.Hostmaster = "hostmaster@office.test" }), zp + ".soa.hostmaster"},
{"soa refresh 0", zone(func(z *configgen.Zone) { z.SOA.Refresh = 0 }), zp + ".soa.refresh"},
{"soa retry 0", zone(func(z *configgen.Zone) { z.SOA.Retry = 0 }), zp + ".soa.retry"},
{"soa expire 0", zone(func(z *configgen.Zone) { z.SOA.Expire = 0 }), zp + ".soa.expire"},
{"soa minimum 0", zone(func(z *configgen.Zone) { z.SOA.Minimum = 0 }), zp + ".soa.minimum"},
{"soa minimum too large", zone(func(z *configgen.Zone) { z.SOA.Minimum = 1 << 31 }), zp + ".soa.minimum"},
{"no name servers", zone(func(z *configgen.Zone) { z.NameServers = nil }), zp + ".name_servers"},
{"name server invalid", zone(func(z *configgen.Zone) { z.NameServers[1].Name = "ns2 .example.net" }), zp + ".name_servers[1].name"},
{"name server duplicate", zone(func(z *configgen.Zone) { z.NameServers[1].Name = "ns1.office.test" }), zp + ".name_servers[1].name"},
{"in-zone name server without glue", zone(func(z *configgen.Zone) { z.NameServers[0].Addrs = nil }), zp + ".name_servers[0].addrs"},
{"glue address mapped", zone(func(z *configgen.Zone) { z.NameServers[0].Addrs[1] = ip("::ffff:192.0.2.1") }), zp + ".name_servers[0].addrs[1]"},
{"glue address invalid", zone(func(z *configgen.Zone) { z.NameServers[0].Addrs[0] = netip.Addr{} }), zp + ".name_servers[0].addrs[0]"},
{"glue address with zone", zone(func(z *configgen.Zone) { z.NameServers[0].Addrs[1] = ip("fe80::1%eth0") }), zp + ".name_servers[0].addrs[1]"},
{"owner empty", records(a("")), zp + ".records[0].name"},
{"owner with zone suffix", records(a("www.office.test")), zp + ".records[0].name"},
{"owner is zone", records(a("office.test")), zp + ".records[0].name"},
{"owner trailing dot", records(a("www.")), zp + ".records[0].name"},
{"owner wildcard", records(a("*")), zp + ".records[0].name"},
{"owner wildcard label", records(a("*.dev")), zp + ".records[0].name"},
{"owner upper case", records(a("WWW")), zp + ".records[0].name"},
{"owner newline", records(a("www\nevil IN A 192.0.2.66")), zp + ".records[0].name"},
{"owner semicolon", records(a("www;x")), zp + ".records[0].name"},
{"owner quote", records(a(`www"`)), zp + ".records[0].name"},
{"owner underscore only", records(a("_")), zp + ".records[0].name"},
{"owner underscore inside", records(a("a_b")), zp + ".records[0].name"},
{"owner too long with zone", records(a(strings.Repeat("abcdefgh.", 27) + "abcdefg")), zp + ".records[0].name"},
{"ttl too large", records(configgen.Record{Name: "x", TTL: 1 << 31, Type: configgen.TypeA, Addr: ip("192.0.2.1")}), zp + ".records[0].ttl"},
{"a with ipv6", records(configgen.Record{Name: "x", Type: configgen.TypeA, Addr: ip("2001:db8::1")}), zp + ".records[0].addr"},
{"a without address", records(configgen.Record{Name: "x", Type: configgen.TypeA}), zp + ".records[0].addr"},
{"aaaa with ipv4", records(configgen.Record{Name: "x", Type: configgen.TypeAAAA, Addr: ip("192.0.2.1")}), zp + ".records[0].addr"},
{"aaaa mapped", records(configgen.Record{Name: "x", Type: configgen.TypeAAAA, Addr: ip("::ffff:192.0.2.1")}), zp + ".records[0].addr"},
{"mx without target", records(withTarget(configgen.TypeMX, "")), zp + ".records[0].target"},
{"srv without target", records(withTarget(configgen.TypeSRV, "")), zp + ".records[0].target"},
{"ptr without target", records(withTarget(configgen.TypePTR, "")), zp + ".records[0].target"},
{"cname without target", records(withTarget(configgen.TypeCNAME, "")), zp + ".records[0].target"},
{"ns without target", records(withTarget(configgen.TypeNS, "")), zp + ".records[0].target"},
{"target trailing dot", records(withTarget(configgen.TypeMX, "mail.office.test.")), zp + ".records[0].target"},
{"target underscore", records(withTarget(configgen.TypeSRV, "_dc.office.test")), zp + ".records[0].target"},
{"target space", records(withTarget(configgen.TypePTR, "www office.test")), zp + ".records[0].target"},
{"cname at apex", records(withTarget(configgen.TypeCNAME, "www.office.test"), configgen.Record{Name: "@", Type: configgen.TypeCNAME, Target: "x.test"}), zp + ".records[1].name"},
{"cname with other record", records(a("x"), withTarget(configgen.TypeCNAME, "www.office.test")), zp + ".records[1].name"},
{"two cnames", records(withTarget(configgen.TypeCNAME, "a.test"), withTarget(configgen.TypeCNAME, "b.test")), zp + ".records[0].name"},
{"cname on glue owner", records(configgen.Record{Name: "ns1", Type: configgen.TypeCNAME, Target: "www.office.test"}), zp + ".records[0].name"},
{"unsupported type", records(configgen.Record{Name: "x", Type: "SPF", Text: []string{"v=spf1"}}), zp + ".records[0].type"},
{"empty type", records(configgen.Record{Name: "x", Addr: ip("192.0.2.1")}), zp + ".records[0].type"},
{"txt without strings", records(configgen.Record{Name: "x", Type: configgen.TypeTXT}), zp + ".records[0].text"},
{"txt too long", records(configgen.Record{Name: "x", Type: configgen.TypeTXT, Text: longText}), zp + ".records[0].text"},
{"key name invalid", keys(func(k []configgen.TSIGKey) { k[1].Name = `x"; }; key "y` }), "keys[1].name"},
{"key name trailing dot", keys(func(k []configgen.TSIGKey) { k[0].Name = "xfer." }), "keys[0].name"},
{"key duplicate", keys(func(k []configgen.TSIGKey) { k[2].Name = k[0].Name }), "keys[2].name"},
{"key algorithm unknown", keys(func(k []configgen.TSIGKey) { k[0].Algorithm = "hmac-md5" }), "keys[0].algorithm"},
{"key algorithm empty", keys(func(k []configgen.TSIGKey) { k[1].Algorithm = "" }), "keys[1].algorithm"},
{"key secret empty", keys(func(k []configgen.TSIGKey) { k[0].Secret = "" }), "keys[0].secret"},
{"key secret not base64", keys(func(k []configgen.TSIGKey) { k[0].Secret = "not base64!" }), "keys[0].secret"},
{"key secret quote", keys(func(k []configgen.TSIGKey) { k[0].Secret = `YQ=="; };` }), "keys[0].secret"},
{"key secret unpadded", keys(func(k []configgen.TSIGKey) { k[0].Secret = "YQ" }), "keys[0].secret"},
{"key secret newline", keys(func(k []configgen.TSIGKey) { k[0].Secret = "YWFh\nYWFh" }), "keys[0].secret"},
{"key secret URL alphabet", keys(func(k []configgen.TSIGKey) { k[0].Secret = "-_-_" }), "keys[0].secret"},
{"zone type unknown", zone(func(z *configgen.Zone) { z.Type = "slave" }), zp + ".type"},
{"secondary without primaries", zone(func(z *configgen.Zone) { z.Type = configgen.ZoneSecondary }), zp + ".primaries"},
{"secondary primary invalid", secondary(configgen.Remote{}), zp + ".primaries[0].addr"},
{"secondary primary multicast", secondary(configgen.Remote{Addr: ip("ff02::1")}), zp + ".primaries[0].addr"},
{"secondary primary duplicate", secondary(configgen.Remote{Addr: ip("192.0.2.1")}, configgen.Remote{Addr: ip("192.0.2.1"), Key: xferKey}), zp + ".primaries[1].addr"},
{"secondary primary unknown key", secondary(configgen.Remote{Addr: ip("192.0.2.1"), Key: "nope"}), zp + ".primaries[0].key"},
{"primary zone with primaries", zone(func(z *configgen.Zone) {
z.Primaries = []configgen.Remote{{Addr: ip("192.0.2.1")}}
}), zp + ".primaries"},
{"also-notify invalid", zone(func(z *configgen.Zone) { z.AlsoNotify = []configgen.Remote{{}} }), zp + ".also_notify[0].addr"},
{"also-notify mapped", zone(func(z *configgen.Zone) {
z.AlsoNotify = []configgen.Remote{{Addr: ip("::ffff:192.0.2.1")}}
}), zp + ".also_notify[0].addr"},
{"also-notify duplicate", zone(func(z *configgen.Zone) {
z.AlsoNotify = []configgen.Remote{{Addr: ip("192.0.2.2")}, {Addr: ip("192.0.2.2")}}
}), zp + ".also_notify[1].addr"},
{"also-notify unknown key", zone(func(z *configgen.Zone) {
z.AlsoNotify = []configgen.Remote{{Addr: ip("192.0.2.2"), Key: xferKey}}
}), zp + ".also_notify[0].key"},
{"transfer key unknown", zone(func(z *configgen.Zone) { z.TransferKey = xferKey }), zp + ".transfer_key"},
{"update key unknown", func(c *configgen.DNSConfig) {
c.Keys = testKeys()
c.Zones[0].Mode = configgen.ZoneDynamic
c.Zones[0].UpdateKeys = []string{"dhcp-update", "gone"}
}, zp + ".update_keys[1]"},
{"update key unknown", func(c *configgen.DNSConfig) {
dynamic(pfx("192.0.2.0/24"))(c)
c.UpdateKey = agentKey
}, "update_key"},
{"update key without dynamic zone", func(c *configgen.DNSConfig) {
c.Keys = []configgen.TSIGKey{agentUpdateKey()}
c.UpdateKey = agentKey
}, "update_key"},
{"update key without zones", func(c *configgen.DNSConfig) {
c.Zones = nil
c.Keys = []configgen.TSIGKey{agentUpdateKey()}
c.UpdateKey = agentKey
}, "update_key"},
{"dynamic without allow-update", dynamic(), zp + ".allow_update"},
{"allow-update not masked", dynamic(pfx("192.0.2.0/24"), pfx("192.0.2.1/24")), zp + ".allow_update[1]"},
{"allow-update any", dynamic(pfx("0.0.0.0/0")), zp + ".allow_update[0]"},
{"allow-update invalid", dynamic(netip.Prefix{}), zp + ".allow_update[0]"},
{"recursion without clients", recursion(nil), "options.allow_recursion"},
{"allow-recursion not masked", recursion([]netip.Prefix{pfx("10.1.2.3/8")}), "options.allow_recursion[0]"},
{"allow-recursion any", recursion([]netip.Prefix{pfx("::/0")}), "options.allow_recursion[0]"},
{"allow-recursion checked without recursion", func(c *configgen.DNSConfig) {
c.Options.AllowRecursion = []netip.Prefix{pfx("10.1.2.3/8")}
}, "options.allow_recursion[0]"},
{"forwarder invalid", recursion([]netip.Prefix{pfx("10.0.0.0/8")}, netip.Addr{}), "options.forwarders[0]"},
{"forwarder unspecified", recursion([]netip.Prefix{pfx("10.0.0.0/8")}, ip("0.0.0.0")), "options.forwarders[0]"},
{"forwarder multicast", recursion([]netip.Prefix{pfx("10.0.0.0/8")}, ip("192.0.2.1"), ip("ff02::1")), "options.forwarders[1]"},
{"forwarder duplicate", recursion([]netip.Prefix{pfx("10.0.0.0/8")}, ip("192.0.2.1"), ip("192.0.2.1")), "options.forwarders[1]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := validConfig()
tt.mutate(&cfg)
arts, err := Render(cfg)
var ve *configgen.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("got %v (%T), want *configgen.ValidationError", err, err)
}
if ve.Path != tt.path {
t.Errorf("path %q (%s), want %q", ve.Path, ve.Message, tt.path)
}
if arts != nil {
t.Errorf("artifacts returned with error")
}
})
}
}
func TestRenderAccepts(t *testing.T) {
tests := []struct {
name string
mutate func(c *configgen.DNSConfig)
}{
{"base", func(*configgen.DNSConfig) {}},
{"no zones", func(c *configgen.DNSConfig) { c.Zones = nil }},
{"underscore owners", func(c *configgen.DNSConfig) {
c.Zones[0].Records = []configgen.Record{
{Name: "_dmarc", Type: configgen.TypeTXT, Text: []string{"v=DMARC1; p=none"}},
{Name: "s1._domainkey", Type: configgen.TypeCNAME, Target: "s1._domainkey.mail.example.net"},
{Name: "_kerberos._udp.dc._msdcs", Type: configgen.TypeSRV, Port: 88, Target: "dc1.office.test"},
}
}},
{"empty txt string", func(c *configgen.DNSConfig) {
c.Zones[0].Records = []configgen.Record{{Name: "x", Type: configgen.TypeTXT, Text: []string{""}}}
}},
{"max ttl", func(c *configgen.DNSConfig) { c.Zones[0].DefaultTTL = 1<<31 - 1 }},
{"name server at apex", func(c *configgen.DNSConfig) {
c.Zones[0].NameServers = []configgen.NameServer{{Name: "office.test", Addrs: []netip.Addr{ip("192.0.2.53")}}}
}},
{"forwarders ignored without recursion", func(c *configgen.DNSConfig) {
c.Options.Forwarders = []netip.Addr{ip("192.0.2.1")}
}},
{"unreferenced keys", func(c *configgen.DNSConfig) { c.Keys = testKeys() }},
{"secondary zone uses only name, kind and primaries", func(c *configgen.DNSConfig) {
z := &c.Zones[0]
z.Type = configgen.ZoneSecondary
z.Primaries = []configgen.Remote{{Addr: ip("2001:db8::1")}, {Addr: ip("192.0.2.1")}}
z.Mode, z.DefaultTTL, z.SOA, z.NameServers = "", 0, configgen.SOA{}, nil
}},
{"explicit primary type", func(c *configgen.DNSConfig) { c.Zones[0].Type = configgen.ZonePrimary }},
{"notify without key, transfer key without notify", func(c *configgen.DNSConfig) {
c.Keys = testKeys()
c.Zones[0].AlsoNotify = []configgen.Remote{{Addr: ip("192.0.2.2")}}
c.Zones = append(c.Zones, fuzzConfig().Zones[0])
c.Zones[1].TransferKey = xferKey
}},
{"dynamic zone with keys only", func(c *configgen.DNSConfig) {
c.Keys = testKeys()
c.Zones[0].Mode = configgen.ZoneDynamic
c.Zones[0].UpdateKeys = []string{xferKey, xferKey}
}},
{"update keys ignored for managed zones", func(c *configgen.DNSConfig) {
c.Zones[0].UpdateKeys = []string{"gone"}
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := validConfig()
tt.mutate(&cfg)
if _, err := Render(cfg); err != nil {
t.Errorf("Render: %v", err)
}
})
}
}
// TestDynamicRecords checks dynamic-records.json against the zone files
// (ADR 0026) and that the update key changes nothing but the update ACLs.
func TestDynamicRecords(t *testing.T) {
cfg := goldenCases()["dynamic-update"]
arts, err := Render(cfg)
if err != nil {
t.Fatal(err)
}
var doc struct {
Version int `json:"version"`
Key string `json:"key"`
Zones []struct {
Zone string `json:"zone"`
Records []struct {
Name string `json:"name"`
TTL uint32 `json:"ttl"`
Type string `json:"type"`
Data string `json:"data"`
} `json:"records"`
} `json:"zones"`
}
dec := json.NewDecoder(strings.NewReader(artifact(t, arts, configgen.DynamicRecordsFile)))
dec.DisallowUnknownFields()
if err := dec.Decode(&doc); err != nil {
t.Fatal(err)
}
if doc.Version != 1 || doc.Key != agentKey || len(doc.Zones) != 2 ||
doc.Zones[0].Zone != "2.0.192.in-addr.arpa" || doc.Zones[1].Zone != "office.test" {
t.Fatalf("unexpected document %+v", doc)
}
for _, z := range doc.Zones {
// Every record is a line of the zone file, written with the
// absolute owner and the effective TTL.
zf := artifact(t, arts, "zones/db."+z.Zone)
ttl := uint32(86400)
if z.Zone == "office.test" {
ttl = 3600
}
lines := map[string]bool{}
for _, l := range strings.Split(zf, "\n") {
f := strings.Fields(l)
if len(f) < 4 || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "$") {
continue
}
owner := f[0] + "." + z.Zone + "."
if f[0] == "@" {
owner = z.Zone + "."
}
rest, t0 := f[1:], ttl
if rest[0] != "IN" {
v, _ := strconv.ParseUint(rest[0], 10, 32)
t0, rest = uint32(v), rest[1:] //nolint:gosec // parsed with 32 bits
}
lines[fmt.Sprintf("%s %d %s %s", owner, t0, rest[1], strings.Join(rest[2:], " "))] = true
}
n := 0
for _, r := range z.Records {
line := fmt.Sprintf("%s %d %s %s", r.Name, r.TTL, r.Type, r.Data)
if !lines[line] {
t.Errorf("%s: %q is not in the zone file:\n%s", z.Zone, line, zf)
}
if r.Name == z.Zone+"." && r.Type == "NS" {
t.Errorf("%s: apex NS record %q listed", z.Zone, line)
}
n++
}
want := map[string]int{"2.0.192.in-addr.arpa": 3, "office.test": 14}[z.Zone]
if n != want {
t.Errorf("%s: %d records, want %d", z.Zone, n, want)
}
}
// Without the update key only the ACLs of the dynamic zones differ.
plain := cfg
plain.UpdateKey = ""
without, err := Render(plain)
if err != nil {
t.Fatal(err)
}
if len(without) != len(arts)-2 {
t.Fatalf("%d artifacts without update key, %d with", len(without), len(arts))
}
for _, a := range without {
with := artifact(t, arts, a.Path)
if a.Path != ConfFile {
if with != string(a.Content) {
t.Errorf("%s depends on the update key", a.Path)
}
continue
}
wl, pl := strings.Split(with, "\n"), strings.Split(string(a.Content), "\n")
for i := range pl {
if wl[i] != pl[i] && (!strings.HasPrefix(pl[i], "acl ") || strings.Replace(wl[i], `key "ddi-agent-update"; `, "", 1) != pl[i]) {
t.Errorf("conf line %d: %q, without update key %q", i, wl[i], pl[i])
}
}
}
if conf := artifact(t, arts, ConfFile); strings.Count(conf, `key "ddi-agent-update";`) != 2 {
t.Errorf("update key not in both ACLs exactly once:\n%s", conf)
}
if got := artifact(t, arts, configgen.UpdateKeyFile); got != "key \"ddi-agent-update\" {\n algorithm hmac-sha256;\n secret \""+agentKeySecret+"\";\n};\n" {
t.Errorf("update key file:\n%s", got)
}
}
func TestTXTRData(t *testing.T) {
tests := []struct {
in []string
want string
wire int
}{
{[]string{""}, `""`, 1},
{[]string{"a", "b"}, `"a" "b"`, 4},
{[]string{`"\`}, `"\"\\"`, 3},
{[]string{"\x00\n\x7f\xff;()$"}, `"\000\010\127\255;()$"`, 9},
{[]string{strings.Repeat("a", 255)}, `"` + strings.Repeat("a", 255) + `"`, 256},
{[]string{strings.Repeat("a", 256)}, `"` + strings.Repeat("a", 255) + `" "a"`, 258},
// A multi-byte rune at the boundary is split by byte.
{[]string{strings.Repeat("a", 254) + "\u00e9"}, `"` + strings.Repeat("a", 254) + `\195" "\169"`, 258},
}
for _, tt := range tests {
got, wire := txtRData(tt.in)
if got != tt.want || wire != tt.wire {
t.Errorf("txtRData(%q) = %s, %d; want %s, %d", tt.in, got, wire, tt.want, tt.wire)
}
}
}
func TestCompareOwners(t *testing.T) {
want := []string{"@", "_ldap._tcp", "a", "_x.a", "b.a", "a-b", "b", "z.b", "www"}
got := slices.Clone(want)
slices.Reverse(got)
slices.SortFunc(got, compareOwners)
if !slices.Equal(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
// fullConfig combines every golden case into one configuration.
func fullConfig() configgen.DNSConfig {
cfg := configgen.DNSConfig{Serial: 9, ZoneDir: testZoneDir, Options: recursionOptions(),
Keys: []configgen.TSIGKey{agentUpdateKey()}, UpdateKey: agentKey}
cfg.Zones = append(cfg.Zones, forwardZone())
cfg.Zones = append(cfg.Zones, reverseZones()...)
cfg.Zones = append(cfg.Zones, dynamicZones()...)
dyn := forwardZone()
dyn.Name, dyn.Mode, dyn.AllowUpdate = "dyn2.test", configgen.ZoneDynamic, []netip.Prefix{pfx("192.0.2.20/32")}
dyn.NameServers = []configgen.NameServer{{Name: "ns2.example.net"}}
cfg.Zones = append(cfg.Zones, dyn)
return cfg
}
func TestRenderDeterministic(t *testing.T) {
want, err := Render(fullConfig())
if err != nil {
t.Fatalf("Render: %v", err)
}
r := rand.New(rand.NewPCG(1, 2))
shuffle := func(n int, swap func(i, j int)) { r.Shuffle(n, swap) }
for i := range 50 {
cfg := fullConfig() // fresh slices each time
shuffle(len(cfg.Zones), func(i, j int) { cfg.Zones[i], cfg.Zones[j] = cfg.Zones[j], cfg.Zones[i] })
o := &cfg.Options
shuffle(len(o.AllowRecursion), func(i, j int) { o.AllowRecursion[i], o.AllowRecursion[j] = o.AllowRecursion[j], o.AllowRecursion[i] })
for zi := range cfg.Zones {
z := &cfg.Zones[zi]
shuffle(len(z.Records), func(i, j int) { z.Records[i], z.Records[j] = z.Records[j], z.Records[i] })
shuffle(len(z.NameServers), func(i, j int) { z.NameServers[i], z.NameServers[j] = z.NameServers[j], z.NameServers[i] })
shuffle(len(z.AllowUpdate), func(i, j int) { z.AllowUpdate[i], z.AllowUpdate[j] = z.AllowUpdate[j], z.AllowUpdate[i] })
for ni := range z.NameServers {
a := z.NameServers[ni].Addrs
shuffle(len(a), func(i, j int) { a[i], a[j] = a[j], a[i] })
}
}
got, err := Render(cfg)
if err != nil {
t.Fatalf("round %d: Render: %v", i, err)
}
if len(got) != len(want) {
t.Fatalf("round %d: %d artifacts, want %d", i, len(got), len(want))
}
for k := range got {
if got[k].Path != want[k].Path || !bytes.Equal(got[k].Content, want[k].Content) {
t.Fatalf("round %d: %s differs:\n%s", i, got[k].Path, got[k].Content)
}
}
}
}
// fuzzConfig is a minimal valid configuration with zone "fuzz.test".
func fuzzConfig() configgen.DNSConfig {
return configgen.DNSConfig{
Serial: 1,
ZoneDir: testZoneDir,
Zones: []configgen.Zone{{
Name: "fuzz.test",
Kind: configgen.ZoneForward,
Mode: configgen.ZoneManaged,
DefaultTTL: 60,
SOA: testSOA("example.net"),
NameServers: []configgen.NameServer{{Name: "ns1.example.net"}},
}},
}
}
// artifact returns the content of the artifact at p.
func artifact(t *testing.T, arts []configgen.Artifact, p string) string {
t.Helper()
for _, a := range arts {
if a.Path == p {
return string(a.Content)
}
}
t.Fatalf("no artifact %s", p)
return ""
}
// parseCharStrings parses space-separated quoted character-strings and
// undoes the escaping (RFC 1035 §5.1).
func parseCharStrings(s string) ([]string, error) {
var out []string
for {
if s == "" || s[0] != '"' {
return nil, fmt.Errorf("expected '\"' at %q", s)
}
s = s[1:]
var b []byte
for {
if s == "" {
return nil, errors.New("unterminated string")
}
c := s[0]
s = s[1:]
if c == '"' {
break
}
if c != '\\' {
b = append(b, c)
continue
}
if s == "" {
return nil, errors.New("dangling backslash")
}
if s[0] < '0' || s[0] > '9' {
b = append(b, s[0])
s = s[1:]
continue
}
if len(s) < 3 {
return nil, errors.New("short \\DDD escape")
}
v := 0
for _, d := range []byte(s[:3]) {
if d < '0' || d > '9' {
return nil, fmt.Errorf("bad \\DDD escape %q", s[:3])
}
v = v*10 + int(d-'0')
}
if v > 255 {
return nil, fmt.Errorf("\\DDD escape %d out of range", v)
}
b = append(b, byte(v))
s = s[3:]
}
out = append(out, string(b))
if s == "" {
return out, nil
}
if s[0] != ' ' {
return nil, fmt.Errorf("expected ' ' at %q", s)
}
s = s[1:]
}
}
func FuzzTXT(f *testing.F) {
for _, s := range []string{"", "a", `"\`, strings.Repeat("x", 255), strings.Repeat("y", 256), "\x00\xff\n;()$", "caf\u00e9", `"; } zone "x`} {
f.Add([]byte(s))
}
f.Fuzz(func(t *testing.T, data []byte) {
cfg := fuzzConfig()
cfg.Zones[0].Records = []configgen.Record{{Name: "txt", Type: configgen.TypeTXT, Text: []string{string(data)}}}
arts, err := Render(cfg)
chunks := max(1, (len(data)+maxCharString-1)/maxCharString)
if chunks+len(data) > maxRDataLen {
var ve *configgen.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("oversized TXT: got %v, want *configgen.ValidationError", err)
}
return
}
if err != nil {
t.Fatalf("Render: %v", err)
}
zone := artifact(t, arts, "zones/db.fuzz.test")
var line string
for _, l := range strings.Split(zone, "\n") {
if strings.HasPrefix(l, "txt ") {
if line != "" {
t.Fatalf("more than one TXT line:\n%s", zone)
}
line = l
}
}
const marker = " IN TXT "
i := strings.Index(line, marker)
if i < 0 {
t.Fatalf("no TXT record in:\n%s", zone)
}
rdata := line[i+len(marker):]
for k := 0; k < len(rdata); k++ {
if rdata[k] < 0x20 || rdata[k] > 0x7e {
t.Fatalf("unescaped byte %#x in %q", rdata[k], rdata)
}
}
parts, err := parseCharStrings(rdata)
if err != nil {
t.Fatalf("parse %q: %v", rdata, err)
}
if len(parts) != chunks {
t.Errorf("%d character-strings, want %d", len(parts), chunks)
}
for _, p := range parts {
if len(p) > maxCharString {
t.Errorf("character-string of %d bytes", len(p))
}
}
if got := strings.Join(parts, ""); got != string(data) {
t.Errorf("round trip: got %q, want %q", got, data)
}
})
}
func FuzzNames(f *testing.F) {
f.Add("fuzz.test", "www", "target.example.net")
f.Add("fuzz.test", "_sip._tcp", "_x.example.net")
f.Add("fuzz.test", "a\nb", "c;d")
f.Add("x\"; };", "@", "\"")
f.Add("2.0.192.in-addr.arpa", "10", "host.example.net")
f.Fuzz(func(t *testing.T, zone, owner, target string) {
cfg := fuzzConfig()
z := &cfg.Zones[0]
z.Name = zone
z.Records = []configgen.Record{
{Name: owner, Type: configgen.TypeCNAME, Target: target},
{Name: "mx", Type: configgen.TypeMX, Target: target},
}
arts, err := Render(cfg)
if err != nil {
var ve *configgen.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("got %v (%T), want *configgen.ValidationError", err, err)
}
return
}
// Accepted names consist of name characters only, so nothing in
// them can end a string, a statement or a line.
for _, s := range []string{zone, owner, target} {
for k := 0; k < len(s); k++ {
c := s[k]
if (c < 'a' || c > 'z') && (c < '0' || c > '9') && !strings.ContainsRune(".-_@", rune(c)) {
t.Fatalf("accepted %q with byte %#x", s, c)
}
}
}
conf := artifact(t, arts, ConfFile)
if n := strings.Count(conf, "\n"); n != 9 {
t.Errorf("%s has %d lines, want 9:\n%s", ConfFile, n, conf)
}
if n := strings.Count(conf, `"`); n != 4 {
t.Errorf("%s has %d quotes, want 4:\n%s", ConfFile, n, conf)
}
zf := artifact(t, arts, "zones/db."+zone)
if n := strings.Count(zf, "\n"); n != 8 {
t.Errorf("zone file has %d lines, want 8:\n%s", n, zf)
}
for _, l := range strings.Split(zf, "\n") {
if !strings.HasPrefix(l, "; ") && strings.ContainsAny(l, "\";()\\") {
t.Errorf("zone file line %q contains special characters", l)
}
}
})
}