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>
1021 lines
41 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|