feat(configgen): BIND and Kea configuration generator with integration tests

- configgen: input model shared by derivation and renderers
- derive: forward zones per domain, reverse zones (IPv4 octet, IPv6 nibble
  boundaries), A/AAAA/PTR from static and manual_dhcp entries, Kea subnets
  with effective pools and reservations; pure and deterministic
- bindgen: zone-statement include, options include, zone files with strict
  validation, canonical ordering and RFC 1035 TXT escaping
- keagen: typed Kea DHCPv4 JSON with strict validation
- golden-file, validation, determinism and fuzz tests
- integration tests check generated files with real named-checkconf,
  named-checkzone (BIND 9.18, 9.20) and kea-dhcp4 -t (Kea 2.6, 3.2);
  new CI job

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
beug.k@beugsiud.de
2026-10-13 14:19:25 +02:00
co-authored by Claude Opus 5.5
parent e91d1d63c0
commit 3564686db0
40 changed files with 4961 additions and 0 deletions
+13
View File
@@ -135,6 +135,19 @@ jobs:
DDI_DATABASE_URL: postgres://ddi:ddi-ci-only@postgres:5432/ddi?sslmode=disable
run: scripts/ci/smoke-server.sh dist
# Needs a runner with access to a Docker daemon (see docs/adr/0003).
integration:
name: Integration (real BIND and Kea)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false
- name: Generated BIND and Kea configuration passes the real checkers
run: make test-integration
# Needs a runner with access to a Docker daemon (see docs/adr/0003).
dev-environment:
name: Development environment (compose)
+2
View File
@@ -3,6 +3,8 @@ version: "2"
run:
timeout: 5m
build-tags:
- integration
linters:
default: standard # errcheck, govet, ineffassign, staticcheck, unused
+5
View File
@@ -139,6 +139,11 @@ test-go:
test-web:
cd web && npm test
## test-integration: check generated configs with real BIND and Kea (needs docker)
.PHONY: test-integration
test-integration:
go test -tags integration -count=1 -v ./server/internal/configgen/integration/
## vuln: scan dependencies for known vulnerabilities
.PHONY: vuln
vuln:
+4
View File
@@ -18,6 +18,10 @@ Der Brief legt BIND 9 fest. Zu klären sind Mindestversion und die Art der Anste
- Kein `rndc addzone`/`catalog zones` in Version 1: Die Include-Datei ist vollständig generiert und damit deterministisch prüfbar. Katalogzonen sind eine mögliche spätere Optimierung für Secondaries.
- Rekursion ist standardmäßig aus.
## Nachträge
- **2026-10-13 (M2):** Statt einer Include-Datei erzeugt der Generator zwei: `ddi-suite.conf` (ACLs und `zone`-Blöcke, auf oberster Ebene eingebunden) und `ddi-suite-options.conf` (Rekursion, `allow-recursion`, `forwarders`, `allow-transfer`), die **innerhalb** des `options`-Blocks der Distribution eingebunden wird. BIND erlaubt nur einen `options`-Block; `include` ist dort zulässig (Parser in 9.18/9.20: „include statements wherever a map body clause can occur“). Die Distributionskonfiguration darf diese Optionen dann nicht selbst setzen; das richtet der Installer (M6) ein. Dynamische Zonen brauchen Schreibrechte von `named` auf das Zonenverzeichnis (Journale).
## Konsequenzen
- Distributionspakete reichen auf Debian 12/13 und Ubuntu 22.04/24.04 (alle ≥ 9.18). Auf RHEL 9 ist die Paketlage (`bind` 9.16 vs. `bind9.18`) in M6 zu prüfen.
+4
View File
@@ -14,6 +14,10 @@
- **SOA-Seriennummer** = Revisionsnummer des Clusters (streng monoton, 32 Bit). Bei Übernahme bestehender Zonen mit höherer Seriennummer wird ein Offset gespeichert.
- **Tests:** Golden-Files unter `testdata/` (Aktualisierung mit `go test ./... -update`), Fuzz-Tests für die Validierer und in den Integrationstests (`/test`) die Prüfung jeder erzeugten Konfiguration mit echtem `named-checkconf`/`named-checkzone` und `kea-dhcp4 -t` in Containern.
## Nachträge
- **2026-10-13 (M2):** Aufteilung in die Pakete `configgen` (Eingabemodell), `configgen/derive` (Ableitung aus IPAM), `configgen/bindgen` und `configgen/keagen` (Renderer). TXT-Inhalte mit Bytes außerhalb von 0x20–0x7E werden nicht abgelehnt, sondern als `\DDD` escaped (RFC 1035 §5.1); Namen mit Steuerzeichen bleiben verboten. Die Anfangsdatei dynamischer Zonen darf Records enthalten (z. B. vorbereitete SRV-Einträge); danach gehört sie BIND. Die Integrationstests liegen unter `server/internal/configgen/integration` (Build-Tag `integration`), weil Go-`internal`-Pakete aus `/test` nicht importierbar sind.
## Konsequenzen
Gleicher Datenbankstand ergibt byte-gleiche Konfiguration. Diffs zwischen Revisionen sind deshalb aussagekräftig.
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package bindgen renders a configgen.DNSConfig into BIND 9 (≥ 9.18)
// configuration: a named.conf include with the zone statements, a second
// include with statements for the options block, and one zone file per zone
// (ADR 0004, ADR 0014).
//
// The distribution's named.conf includes the generated files like this:
//
// options {
// directory "/var/cache/bind";
// include "/etc/bind/ddi-suite/ddi-suite-options.conf";
// };
// include "/etc/bind/ddi-suite/ddi-suite.conf";
//
// BIND accepts only one options block, so the generator cannot emit its own;
// BIND accepts "include" wherever a clause of a block may appear, so the
// options are included into the distribution's block instead.
//
// Every value is validated before anything is written. Names are restricted
// to letters, digits, '-' and (where DNS needs it) a leading '_', so no input
// can leave a quoted string or a statement.
package bindgen
import (
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
)
// Paths of the generated files relative to the configuration root.
const (
// ConfFile holds the top-level statements (ACLs and zones).
ConfFile = "ddi-suite.conf"
// OptionsFile holds statements for the options block.
OptionsFile = "ddi-suite-options.conf"
// ZonesDir holds the zone files; the agent maps it to DNSConfig.ZoneDir.
ZonesDir = "zones"
)
// fileMode is the mode of every generated file (readable by the named group).
const fileMode = 0o640
// Render validates cfg and returns the include files followed by one zone
// file per zone, sorted by zone name. Invalid input yields a
// *configgen.ValidationError and no artifacts.
func Render(cfg configgen.DNSConfig) ([]configgen.Artifact, error) {
m, err := validate(cfg)
if err != nil {
return nil, err
}
out := make([]configgen.Artifact, 0, 2+len(m.zones))
out = append(out,
configgen.Artifact{Path: ConfFile, Mode: fileMode, Content: renderConf(m)},
configgen.Artifact{Path: OptionsFile, Mode: fileMode, Content: renderOptions(m)},
)
for i := range m.zones {
z := &m.zones[i]
out = append(out, configgen.Artifact{
Path: ZonesDir + "/" + zoneFileName(z.name),
Mode: fileMode,
Content: renderZone(m.serial, z),
CreateOnly: z.dynamic,
})
}
return out, nil
}
// zoneFileName is the file name of a zone's file, e.g. "db.office.test".
func zoneFileName(zone string) string { return "db." + zone }
@@ -0,0 +1,674 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package bindgen
import (
"bytes"
"errors"
"flag"
"fmt"
"math/rand/v2"
"net/netip"
"os"
"path"
"path/filepath"
"slices"
"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")},
}
}
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()},
}
}
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
}
cfg := configgen.DNSConfig{Serial: 3, ZoneDir: testZoneDir}
cfg.Zones = append(cfg.Zones, dynamicZones()...)
cfg.Zones = append(cfg.Zones, forwardZone())
cfg.Zones = append(cfg.Zones, reverseZones()...)
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})
if len(a.Content) == 0 || a.Content[len(a.Content)-1] != '\n' {
t.Errorf("%s does not end with a newline", a.Path)
}
}
want := []meta{
{"ddi-suite.conf", 0o640, false},
{"ddi-suite-options.conf", 0o640, false},
{"zones/db.2.0.192.in-addr.arpa", 0o640, false},
{"zones/db.8.b.d.0.1.0.0.2.ip6.arpa", 0o640, false},
{"zones/db.dyn.test", 0o640, true},
{"zones/db.office.test", 0o640, false},
{"zones/db.static.test", 0o640, false},
}
if !slices.Equal(got, want) {
t.Errorf("artifacts\n got %v\nwant %v", got, want)
}
}
// 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}
}
}
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"},
{"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")}
}},
}
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)
}
})
}
}
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()}
cfg.Zones = append(cfg.Zones, forwardZone())
cfg.Zones = append(cfg.Zones, reverseZones()...)
cfg.Zones = append(cfg.Zones, dynamicZones()...)
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)
}
}
})
}
@@ -0,0 +1,132 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package bindgen
import (
"bytes"
"fmt"
"net/netip"
"strings"
)
// confWriter writes named.conf statements. Every value it receives has been
// validated: names and paths contain no quotes, semicolons, braces,
// whitespace or comment characters.
type confWriter struct {
b bytes.Buffer
}
// comment writes "// " comment lines.
func (w *confWriter) comment(lines ...string) {
for _, l := range lines {
w.b.WriteString("//")
if l != "" {
w.b.WriteByte(' ')
w.b.WriteString(l)
}
w.b.WriteByte('\n')
}
}
// blank writes an empty line.
func (w *confWriter) blank() { w.b.WriteByte('\n') }
// stmt writes one statement line at the given block depth; s carries its
// own terminating semicolon.
func (w *confWriter) stmt(depth int, s string) {
w.b.WriteString(strings.Repeat(" ", depth))
w.b.WriteString(s)
w.b.WriteByte('\n')
}
// quoted returns a validated value as a named.conf quoted string.
func quoted(s string) string { return `"` + s + `"` }
// braceList renders a list such as an address match list,
// e.g. "{ 192.0.2.0/24; 2001:db8::/32; }".
func braceList(elems []string) string {
var b strings.Builder
b.WriteString("{ ")
for _, e := range elems {
b.WriteString(e)
b.WriteString("; ")
}
b.WriteByte('}')
return b.String()
}
// prefixStrings formats prefixes for a match list.
func prefixStrings(ps []netip.Prefix) []string {
out := make([]string, len(ps))
for i, p := range ps {
out[i] = p.String()
}
return out
}
// updateACL is the name of the ACL of a dynamic zone's update sources.
func updateACL(zone string) string { return "ddi-update-" + zone }
// renderConf writes ddi-suite.conf: ACLs, then one zone statement per zone.
func renderConf(m *model) []byte {
var w confWriter
w.comment(
fmt.Sprintf("Generated by DDI Suite (revision %d). Do not edit: the DNS agent", m.serial),
"overwrites this file. Include it at the top level of named.conf.",
)
first := true
for i := range m.zones {
z := &m.zones[i]
if !z.dynamic {
continue
}
if first {
w.blank()
first = false
}
w.stmt(0, fmt.Sprintf("acl %s %s;", quoted(updateACL(z.name)), braceList(prefixStrings(z.allowUpdate))))
}
for i := range m.zones {
z := &m.zones[i]
update := "none"
if z.dynamic {
update = quoted(updateACL(z.name))
}
w.blank()
w.stmt(0, fmt.Sprintf("zone %s {", quoted(z.name)))
w.stmt(1, "type primary;")
w.stmt(1, fmt.Sprintf("file %s;", quoted(m.zoneDir+"/"+zoneFileName(z.name))))
w.stmt(1, "notify no;")
w.stmt(1, fmt.Sprintf("allow-update %s;", braceList([]string{update})))
w.stmt(0, "};")
}
return w.b.Bytes()
}
// renderOptions writes ddi-suite-options.conf, the statements included into
// the options block of named.conf.
func renderOptions(m *model) []byte {
var w confWriter
w.comment(
fmt.Sprintf("Generated by DDI Suite (revision %d). Do not edit: the DNS agent", m.serial),
"overwrites this file. Include it inside the options block of named.conf",
"and do not set the statements below there as well.",
)
w.blank()
o := m.options
if o.recursion {
w.stmt(0, "recursion yes;")
w.stmt(0, fmt.Sprintf("allow-recursion %s;", braceList(prefixStrings(o.allowRecursion))))
if len(o.forwarders) > 0 {
fwd := make([]string, len(o.forwarders))
for i, a := range o.forwarders {
fwd[i] = a.String()
}
w.stmt(0, fmt.Sprintf("forwarders %s;", braceList(fwd)))
}
} else {
w.stmt(0, "recursion no;")
}
w.stmt(0, "allow-transfer { none; };")
return w.b.Bytes()
}
+100
View File
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package bindgen
import (
"cmp"
"fmt"
"strings"
)
// Domain name limits (RFC 1035 §2.3.4, presentation form without the
// trailing dot).
const (
maxLabelLen = 63
maxNameLen = 253
)
// checkName validates a domain name made of lower-case LDH labels; with
// underscore, a label may also start with '_' ("_ldap._tcp"). It returns an
// empty string for valid names, otherwise the reason. The input is never
// echoed, so messages are safe to show anywhere.
func checkName(name string, underscore bool) string {
switch {
case name == "":
return "is required"
case len(name) > maxNameLen:
return fmt.Sprintf("is longer than %d characters", maxNameLen)
case strings.HasSuffix(name, "."):
return "must not end with a dot"
}
for i, l := range strings.Split(name, ".") {
if !validLabel(l, underscore) {
if underscore {
return fmt.Sprintf("label %d is invalid: 1-63 characters of a-z, 0-9 and '-', "+
"optionally starting with '_', not starting or ending with '-'", i+1)
}
return fmt.Sprintf("label %d is invalid: 1-63 characters of a-z, 0-9 and '-', "+
"not starting or ending with '-'", i+1)
}
}
return ""
}
// validLabel reports whether l is a lower-case LDH label (RFC 1123), or with
// underscore an LDH label prefixed by '_' (RFC 8552 attribute leaves).
func validLabel(l string, underscore bool) bool {
if len(l) == 0 || len(l) > maxLabelLen {
return false
}
if underscore && l[0] == '_' {
l = l[1:]
if l == "" {
return false
}
}
if l[0] == '-' || l[len(l)-1] == '-' {
return false
}
for i := 0; i < len(l); i++ {
c := l[i]
if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
return false
}
}
return true
}
// inZone reports whether the FQDN name is the zone apex or below it.
func inZone(name, zone string) bool {
return name == zone || strings.HasSuffix(name, "."+zone)
}
// relativeName returns name relative to zone ("@" for the apex); name must
// be inside the zone.
func relativeName(name, zone string) string {
if name == zone {
return "@"
}
return strings.TrimSuffix(name, "."+zone)
}
// compareOwners orders relative owner names canonically (RFC 4034 §6.1):
// the apex first, then label by label from the right.
func compareOwners(a, b string) int {
switch {
case a == b:
return 0
case a == "@":
return -1
case b == "@":
return 1
}
la, lb := strings.Split(a, "."), strings.Split(b, ".")
for i := 1; i <= min(len(la), len(lb)); i++ {
if c := strings.Compare(la[len(la)-i], lb[len(lb)-i]); c != 0 {
return c
}
}
return cmp.Compare(len(la), len(lb))
}
@@ -0,0 +1,8 @@
; Initial file of the dynamic zone dyn.test, generated by DDI Suite (revision 7).
; BIND owns this file once it exists; the DNS agent never overwrites it.
$ORIGIN dyn.test.
$TTL 1200
@ IN SOA ns1.dyn.test. hostmaster.dyn.test. 7 3600 600 604800 300
@ IN NS ns1.dyn.test.
ns1 IN A 192.0.2.53
dc1 IN A 192.0.2.20
@@ -0,0 +1,6 @@
; Zone static.test, generated by DDI Suite (revision 7).
; Do not edit: the DNS agent overwrites this file.
$ORIGIN static.test.
$TTL 3600
@ IN SOA ns1.dyn.test. hostmaster.dyn.test. 7 3600 600 604800 300
@ IN NS ns1.dyn.test.
@@ -0,0 +1,6 @@
// Generated by DDI Suite (revision 7). Do not edit: the DNS agent
// overwrites this file. Include it inside the options block of named.conf
// and do not set the statements below there as well.
recursion no;
allow-transfer { none; };
@@ -0,0 +1,18 @@
// Generated by DDI Suite (revision 7). Do not edit: the DNS agent
// overwrites this file. Include it at the top level of named.conf.
acl "ddi-update-dyn.test" { 192.0.2.10/32; 192.0.2.11/32; 2001:db8:1::/64; };
zone "dyn.test" {
type primary;
file "/etc/bind/ddi-suite/zones/db.dyn.test";
notify no;
allow-update { "ddi-update-dyn.test"; };
};
zone "static.test" {
type primary;
file "/etc/bind/ddi-suite/zones/db.static.test";
notify no;
allow-update { none; };
};
@@ -0,0 +1,22 @@
; Zone office.test, generated by DDI Suite (revision 42).
; Do not edit: the DNS agent overwrites this file.
$ORIGIN office.test.
$TTL 3600
@ IN SOA ns1.office.test. hostmaster.office.test. 42 3600 600 604800 300
@ IN NS ns1.office.test.
@ IN NS ns2.example.net.
ns1 IN A 192.0.2.53
ns1 IN AAAA 2001:db8::53
@ IN MX 10 mail.office.test.
@ IN MX 20 mx.example.net.
@ IN TXT "v=spf1 mx -all"
_ldap._tcp IN SRV 0 100 389 dc1.office.test.
dc1 IN A 192.0.2.20
info 600 IN TXT "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234" "567890123456789012345678901234567890123456789" "say \"hi\" C:\\temp" "caf\195\169" "tab\009here"
intranet IN CNAME www.office.test.
mail 300 IN A 192.0.2.25
sub IN NS ns1.sub.office.test.
ns1.sub IN A 192.0.2.60
www IN A 192.0.2.9
www IN A 192.0.2.10
www IN AAAA 2001:db8::10
@@ -0,0 +1,6 @@
// Generated by DDI Suite (revision 42). Do not edit: the DNS agent
// overwrites this file. Include it inside the options block of named.conf
// and do not set the statements below there as well.
recursion no;
allow-transfer { none; };
@@ -0,0 +1,9 @@
// Generated by DDI Suite (revision 42). Do not edit: the DNS agent
// overwrites this file. Include it at the top level of named.conf.
zone "office.test" {
type primary;
file "/etc/bind/ddi-suite/zones/db.office.test";
notify no;
allow-update { none; };
};
@@ -0,0 +1,8 @@
// Generated by DDI Suite (revision 1). Do not edit: the DNS agent
// overwrites this file. Include it inside the options block of named.conf
// and do not set the statements below there as well.
recursion yes;
allow-recursion { 10.0.0.0/8; 192.0.2.0/24; 2001:db8::/32; };
forwarders { 192.0.2.1; 2001:db8::1; };
allow-transfer { none; };
@@ -0,0 +1,2 @@
// Generated by DDI Suite (revision 1). Do not edit: the DNS agent
// overwrites this file. Include it at the top level of named.conf.
@@ -0,0 +1,9 @@
; Zone 2.0.192.in-addr.arpa, generated by DDI Suite (revision 42).
; Do not edit: the DNS agent overwrites this file.
$ORIGIN 2.0.192.in-addr.arpa.
$TTL 86400
@ IN SOA ns1.office.test. hostmaster.office.test. 42 3600 600 604800 300
@ IN NS ns1.office.test.
10 IN PTR www.office.test.
53 IN PTR ns1.office.test.
9 IN PTR www.office.test.
@@ -0,0 +1,8 @@
; Zone 8.b.d.0.1.0.0.2.ip6.arpa, generated by DDI Suite (revision 42).
; Do not edit: the DNS agent overwrites this file.
$ORIGIN 8.b.d.0.1.0.0.2.ip6.arpa.
$TTL 86400
@ IN SOA ns1.office.test. hostmaster.office.test. 42 3600 600 604800 300
@ IN NS ns1.office.test.
0.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0 IN PTR www.office.test.
3.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0 IN PTR ns1.office.test.
@@ -0,0 +1,6 @@
// Generated by DDI Suite (revision 42). Do not edit: the DNS agent
// overwrites this file. Include it inside the options block of named.conf
// and do not set the statements below there as well.
recursion no;
allow-transfer { none; };
@@ -0,0 +1,16 @@
// Generated by DDI Suite (revision 42). Do not edit: the DNS agent
// overwrites this file. Include it at the top level of named.conf.
zone "2.0.192.in-addr.arpa" {
type primary;
file "/etc/bind/ddi-suite/zones/db.2.0.192.in-addr.arpa";
notify no;
allow-update { none; };
};
zone "8.b.d.0.1.0.0.2.ip6.arpa" {
type primary;
file "/etc/bind/ddi-suite/zones/db.8.b.d.0.1.0.0.2.ip6.arpa";
notify no;
allow-update { none; };
};
@@ -0,0 +1,457 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package bindgen
import (
"cmp"
"fmt"
"net/netip"
"path"
"slices"
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
)
// maxTTL is the largest TTL BIND accepts (RFC 2181 §8).
const maxTTL = 1<<31 - 1
// maxRDataLen is the largest RDATA in wire format.
const maxRDataLen = 65535
// model is the validated, normalized and sorted input of the writers.
type model struct {
serial uint32
zoneDir string
options options
zones []zoneData
}
// options are the validated server options.
type options struct {
recursion bool
allowRecursion []netip.Prefix // sorted, without duplicates
forwarders []netip.Addr // input order (the administrator's choice)
}
// zoneData is one validated zone.
type zoneData struct {
name string
dynamic bool
defaultTTL uint32
soa configgen.SOA
nameServers []string // sorted FQDNs
glue []rr // sorted, without duplicates
records []rr // sorted, without duplicates and without RRs already in glue or NS
allowUpdate []netip.Prefix
}
// rr is one validated resource record with its rendered RDATA.
type rr struct {
owner string // relative name or "@"
ttl uint32 // 0: zone default
typ configgen.RRType
rdata string
// Sort keys besides rdata: addresses and numeric fields compare as
// numbers, not as text.
addr netip.Addr
nums [3]uint16
}
// invalid builds the error returned for rejected input.
func invalid(path, msg string) error {
return &configgen.ValidationError{Path: path, Message: msg}
}
// validate checks cfg completely and returns the model the writers render.
func validate(cfg configgen.DNSConfig) (*model, error) {
if cfg.Serial == 0 {
return nil, invalid("serial", "must not be 0")
}
if err := checkZoneDir(cfg.ZoneDir); err != nil {
return nil, err
}
opts, err := validateOptions(cfg.Options)
if err != nil {
return nil, err
}
m := &model{serial: cfg.Serial, zoneDir: cfg.ZoneDir, options: opts}
seen := make(map[string]bool, len(cfg.Zones))
for i, z := range cfg.Zones {
zd, err := validateZone(i, z)
if err != nil {
return nil, err
}
if seen[zd.name] {
return nil, invalid(fmt.Sprintf("zones[%d].name", i), "duplicate zone")
}
seen[zd.name] = true
m.zones = append(m.zones, zd)
}
slices.SortFunc(m.zones, func(a, b zoneData) int { return strings.Compare(a.name, b.name) })
return m, nil
}
// checkZoneDir accepts clean absolute paths of [A-Za-z0-9/._-].
func checkZoneDir(dir string) error {
const p = "zone_dir"
for i := 0; i < len(dir); i++ {
c := dir[i]
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') &&
c != '/' && c != '.' && c != '_' && c != '-' {
return invalid(p, "may only contain A-Z, a-z, 0-9, '/', '.', '_' and '-'")
}
}
switch {
case !strings.HasPrefix(dir, "/"):
return invalid(p, "must be an absolute path")
case dir == "/":
return invalid(p, "must not be the root directory")
case path.Clean(dir) != dir:
return invalid(p, "must be a clean path (no trailing '/', no '.' or '..' elements)")
}
return nil
}
// validateOptions checks the server options.
func validateOptions(o configgen.DNSOptions) (options, error) {
allow, err := checkPrefixes("options.allow_recursion", o.AllowRecursion)
if err != nil {
return options{}, err
}
if o.Recursion && len(allow) == 0 {
return options{}, invalid("options.allow_recursion", "at least one client prefix is required when recursion is enabled")
}
for i, a := range o.Forwarders {
p := fmt.Sprintf("options.forwarders[%d]", i)
if err := checkHostAddr(p, a); err != nil {
return options{}, err
}
if slices.Contains(o.Forwarders[:i], a) {
return options{}, invalid(p, "duplicate forwarder")
}
}
return options{
recursion: o.Recursion,
allowRecursion: allow,
forwarders: slices.Clone(o.Forwarders),
}, nil
}
// checkPrefixes validates an address match list and returns it sorted and
// without duplicates.
func checkPrefixes(p string, in []netip.Prefix) ([]netip.Prefix, error) {
for i, pfx := range in {
ip := fmt.Sprintf("%s[%d]", p, i)
switch {
case !pfx.IsValid():
return nil, invalid(ip, "is not a valid prefix")
case pfx.Addr().Zone() != "":
return nil, invalid(ip, "must not have an IPv6 zone")
case pfx.Addr().Is4In6():
return nil, invalid(ip, "must not be IPv4-mapped; use the IPv4 form")
case pfx.Masked() != pfx:
return nil, invalid(ip, "has host bits set; use "+pfx.Masked().String())
case pfx.Bits() == 0:
return nil, invalid(ip, "must not match every address")
}
}
out := slices.Clone(in)
slices.SortFunc(out, func(a, b netip.Prefix) int {
return cmp.Or(a.Addr().Compare(b.Addr()), cmp.Compare(a.Bits(), b.Bits()))
})
return slices.Compact(out), nil
}
// checkHostAddr accepts unicast addresses usable as server addresses.
func checkHostAddr(p string, a netip.Addr) error {
switch {
case !a.IsValid():
return invalid(p, "is not a valid IP address")
case a.Zone() != "":
return invalid(p, "must not have an IPv6 zone")
case a.Is4In6():
return invalid(p, "must not be IPv4-mapped; use the IPv4 form")
case a.IsUnspecified() || a.IsMulticast():
return invalid(p, "must be a unicast address")
}
return nil
}
// checkTTL validates a TTL or SOA timer.
func checkTTL(p string, v uint32, zeroOK bool) error {
switch {
case v == 0 && !zeroOK:
return invalid(p, "must not be 0")
case v > maxTTL:
return invalid(p, fmt.Sprintf("must not exceed %d", maxTTL))
}
return nil
}
// checkFQDN validates an absolute host name given without trailing dot.
func checkFQDN(p, name string) error {
if msg := checkName(name, false); msg != "" {
return invalid(p, msg)
}
return nil
}
// validateZone checks one zone; i is its index in the input.
func validateZone(i int, z configgen.Zone) (zoneData, error) {
if msg := checkName(z.Name, false); msg != "" {
return zoneData{}, invalid(fmt.Sprintf("zones[%d].name", i), msg)
}
p := fmt.Sprintf("zones[%q]", z.Name)
reverse := strings.HasSuffix(z.Name, ".in-addr.arpa") || strings.HasSuffix(z.Name, ".ip6.arpa")
switch z.Kind {
case configgen.ZoneForward:
if z.Name == "arpa" || strings.HasSuffix(z.Name, ".arpa") {
return zoneData{}, invalid(p+".kind", "a zone below arpa must be a reverse zone")
}
case configgen.ZoneReverse:
if !reverse {
return zoneData{}, invalid(p+".name", "a reverse zone must end in .in-addr.arpa or .ip6.arpa")
}
default:
return zoneData{}, invalid(p+".kind", "must be forward or reverse")
}
zd := zoneData{name: z.Name, defaultTTL: z.DefaultTTL, soa: z.SOA}
switch z.Mode {
case configgen.ZoneManaged:
case configgen.ZoneDynamic:
zd.dynamic = true
default:
return zoneData{}, invalid(p+".mode", "must be managed or dynamic")
}
if err := checkTTL(p+".default_ttl", z.DefaultTTL, false); err != nil {
return zoneData{}, err
}
if err := validateSOA(p+".soa", z.SOA); err != nil {
return zoneData{}, err
}
if err := validateNameServers(p, &zd, z.NameServers); err != nil {
return zoneData{}, err
}
if err := validateRecords(p, &zd, z.Records); err != nil {
return zoneData{}, err
}
if zd.dynamic {
if len(z.AllowUpdate) == 0 {
return zoneData{}, invalid(p+".allow_update", "a dynamic zone must allow updates from at least one prefix")
}
allow, err := checkPrefixes(p+".allow_update", z.AllowUpdate)
if err != nil {
return zoneData{}, err
}
zd.allowUpdate = allow
}
return zd, nil
}
// validateSOA checks the SOA fields.
func validateSOA(p string, s configgen.SOA) error {
if err := checkFQDN(p+".primary_ns", s.PrimaryNS); err != nil {
return err
}
if err := checkFQDN(p+".hostmaster", s.Hostmaster); err != nil {
return err
}
for _, t := range []struct {
field string
v uint32
}{{"refresh", s.Refresh}, {"retry", s.Retry}, {"expire", s.Expire}, {"minimum", s.Minimum}} {
if err := checkTTL(p+"."+t.field, t.v, false); err != nil {
return err
}
}
return nil
}
// validateNameServers checks the apex NS set and builds the glue records.
func validateNameServers(zp string, zd *zoneData, nss []configgen.NameServer) error {
if len(nss) == 0 {
return invalid(zp+".name_servers", "at least one name server is required")
}
for i, ns := range nss {
p := fmt.Sprintf("%s.name_servers[%d]", zp, i)
if err := checkFQDN(p+".name", ns.Name); err != nil {
return err
}
if slices.Contains(zd.nameServers, ns.Name) {
return invalid(p+".name", "duplicate name server")
}
zd.nameServers = append(zd.nameServers, ns.Name)
for j, a := range ns.Addrs {
if err := checkHostAddr(fmt.Sprintf("%s.addrs[%d]", p, j), a); err != nil {
return err
}
}
if !inZone(ns.Name, zd.name) {
continue // glue is only written for name servers inside the zone
}
if len(ns.Addrs) == 0 {
return invalid(p+".addrs", "a name server inside the zone needs glue addresses")
}
owner := relativeName(ns.Name, zd.name)
for _, a := range ns.Addrs {
typ := configgen.TypeA
if a.Is6() {
typ = configgen.TypeAAAA
}
zd.glue = append(zd.glue, rr{owner: owner, typ: typ, rdata: a.String(), addr: a})
}
}
slices.Sort(zd.nameServers)
zd.glue = sortRRs(zd.glue)
return nil
}
// validateRecords checks the records and stores them sorted in zd.
func validateRecords(zp string, zd *zoneData, recs []configgen.Record) error {
rrs := make([]rr, 0, len(recs))
for i, r := range recs {
x, err := validateRecord(fmt.Sprintf("%s.records[%d]", zp, i), zd.name, r)
if err != nil {
return err
}
rrs = append(rrs, x)
}
// A CNAME owner must not own anything else (RFC 1034 §3.6.2), glue
// included.
owners := make(map[string]int, len(rrs)+len(zd.glue))
for _, x := range rrs {
owners[x.owner]++
}
for _, g := range zd.glue {
owners[g.owner]++
}
for i, x := range rrs {
if x.typ == configgen.TypeCNAME && owners[x.owner] > 1 {
return invalid(fmt.Sprintf("%s.records[%d].name", zp, i), "a CNAME owner must not have other records")
}
}
// Drop records that repeat glue or apex NS records.
written := make(map[[3]string]bool, len(zd.glue)+len(zd.nameServers))
for _, g := range zd.glue {
written[g.key()] = true
}
for _, ns := range zd.nameServers {
written[[3]string{"@", string(configgen.TypeNS), ns + "."}] = true
}
rrs = slices.DeleteFunc(rrs, func(x rr) bool { return written[x.key()] })
zd.records = sortRRs(rrs)
return nil
}
// validateRecord checks one record and renders its RDATA.
func validateRecord(p, zone string, r configgen.Record) (rr, error) {
if err := checkOwner(p+".name", r.Name, zone); err != nil {
return rr{}, err
}
if err := checkTTL(p+".ttl", r.TTL, true); err != nil {
return rr{}, err
}
x := rr{owner: r.Name, ttl: r.TTL, typ: r.Type}
target := func(underscore bool) error {
if msg := checkName(r.Target, underscore); msg != "" {
return invalid(p+".target", msg)
}
return nil
}
switch r.Type {
case configgen.TypeA:
if !r.Addr.Is4() {
return rr{}, invalid(p+".addr", "must be an IPv4 address")
}
x.addr, x.rdata = r.Addr, r.Addr.String()
case configgen.TypeAAAA:
if !r.Addr.Is6() || r.Addr.Is4In6() || r.Addr.Zone() != "" {
return rr{}, invalid(p+".addr", "must be an IPv6 address without zone")
}
x.addr, x.rdata = r.Addr, r.Addr.String()
case configgen.TypeCNAME:
if r.Name == "@" {
return rr{}, invalid(p+".name", "a CNAME is not allowed at the zone apex")
}
// CNAME targets may be service names (DKIM, ACME delegation).
if err := target(true); err != nil {
return rr{}, err
}
x.rdata = r.Target + "."
case configgen.TypePTR, configgen.TypeNS:
if err := target(false); err != nil {
return rr{}, err
}
x.rdata = r.Target + "."
case configgen.TypeMX:
if err := target(false); err != nil {
return rr{}, err
}
x.nums[0] = r.Preference
x.rdata = fmt.Sprintf("%d %s.", r.Preference, r.Target)
case configgen.TypeSRV:
if err := target(false); err != nil {
return rr{}, err
}
x.nums = [3]uint16{r.Priority, r.Weight, r.Port}
x.rdata = fmt.Sprintf("%d %d %d %s.", r.Priority, r.Weight, r.Port, r.Target)
case configgen.TypeTXT:
if len(r.Text) == 0 {
return rr{}, invalid(p+".text", "at least one string is required")
}
rdata, wire := txtRData(r.Text)
if wire > maxRDataLen {
return rr{}, invalid(p+".text", fmt.Sprintf("exceeds %d bytes in wire format", maxRDataLen))
}
x.rdata = rdata
default:
return rr{}, invalid(p+".type", "unsupported record type")
}
return x, nil
}
// checkOwner validates a record owner relative to zone.
func checkOwner(p, name, zone string) error {
switch {
case name == "@":
return nil
case strings.HasSuffix(name, "."):
return invalid(p, "must be relative to the zone (no trailing dot)")
case inZone(name, zone):
return invalid(p, "must be relative to the zone (without the zone name)")
}
if msg := checkName(name, true); msg != "" {
return invalid(p, msg)
}
if len(name)+1+len(zone) > maxNameLen {
return invalid(p, fmt.Sprintf("is longer than %d characters together with the zone name", maxNameLen))
}
return nil
}
// key identifies an RR regardless of its TTL.
func (x rr) key() [3]string { return [3]string{x.owner, string(x.typ), x.rdata} }
// compareRR orders RRs by owner (canonical), type, RDATA and TTL.
func compareRR(a, b rr) int {
return cmp.Or(
compareOwners(a.owner, b.owner),
strings.Compare(string(a.typ), string(b.typ)),
a.addr.Compare(b.addr),
cmp.Compare(a.nums[0], b.nums[0]),
cmp.Compare(a.nums[1], b.nums[1]),
cmp.Compare(a.nums[2], b.nums[2]),
strings.Compare(a.rdata, b.rdata),
cmp.Compare(a.ttl, b.ttl),
)
}
// sortRRs sorts rrs and removes repeated RRs; of RRs differing only in TTL
// the first in sort order stays.
func sortRRs(rrs []rr) []rr {
slices.SortFunc(rrs, compareRR)
return slices.CompactFunc(rrs, func(a, b rr) bool { return a.key() == b.key() })
}
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package bindgen
import (
"bytes"
"fmt"
"strconv"
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
)
// maxCharString is the longest <character-string> (RFC 1035 §3.3).
const maxCharString = 255
// zoneWriter writes master file lines (RFC 1035 §5.1) in fixed columns.
type zoneWriter struct {
b bytes.Buffer
}
// comment writes a "; " comment line; text must be validated.
func (w *zoneWriter) comment(text string) {
w.b.WriteString("; ")
w.b.WriteString(text)
w.b.WriteByte('\n')
}
// directive writes a "$NAME value" line.
func (w *zoneWriter) directive(name, value string) {
fmt.Fprintf(&w.b, "%s %s\n", name, value)
}
// rr writes one record: owner, TTL (empty for the zone default), class,
// type and RDATA, each column padded to a fixed width. The owner is never
// empty, so no line inherits the previous owner.
func (w *zoneWriter) rr(owner string, ttl uint32, typ configgen.RRType, rdata string) {
t := ""
if ttl != 0 {
t = strconv.FormatUint(uint64(ttl), 10)
}
fmt.Fprintf(&w.b, "%-24s %-10s IN %-5s %s\n", owner, t, typ, rdata)
}
// renderZone writes the zone file of z.
func renderZone(serial uint32, z *zoneData) []byte {
var w zoneWriter
if z.dynamic {
w.comment(fmt.Sprintf("Initial file of the dynamic zone %s, generated by DDI Suite (revision %d).", z.name, serial))
w.comment("BIND owns this file once it exists; the DNS agent never overwrites it.")
} else {
w.comment(fmt.Sprintf("Zone %s, generated by DDI Suite (revision %d).", z.name, serial))
w.comment("Do not edit: the DNS agent overwrites this file.")
}
w.directive("$ORIGIN", z.name+".")
w.directive("$TTL", strconv.FormatUint(uint64(z.defaultTTL), 10))
w.rr("@", 0, "SOA", fmt.Sprintf("%s. %s. %d %d %d %d %d",
z.soa.PrimaryNS, z.soa.Hostmaster, serial, z.soa.Refresh, z.soa.Retry, z.soa.Expire, z.soa.Minimum))
for _, ns := range z.nameServers {
w.rr("@", 0, configgen.TypeNS, ns+".")
}
for _, g := range z.glue {
w.rr(g.owner, g.ttl, g.typ, g.rdata)
}
for _, r := range z.records {
w.rr(r.owner, r.ttl, r.typ, r.rdata)
}
return w.b.Bytes()
}
// txtRData renders the logical strings of a TXT record as quoted
// character-strings of at most 255 bytes each and returns the RDATA length
// in wire format.
func txtRData(texts []string) (rdata string, wire int) {
var b strings.Builder
for _, s := range texts {
for _, chunk := range splitCharStrings(s) {
if b.Len() > 0 {
b.WriteByte(' ')
}
writeQuoted(&b, chunk)
wire += 1 + len(chunk)
}
}
return b.String(), wire
}
// splitCharStrings splits s into pieces of at most 255 bytes. Splitting is
// by byte, so a multi-byte UTF-8 sequence may span two pieces; DNS consumers
// of TXT data concatenate them again. An empty s yields one empty piece.
func splitCharStrings(s string) []string {
var out []string
for len(s) > maxCharString {
out = append(out, s[:maxCharString])
s = s[maxCharString:]
}
return append(out, s)
}
// writeQuoted writes s as a quoted character-string: '"' and '\' are
// escaped with a backslash, every byte outside printable ASCII becomes \DDD
// (RFC 1035 §5.1), so the result is plain ASCII on a single line.
func writeQuoted(b *strings.Builder, s string) {
b.WriteByte('"')
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c == '"' || c == '\\':
b.WriteByte('\\')
b.WriteByte(c)
case c < 0x20 || c > 0x7e:
b.WriteByte('\\')
b.WriteByte('0' + c/100)
b.WriteByte('0' + c/10%10)
b.WriteByte('0' + c%10)
default:
b.WriteByte(c)
}
}
b.WriteByte('"')
}
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package derive turns the IPAM data (configgen.Source) into the typed DNS
// and DHCP models that the renderers write: forward and reverse zones with
// their A/AAAA/PTR records, Kea subnets with effective pools and host
// reservations.
//
// Everything here is a pure function: no I/O, no clock, no randomness. The
// input is never modified and never aliased by the output, and the output
// is sorted, so equal input yields equal output regardless of input order.
// Inconsistencies are not errors; the affected entry is skipped and reported
// as a configgen.Warning.
package derive
import (
"cmp"
"slices"
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
)
// Warning codes. They are part of the API contract; the UI translates them.
const (
// WarnDNSNoDomain: an address with a hostname gets no forward record
// because its network has no domain (params ip, hostname).
WarnDNSNoDomain = "dns.no_domain"
// WarnDNSUnknownNetwork: an address refers to a network that is not part
// of the source and is ignored (params ip, network_id).
WarnDNSUnknownNetwork = "dns.unknown_network"
// WarnDNSReverseTooLarge: no reverse zone is derived for a network
// shorter than /8 (IPv4) or /16 (IPv6) (param cidr).
WarnDNSReverseTooLarge = "dns.reverse_too_large"
// WarnDNSZoneConflict: a derived reverse zone has the name of a forward
// zone and is dropped (param zone).
WarnDNSZoneConflict = "dns.zone_conflict"
// WarnDNSDuplicatePTR: several entries with the same IP want a PTR
// record; the lexically smallest FQDN wins, the others are dropped
// (params ip, fqdn of the dropped entry).
WarnDNSDuplicatePTR = "dns.duplicate_ptr"
// WarnDHCPNoSubnetID: a DHCP-enabled network has no Kea subnet ID and is
// skipped (params network_id, cidr).
WarnDHCPNoSubnetID = "dhcp.no_subnet_id"
// WarnDHCPReservationWithoutMAC: a manual_dhcp entry without MAC gets no
// reservation (param ip).
WarnDHCPReservationWithoutMAC = "dhcp.reservation_without_mac"
)
// warnings collects warnings during a derivation.
type warnings []configgen.Warning
// add appends a warning; kv are alternating parameter keys and values.
func (w *warnings) add(code string, kv ...string) {
wn := configgen.Warning{Code: code}
if len(kv) > 0 {
wn.Params = make(map[string]string, len(kv)/2)
for i := 0; i+1 < len(kv); i += 2 {
wn.Params[kv[i]] = kv[i+1]
}
}
*w = append(*w, wn)
}
// sorted returns the warnings ordered by code, then by parameters.
func (w warnings) sorted() []configgen.Warning {
out := slices.Clone([]configgen.Warning(w))
slices.SortStableFunc(out, func(a, b configgen.Warning) int {
return cmp.Or(strings.Compare(a.Code, b.Code), strings.Compare(paramKey(a.Params), paramKey(b.Params)))
})
return out
}
// paramKey serializes parameters in key order for sorting.
func paramKey(p map[string]string) string {
keys := make([]string, 0, len(p))
for k := range p {
keys = append(keys, k)
}
slices.Sort(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteByte('=')
b.WriteString(p[k])
b.WriteByte(0)
}
return b.String()
}
@@ -0,0 +1,761 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package derive
import (
"fmt"
"maps"
"net/netip"
"reflect"
"slices"
"strings"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
)
// ---------------------------------------------------------------------------
// Fixtures and helpers
// ---------------------------------------------------------------------------
func ip(s string) netip.Addr { return netip.MustParseAddr(s) }
func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) }
func ptr[T any](v T) *T { return &v }
func network(id, cidr, domain string) ipam.Network {
return ipam.Network{ID: id, Name: id, CIDR: pfx(cidr), Domain: domain}
}
func entry(netID, addr string, t ipam.AddressType, hostname, fqdn string) ipam.Address {
return ipam.Address{ID: netID + "/" + addr, NetworkID: netID, IP: ip(addr), Type: t, Hostname: hostname, FQDN: fqdn}
}
func dnsSettings() configgen.DNSSettings {
return configgen.DNSSettings{
ZoneDir: "/etc/bind/ddi-suite/zones",
Options: configgen.DNSOptions{
Recursion: true,
AllowRecursion: []netip.Prefix{pfx("192.0.2.0/24")},
Forwarders: []netip.Addr{ip("198.51.100.53")},
},
DefaultTTL: 3600,
SOA: configgen.SOA{
PrimaryNS: "ns1.example.test", Hostmaster: "hostmaster.example.test",
Refresh: 7200, Retry: 900, Expire: 1209600, Minimum: 300,
},
NameServers: []configgen.NameServer{
{Name: "ns1.example.test", Addrs: []netip.Addr{ip("192.0.2.53"), ip("2001:db8::53")}},
{Name: "ns2.example.test"},
},
}
}
func dhcpSettings() configgen.DHCPSettings {
return configgen.DHCPSettings{
Interfaces: []string{"eth0", "eth1"},
DirectClients: true,
ControlSocket: "/run/kea/kea4-ctrl-socket",
LeaseFile: "/var/lib/kea/kea-leases4.csv",
ValidLifetime: 3600,
}
}
const (
fwd = configgen.ZoneForward
rev = configgen.ZoneReverse
)
// zone is a managed zone with the settings of dnsSettings.
func zone(name string, kind configgen.ZoneKind, records ...configgen.Record) configgen.Zone {
s := dnsSettings()
return configgen.Zone{
Name: name, Kind: kind, Mode: configgen.ZoneManaged,
DefaultTTL: s.DefaultTTL, SOA: s.SOA, NameServers: s.NameServers, Records: records,
}
}
// zoneSeq returns the reverse zones format%from … format%to, with records
// for some of them.
func zoneSeq(format string, from, to int, records map[string][]configgen.Record) []configgen.Zone {
var out []configgen.Zone
for i := from; i <= to; i++ {
name := fmt.Sprintf(format, i)
out = append(out, zone(name, rev, records[name]...))
}
return out
}
func recA(owner, addr string) configgen.Record {
return configgen.Record{Name: owner, Type: configgen.TypeA, Addr: ip(addr)}
}
func recAAAA(owner, addr string) configgen.Record {
return configgen.Record{Name: owner, Type: configgen.TypeAAAA, Addr: ip(addr)}
}
func recPTR(owner, target string) configgen.Record {
return configgen.Record{Name: owner, Type: configgen.TypePTR, Target: target}
}
func warn(code string, kv ...string) configgen.Warning {
w := configgen.Warning{Code: code, Params: map[string]string{}}
for i := 0; i+1 < len(kv); i += 2 {
w.Params[kv[i]] = kv[i+1]
}
return w
}
// equal reports a mismatch with both sides printed.
func equal(t *testing.T, what string, got, want any) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("%s differs\n got: %+v\nwant: %+v", what, got, want)
}
}
// equalWarnings compares warnings; nil and empty are equal.
func equalWarnings(t *testing.T, got, want []configgen.Warning) {
t.Helper()
if len(got) == 0 && len(want) == 0 {
return
}
equal(t, "warnings", got, want)
}
// equalZones compares zone lists zone by zone for readable failures.
func equalZones(t *testing.T, got, want []configgen.Zone) {
t.Helper()
names := func(zs []configgen.Zone) []string {
out := make([]string, len(zs))
for i, z := range zs {
out[i] = z.Name
}
return out
}
if !slices.Equal(names(got), names(want)) {
t.Errorf("zone names differ\n got: %v\nwant: %v", names(got), names(want))
return
}
for i := range got {
equal(t, "zone "+got[i].Name, got[i], want[i])
}
}
// v6 nibble zones used below.
const (
zoneV6Net2 = "2.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa" // 2001:db8:1:2::/64
zoneV6Net4 = "4.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa" // 2001:db8:1:4::/64
zoneV6Net5 = "5.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"
zoneV6Net6 = "6.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"
zoneV6Net7 = "7.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa"
)
// fullV6 is the reverse name of 2001:db8::1.
var fullV6 = "1.0.0.0." + strings.Repeat("0.", 20) + "8.b.d.0.1.0.0.2.ip6.arpa"
// ---------------------------------------------------------------------------
// Reverse zone rules
// ---------------------------------------------------------------------------
func TestReverseZones(t *testing.T) {
seq := func(format string, from, to int) []string {
var out []string
for i := from; i <= to; i++ {
out = append(out, fmt.Sprintf(format, i))
}
return out
}
tests := []struct {
cidr string
want []string // nil: too large
}{
{"192.0.2.0/24", []string{"2.0.192.in-addr.arpa"}},
{"192.0.2.128/25", []string{"2.0.192.in-addr.arpa"}},
{"192.0.2.7/32", []string{"2.0.192.in-addr.arpa"}},
{"10.1.4.0/22", []string{"4.1.10.in-addr.arpa", "5.1.10.in-addr.arpa", "6.1.10.in-addr.arpa", "7.1.10.in-addr.arpa"}},
{"10.1.6.0/23", []string{"6.1.10.in-addr.arpa", "7.1.10.in-addr.arpa"}},
{"172.16.0.0/16", seq("%d.16.172.in-addr.arpa", 0, 255)},
{"10.16.0.0/12", seq("%d.10.in-addr.arpa", 16, 31)},
{"10.0.0.0/8", seq("%d.10.in-addr.arpa", 0, 255)},
{"10.0.0.0/7", nil},
{"0.0.0.0/0", nil},
{"2001:db8:1:2::/64", []string{zoneV6Net2}},
{"2001:db8:1:4::/62", []string{zoneV6Net4, zoneV6Net5, zoneV6Net6, zoneV6Net7}},
{"2001:db8:1:6::/63", []string{zoneV6Net6, zoneV6Net7}},
{"2001:db8::/32", []string{"8.b.d.0.1.0.0.2.ip6.arpa"}},
{"2001:db8::/30", []string{"8.b.d.0.1.0.0.2.ip6.arpa", "9.b.d.0.1.0.0.2.ip6.arpa", "a.b.d.0.1.0.0.2.ip6.arpa", "b.b.d.0.1.0.0.2.ip6.arpa"}},
{"2001::/16", []string{"1.0.0.2.ip6.arpa"}},
{"2001:db8::1/128", []string{fullV6}},
{"2000::/15", nil},
}
for _, tt := range tests {
t.Run(tt.cidr, func(t *testing.T) {
got, ok := reverseZones(pfx(tt.cidr))
if ok != (tt.want != nil) {
t.Fatalf("ok = %v, want %v", ok, tt.want != nil)
}
equal(t, "zones", got, tt.want)
})
}
// Spot checks independent of the generated lists above.
if got, _ := reverseZones(pfx("172.16.0.0/16")); len(got) != 256 || got[0] != "0.16.172.in-addr.arpa" || got[255] != "255.16.172.in-addr.arpa" {
t.Errorf("/16: %d zones: %v", len(got), got)
}
if got, _ := reverseZones(pfx("10.16.0.0/12")); len(got) != 16 || got[0] != "16.10.in-addr.arpa" || got[15] != "31.10.in-addr.arpa" {
t.Errorf("/12: %d zones: %v", len(got), got)
}
}
func TestReverseName(t *testing.T) {
for in, want := range map[string]string{
"192.0.2.10": "10.2.0.192.in-addr.arpa",
"10.17.2.10": "10.2.17.10.in-addr.arpa",
"0.0.0.0": "0.0.0.0.in-addr.arpa",
"255.255.255.255": "255.255.255.255.in-addr.arpa",
"2001:db8::1": fullV6,
"2001:db8:1:2::10": "0.1" + strings.Repeat(".0", 14) + "." + zoneV6Net2,
} {
if got := reverseName(ip(in)); got != want {
t.Errorf("%s: got %q, want %q", in, got, want)
}
}
}
// ---------------------------------------------------------------------------
// BuildDNS
// ---------------------------------------------------------------------------
func TestBuildDNS(t *testing.T) {
tests := []struct {
name string
networks []ipam.Network
addresses []ipam.Address
want []configgen.Zone // compared after sorting by name
warnings []configgen.Warning
}{
{
name: "forward A/AAAA and PTR, shared domain, only static and manual_dhcp",
networks: []ipam.Network{
network("n1", "192.0.2.0/24", "office.example.test"),
network("n6", "2001:db8:1:2::/64", "office.example.test"),
},
addresses: []ipam.Address{
entry("n1", "192.0.2.10", ipam.TypeStatic, "srv1", "srv1.office.example.test"),
entry("n1", "192.0.2.20", ipam.TypeManualDHCP, "pc20", "pc20.office.example.test"),
entry("n1", "192.0.2.30", ipam.TypeReserved, "res30", "res30.office.example.test"),
entry("n1", "192.0.2.150", ipam.TypeDynamic, "dyn150", "dyn150.office.example.test"),
entry("n1", "192.0.2.40", ipam.TypeStatic, "", ""),
entry("n6", "2001:db8:1:2::10", ipam.TypeStatic, "srv1", "srv1.office.example.test"),
},
want: []configgen.Zone{
zone("office.example.test", fwd,
recA("pc20", "192.0.2.20"),
recA("srv1", "192.0.2.10"),
recAAAA("srv1", "2001:db8:1:2::10"),
),
zone("2.0.192.in-addr.arpa", rev,
recPTR("10", "srv1.office.example.test"),
recPTR("20", "pc20.office.example.test"),
),
zone(zoneV6Net2, rev,
recPTR("0.1"+strings.Repeat(".0", 14), "srv1.office.example.test"),
),
},
},
{
name: "hostname without domain",
networks: []ipam.Network{network("n2", "198.51.100.0/24", "")},
addresses: []ipam.Address{
entry("n2", "198.51.100.6", ipam.TypeManualDHCP, "scanner", ""),
entry("n2", "198.51.100.5", ipam.TypeStatic, "printer", ""),
entry("n2", "198.51.100.7", ipam.TypeReserved, "blocked", ""),
entry("n2", "198.51.100.8", ipam.TypeDynamic, "laptop", ""),
},
want: []configgen.Zone{zone("100.51.198.in-addr.arpa", rev)},
warnings: []configgen.Warning{
warn(WarnDNSNoDomain, "ip", "198.51.100.5", "hostname", "printer"),
warn(WarnDNSNoDomain, "ip", "198.51.100.6", "hostname", "scanner"),
},
},
{
name: "two /25 networks share one reverse zone",
networks: []ipam.Network{
network("lo", "192.0.2.0/25", "a.test"),
network("hi", "192.0.2.128/25", "b.test"),
},
addresses: []ipam.Address{
entry("hi", "192.0.2.200", ipam.TypeStatic, "h200", "h200.b.test"),
entry("lo", "192.0.2.10", ipam.TypeStatic, "h10", "h10.a.test"),
},
want: []configgen.Zone{
zone("2.0.192.in-addr.arpa", rev, recPTR("10", "h10.a.test"), recPTR("200", "h200.b.test")),
zone("a.test", fwd, recA("h10", "192.0.2.10")),
zone("b.test", fwd, recA("h200", "192.0.2.200")),
},
},
{
name: "/22 network gets four reverse zones",
networks: []ipam.Network{network("n", "10.1.4.0/22", "lab.test")},
addresses: []ipam.Address{entry("n", "10.1.5.7", ipam.TypeStatic, "x", "x.lab.test")},
want: []configgen.Zone{
zone("4.1.10.in-addr.arpa", rev),
zone("5.1.10.in-addr.arpa", rev, recPTR("7", "x.lab.test")),
zone("6.1.10.in-addr.arpa", rev),
zone("7.1.10.in-addr.arpa", rev),
zone("lab.test", fwd, recA("x", "10.1.5.7")),
},
},
{
name: "/16 network gets 256 /24 zones",
networks: []ipam.Network{network("n", "172.16.0.0/16", "x.test")},
addresses: []ipam.Address{
entry("n", "172.16.3.4", ipam.TypeStatic, "h", "h.x.test"),
entry("n", "172.16.255.254", ipam.TypeManualDHCP, "g", "g.x.test"),
},
want: append(
zoneSeq("%d.16.172.in-addr.arpa", 0, 255, map[string][]configgen.Record{
"3.16.172.in-addr.arpa": {recPTR("4", "h.x.test")},
"255.16.172.in-addr.arpa": {recPTR("254", "g.x.test")},
}),
zone("x.test", fwd, recA("g", "172.16.255.254"), recA("h", "172.16.3.4")),
),
},
{
name: "/12 network gets 16 /16-level zones, PTR owner has two labels",
networks: []ipam.Network{network("n", "10.16.0.0/12", "big.test")},
addresses: []ipam.Address{
entry("n", "10.17.2.10", ipam.TypeStatic, "h", "h.big.test"),
entry("n", "10.31.0.1", ipam.TypeStatic, "z", "z.big.test"),
},
want: append(
zoneSeq("%d.10.in-addr.arpa", 16, 31, map[string][]configgen.Record{
"17.10.in-addr.arpa": {recPTR("10.2", "h.big.test")},
"31.10.in-addr.arpa": {recPTR("1.0", "z.big.test")},
}),
zone("big.test", fwd, recA("h", "10.17.2.10"), recA("z", "10.31.0.1")),
),
},
{
name: "too large for reverse zones",
networks: []ipam.Network{
network("v4", "10.0.0.0/7", "huge.test"),
network("v6", "2000::/15", ""),
},
addresses: []ipam.Address{entry("v4", "10.1.1.1", ipam.TypeStatic, "h", "h.huge.test")},
want: []configgen.Zone{zone("huge.test", fwd, recA("h", "10.1.1.1"))},
warnings: []configgen.Warning{
warn(WarnDNSReverseTooLarge, "cidr", "10.0.0.0/7"),
warn(WarnDNSReverseTooLarge, "cidr", "2000::/15"),
},
},
{
name: "IPv6 /62 gets four nibble zones",
networks: []ipam.Network{network("n", "2001:db8:1:4::/62", "v6.test")},
addresses: []ipam.Address{entry("n", "2001:db8:1:6::1", ipam.TypeManualDHCP, "h", "h.v6.test")},
want: []configgen.Zone{
zone(zoneV6Net4, rev),
zone(zoneV6Net5, rev),
zone(zoneV6Net6, rev, recPTR("1"+strings.Repeat(".0", 15), "h.v6.test")),
zone(zoneV6Net7, rev),
zone("v6.test", fwd, recAAAA("h", "2001:db8:1:6::1")),
},
},
{
name: "IPv6 /128: PTR at the zone apex",
networks: []ipam.Network{network("n", "2001:db8::1/128", "v6.test")},
addresses: []ipam.Address{entry("n", "2001:db8::1", ipam.TypeStatic, "h", "h.v6.test")},
want: []configgen.Zone{
zone(fullV6, rev, recPTR("@", "h.v6.test")),
zone("v6.test", fwd, recAAAA("h", "2001:db8::1")),
},
},
{
name: "address of an unknown network",
networks: []ipam.Network{network("n", "192.0.2.0/24", "x.test")},
addresses: []ipam.Address{
entry("gone", "198.51.100.1", ipam.TypeStatic, "h", "h.x.test"),
},
want: []configgen.Zone{zone("2.0.192.in-addr.arpa", rev), zone("x.test", fwd)},
warnings: []configgen.Warning{
warn(WarnDNSUnknownNetwork, "ip", "198.51.100.1", "network_id", "gone"),
},
},
{
name: "duplicate PTR: smallest FQDN wins",
networks: []ipam.Network{network("n", "192.0.2.0/24", "x.test")},
addresses: []ipam.Address{
entry("n", "192.0.2.10", ipam.TypeStatic, "b", "b.x.test"),
entry("n", "192.0.2.10", ipam.TypeStatic, "c", "c.x.test"),
entry("n", "192.0.2.10", ipam.TypeStatic, "a", "a.x.test"),
},
want: []configgen.Zone{
zone("2.0.192.in-addr.arpa", rev, recPTR("10", "a.x.test")),
zone("x.test", fwd, recA("a", "192.0.2.10"), recA("b", "192.0.2.10"), recA("c", "192.0.2.10")),
},
warnings: []configgen.Warning{
warn(WarnDNSDuplicatePTR, "ip", "192.0.2.10", "fqdn", "b.x.test"),
warn(WarnDNSDuplicatePTR, "ip", "192.0.2.10", "fqdn", "c.x.test"),
},
},
{
name: "identical entries yield one record",
networks: []ipam.Network{network("n", "192.0.2.0/24", "x.test")},
addresses: []ipam.Address{entry("n", "192.0.2.10", ipam.TypeStatic, "a", "a.x.test"), entry("n", "192.0.2.10", ipam.TypeStatic, "a", "a.x.test")},
want: []configgen.Zone{
zone("2.0.192.in-addr.arpa", rev, recPTR("10", "a.x.test")),
zone("x.test", fwd, recA("a", "192.0.2.10")),
},
},
{
name: "reverse zone named like a forward zone is dropped",
networks: []ipam.Network{network("n", "192.0.2.0/24", "2.0.192.in-addr.arpa")},
addresses: []ipam.Address{entry("n", "192.0.2.10", ipam.TypeStatic, "h", "h.2.0.192.in-addr.arpa")},
want: []configgen.Zone{zone("2.0.192.in-addr.arpa", fwd, recA("h", "192.0.2.10"))},
warnings: []configgen.Warning{warn(WarnDNSZoneConflict, "zone", "2.0.192.in-addr.arpa")},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
src := configgen.Source{Serial: 42, Networks: tt.networks, Addresses: tt.addresses, DNS: dnsSettings()}
cfg, warnings := BuildDNS(src)
want := slices.Clone(tt.want)
slices.SortFunc(want, func(a, b configgen.Zone) int { return strings.Compare(a.Name, b.Name) })
equalZones(t, cfg.Zones, want)
equalWarnings(t, warnings, tt.warnings)
})
}
}
func TestBuildDNSSettings(t *testing.T) {
src := configgen.Source{
Serial: 2026101301,
Networks: []ipam.Network{network("n", "192.0.2.0/24", "x.test")},
DNS: dnsSettings(),
}
cfg, warnings := BuildDNS(src)
equalWarnings(t, warnings, nil)
cfg.Zones = nil
equal(t, "config", cfg, configgen.DNSConfig{
Serial: 2026101301,
ZoneDir: "/etc/bind/ddi-suite/zones",
Options: dnsSettings().Options,
})
// Empty source: no zones, no warnings.
cfg, warnings = BuildDNS(configgen.Source{DNS: dnsSettings()})
if len(cfg.Zones) != 0 || len(warnings) != 0 {
t.Errorf("empty source: %d zones, %v", len(cfg.Zones), warnings)
}
}
// ---------------------------------------------------------------------------
// BuildDHCP
// ---------------------------------------------------------------------------
func officeNetwork() ipam.Network {
n := network("n1", "192.0.2.0/24", "office.example.test")
n.Gateway = ptr(ip("192.0.2.1"))
n.SearchDomains = []string{"office.example.test", "example.test"}
n.DHCPEnabled = true
n.LeaseTime = ptr(7200)
n.DNSServers = []netip.Addr{ip("192.0.2.53"), ip("192.0.2.54")}
n.NTPServers = []netip.Addr{ip("192.0.2.123")}
n.Pools = []ipam.Pool{{Start: ip("192.0.2.100"), End: ip("192.0.2.199")}}
return n
}
func withMAC(a ipam.Address, mac string) ipam.Address {
a.MAC = mac
return a
}
func officeAddresses() []ipam.Address {
return []ipam.Address{
entry("n1", "192.0.2.120", ipam.TypeStatic, "srv120", "srv120.office.example.test"),
withMAC(entry("n1", "192.0.2.150", ipam.TypeManualDHCP, "pc150", "pc150.office.example.test"), "00:11:22:33:44:55"),
withMAC(entry("n1", "192.0.2.160", ipam.TypeDynamic, "dyn160", ""), "00:11:22:33:44:77"),
withMAC(entry("n1", "192.0.2.50", ipam.TypeManualDHCP, "", ""), "00:11:22:33:44:66"),
entry("n1", "192.0.2.170", ipam.TypeManualDHCP, "nomac", "nomac.office.example.test"),
entry("n1", "192.0.2.180", ipam.TypeReserved, "", ""),
}
}
func TestBuildDHCP(t *testing.T) {
disabled := network("off", "10.0.1.0/24", "")
disabled.Pools = []ipam.Pool{{Start: ip("10.0.1.10"), End: ip("10.0.1.20")}}
v6 := network("v6", "2001:db8::/64", "")
v6.DHCPEnabled = true
noID := network("noid", "10.0.0.0/24", "")
noID.DHCPEnabled = true
zeroID := network("zero", "10.0.3.0/24", "")
zeroID.DHCPEnabled = true
plain := network("plain", "10.0.2.0/24", "")
plain.DHCPEnabled = true
plain.LeaseTime = ptr(0)
tests := []struct {
name string
networks []ipam.Network
addresses []ipam.Address
ids map[string]uint32
want []configgen.Subnet
warnings []configgen.Warning
}{
{
name: "subnet with options, split pools and reservations",
networks: []ipam.Network{officeNetwork()},
addresses: officeAddresses(),
ids: map[string]uint32{"n1": 7},
want: []configgen.Subnet{{
ID: 7,
Prefix: pfx("192.0.2.0/24"),
// Cut out: .120 static, .150 and .170 manual_dhcp, .180
// reserved; .160 (dynamic) stays in the pool.
Pools: []ipam.Pool{
{Start: ip("192.0.2.100"), End: ip("192.0.2.119")},
{Start: ip("192.0.2.121"), End: ip("192.0.2.149")},
{Start: ip("192.0.2.151"), End: ip("192.0.2.169")},
{Start: ip("192.0.2.171"), End: ip("192.0.2.179")},
{Start: ip("192.0.2.181"), End: ip("192.0.2.199")},
},
Routers: []netip.Addr{ip("192.0.2.1")},
DNSServers: []netip.Addr{ip("192.0.2.53"), ip("192.0.2.54")},
NTPServers: []netip.Addr{ip("192.0.2.123")},
DomainName: "office.example.test",
SearchDomains: []string{"office.example.test", "example.test"},
ValidLifetime: 7200,
Reservations: []configgen.Reservation{
{MAC: "00:11:22:33:44:66", IP: ip("192.0.2.50")},
{MAC: "00:11:22:33:44:55", IP: ip("192.0.2.150"), Hostname: "pc150"},
},
}},
warnings: []configgen.Warning{warn(WarnDHCPReservationWithoutMAC, "ip", "192.0.2.170")},
},
{
name: "disabled, IPv6 and networks without subnet ID are skipped",
networks: []ipam.Network{officeNetwork(), disabled, v6, noID, zeroID, plain},
ids: map[string]uint32{"n1": 7, "off": 1, "v6": 2, "zero": 0, "plain": 3},
want: []configgen.Subnet{
{ID: 3, Prefix: pfx("10.0.2.0/24")},
{
ID: 7,
Prefix: pfx("192.0.2.0/24"),
Pools: []ipam.Pool{{Start: ip("192.0.2.100"), End: ip("192.0.2.199")}},
Routers: []netip.Addr{ip("192.0.2.1")},
DNSServers: []netip.Addr{ip("192.0.2.53"), ip("192.0.2.54")},
NTPServers: []netip.Addr{ip("192.0.2.123")},
DomainName: "office.example.test",
SearchDomains: []string{"office.example.test", "example.test"},
ValidLifetime: 7200,
},
},
warnings: []configgen.Warning{
warn(WarnDHCPNoSubnetID, "network_id", "noid", "cidr", "10.0.0.0/24"),
warn(WarnDHCPNoSubnetID, "network_id", "zero", "cidr", "10.0.3.0/24"),
},
},
{
name: "addresses of other networks do not affect a subnet",
networks: []ipam.Network{plain},
addresses: officeAddresses(),
ids: map[string]uint32{"plain": 3},
want: []configgen.Subnet{{ID: 3, Prefix: pfx("10.0.2.0/24")}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
src := configgen.Source{Networks: tt.networks, Addresses: tt.addresses, SubnetIDs: tt.ids, DHCP: dhcpSettings()}
cfg, warnings := BuildDHCP(src)
equal(t, "subnets", cfg.Subnets, tt.want)
equalWarnings(t, warnings, tt.warnings)
cfg.Subnets = nil
equal(t, "config", cfg, configgen.DHCPConfig{
Interfaces: []string{"eth0", "eth1"},
DirectClients: true,
ControlSocket: "/run/kea/kea4-ctrl-socket",
LeaseFile: "/var/lib/kea/kea-leases4.csv",
ValidLifetime: 3600,
})
})
}
}
// ---------------------------------------------------------------------------
// Determinism and purity
// ---------------------------------------------------------------------------
// richSource combines most cases above, including warnings.
func richSource() configgen.Source {
nets := []ipam.Network{
officeNetwork(),
network("n6", "2001:db8:1:4::/62", "office.example.test"),
network("lo", "198.51.100.0/25", ""),
network("hi", "198.51.100.128/25", "b.test"),
network("big", "10.16.0.0/12", "big.test"),
network("huge", "64.0.0.0/7", ""),
}
nets[2].DHCPEnabled = true
nets[3].DHCPEnabled = true
nets[3].Gateway = ptr(ip("198.51.100.129"))
addrs := append(officeAddresses(),
entry("n6", "2001:db8:1:6::1", ipam.TypeStatic, "srv6", "srv6.office.example.test"),
entry("lo", "198.51.100.5", ipam.TypeStatic, "printer", ""),
withMAC(entry("lo", "198.51.100.6", ipam.TypeManualDHCP, "scanner", ""), "00:11:22:33:44:88"),
entry("hi", "198.51.100.200", ipam.TypeStatic, "h200", "h200.b.test"),
entry("big", "10.17.2.10", ipam.TypeStatic, "h", "h.big.test"),
entry("gone", "203.0.113.1", ipam.TypeStatic, "x", "x.test"),
)
return configgen.Source{
Serial: 7,
Networks: nets,
Addresses: addrs,
SubnetIDs: map[string]uint32{"n1": 7, "hi": 2},
DNS: dnsSettings(),
DHCP: dhcpSettings(),
}
}
func TestDeterminism(t *testing.T) {
base := richSource()
dns, dnsWarn := BuildDNS(base)
dhcp, dhcpWarn := BuildDHCP(base)
if len(dnsWarn) == 0 || len(dhcpWarn) == 0 || len(dhcp.Subnets) != 2 {
t.Fatalf("fixture too small: %d DNS warnings, %d DHCP warnings, %d subnets", len(dnsWarn), len(dhcpWarn), len(dhcp.Subnets))
}
shuffles := map[string]func(configgen.Source) configgen.Source{
"reversed": func(s configgen.Source) configgen.Source {
slices.Reverse(s.Networks)
slices.Reverse(s.Addresses)
return s
},
"rotated": func(s configgen.Source) configgen.Source {
s.Networks = append(s.Networks[2:], s.Networks[:2]...)
s.Addresses = append(s.Addresses[5:], s.Addresses[:5]...)
return s
},
"interleaved": func(s configgen.Source) configgen.Source {
var even, odd []ipam.Address
for i, a := range s.Addresses {
if i%2 == 0 {
even = append(even, a)
} else {
odd = append(odd, a)
}
}
s.Addresses = append(odd, even...)
s.Networks[0], s.Networks[len(s.Networks)-1] = s.Networks[len(s.Networks)-1], s.Networks[0]
return s
},
}
for name, shuffle := range shuffles {
t.Run(name, func(t *testing.T) {
src := shuffle(richSource())
for range 3 { // map iteration order varies between runs
gotDNS, gotDNSWarn := BuildDNS(src)
gotDHCP, gotDHCPWarn := BuildDHCP(src)
equal(t, "DNS config", gotDNS, dns)
equal(t, "DNS warnings", gotDNSWarn, dnsWarn)
equal(t, "DHCP config", gotDHCP, dhcp)
equal(t, "DHCP warnings", gotDHCPWarn, dhcpWarn)
}
})
}
}
// cloneSource deep-copies a source.
func cloneSource(s configgen.Source) configgen.Source {
c := s
c.Networks = make([]ipam.Network, len(s.Networks))
for i, n := range s.Networks {
n.VLAN = clonePtr(n.VLAN)
n.Gateway = clonePtr(n.Gateway)
n.LeaseTime = clonePtr(n.LeaseTime)
n.SearchDomains = slices.Clone(n.SearchDomains)
n.Tags = slices.Clone(n.Tags)
n.DNSServers = slices.Clone(n.DNSServers)
n.NTPServers = slices.Clone(n.NTPServers)
n.Pools = slices.Clone(n.Pools)
c.Networks[i] = n
}
c.Addresses = make([]ipam.Address, len(s.Addresses))
for i, a := range s.Addresses {
a.Tags = slices.Clone(a.Tags)
a.FirstSeen = clonePtr(a.FirstSeen)
a.LastSeen = clonePtr(a.LastSeen)
a.LeaseExpiresAt = clonePtr(a.LeaseExpiresAt)
c.Addresses[i] = a
}
c.SubnetIDs = maps.Clone(s.SubnetIDs)
c.DNS.Options.AllowRecursion = slices.Clone(s.DNS.Options.AllowRecursion)
c.DNS.Options.Forwarders = slices.Clone(s.DNS.Options.Forwarders)
c.DNS.NameServers = cloneNameServers(s.DNS.NameServers)
c.DHCP.Interfaces = slices.Clone(s.DHCP.Interfaces)
return c
}
func clonePtr[T any](p *T) *T {
if p == nil {
return nil
}
v := *p
return &v
}
func TestInputNotModified(t *testing.T) {
src := richSource()
seen := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
src.Networks[0].VLAN = ptr(10)
src.Networks[0].Tags = []string{"office"}
src.Addresses[0].Tags = []string{"server"}
src.Addresses[0].FirstSeen = &seen
before := cloneSource(src)
dns, _ := BuildDNS(src)
dhcp, _ := BuildDHCP(src)
equal(t, "source after build", src, before)
// Overwrite everything reachable from the output: the input must not
// share memory with it.
for i := range dns.Options.AllowRecursion {
dns.Options.AllowRecursion[i] = netip.Prefix{}
}
for i := range dns.Options.Forwarders {
dns.Options.Forwarders[i] = netip.Addr{}
}
for _, z := range dns.Zones {
for i := range z.NameServers {
z.NameServers[i].Name = "overwritten"
for j := range z.NameServers[i].Addrs {
z.NameServers[i].Addrs[j] = netip.Addr{}
}
}
}
for i := range dhcp.Interfaces {
dhcp.Interfaces[i] = "overwritten"
}
for _, s := range dhcp.Subnets {
for i := range s.Pools {
s.Pools[i] = ipam.Pool{}
}
for _, l := range [][]netip.Addr{s.Routers, s.DNSServers, s.NTPServers} {
for i := range l {
l[i] = netip.Addr{}
}
}
for i := range s.SearchDomains {
s.SearchDomains[i] = "overwritten"
}
}
equal(t, "source after overwriting the output", src, before)
// Zones must not share name servers with each other either.
dns, _ = BuildDNS(src)
dns.Zones[0].NameServers[0].Addrs[0] = netip.Addr{}
if dns.Zones[1].NameServers[0].Addrs[0] != ip("192.0.2.53") {
t.Error("zones share name server slices")
}
}
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package derive
import (
"cmp"
"math"
"net/netip"
"slices"
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
)
// BuildDHCP derives the Kea DHCPv4 model from the IPAM data: one subnet per
// DHCP-enabled IPv4 network with its effective pools (ipam.EffectivePools),
// options and a host reservation per manual_dhcp entry. IPv6 networks are
// skipped (DHCPv6 is not supported yet). Subnets are sorted by ID,
// reservations by IP.
func BuildDHCP(src configgen.Source) (configgen.DHCPConfig, []configgen.Warning) {
var w warnings
cfg := configgen.DHCPConfig{
Interfaces: slices.Clone(src.DHCP.Interfaces),
DirectClients: src.DHCP.DirectClients,
ControlSocket: src.DHCP.ControlSocket,
LeaseFile: src.DHCP.LeaseFile,
HooksDir: src.DHCP.HooksDir,
ValidLifetime: src.DHCP.ValidLifetime,
}
byNetwork := map[string][]ipam.Address{}
for _, a := range src.Addresses {
byNetwork[a.NetworkID] = append(byNetwork[a.NetworkID], a)
}
for i := range src.Networks {
n := src.Networks[i]
if !n.DHCPEnabled || !n.CIDR.Addr().Is4() {
continue
}
id, ok := src.SubnetIDs[n.ID]
if !ok || id == 0 {
w.add(WarnDHCPNoSubnetID, "network_id", n.ID, "cidr", n.CIDR.String())
continue
}
addrs := byNetwork[n.ID]
s := configgen.Subnet{
ID: id,
Prefix: n.CIDR,
Pools: ipam.EffectivePools(&n, addrs),
DNSServers: slices.Clone(n.DNSServers),
NTPServers: slices.Clone(n.NTPServers),
DomainName: n.Domain,
SearchDomains: slices.Clone(n.SearchDomains),
}
if n.Gateway != nil {
s.Routers = []netip.Addr{*n.Gateway}
}
if n.LeaseTime != nil && *n.LeaseTime > 0 && int64(*n.LeaseTime) <= math.MaxUint32 {
s.ValidLifetime = uint32(*n.LeaseTime)
}
for _, a := range addrs {
if a.Type != ipam.TypeManualDHCP {
continue
}
if a.MAC == "" {
w.add(WarnDHCPReservationWithoutMAC, "ip", a.IP.String())
continue
}
s.Reservations = append(s.Reservations, configgen.Reservation{MAC: a.MAC, IP: a.IP, Hostname: a.Hostname})
}
slices.SortFunc(s.Reservations, func(a, b configgen.Reservation) int {
return cmp.Or(a.IP.Compare(b.IP), strings.Compare(a.MAC, b.MAC), strings.Compare(a.Hostname, b.Hostname))
})
cfg.Subnets = append(cfg.Subnets, s)
}
slices.SortFunc(cfg.Subnets, func(a, b configgen.Subnet) int {
return cmp.Or(cmp.Compare(a.ID, b.ID), strings.Compare(a.Prefix.String(), b.Prefix.String()))
})
return cfg, w.sorted()
}
+155
View File
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package derive
import (
"cmp"
"net/netip"
"slices"
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
)
// BuildDNS derives the BIND model from the IPAM data:
//
// - one managed forward zone per distinct network domain, with an A or
// AAAA record per static and manual_dhcp entry that has a hostname;
// - managed reverse zones for every network (see reverseZones), shared by
// networks that fall into the same zone, with a PTR record per static
// and manual_dhcp entry that has an FQDN.
//
// Every zone uses the SOA, default TTL and name servers of src.DNS. Zones are
// sorted by name, records by owner, type and data.
func BuildDNS(src configgen.Source) (configgen.DNSConfig, []configgen.Warning) {
var w warnings
cfg := configgen.DNSConfig{
Serial: src.Serial,
ZoneDir: src.DNS.ZoneDir,
Options: configgen.DNSOptions{
Recursion: src.DNS.Options.Recursion,
AllowRecursion: slices.Clone(src.DNS.Options.AllowRecursion),
Forwarders: slices.Clone(src.DNS.Options.Forwarders),
},
}
newZone := func(name string, kind configgen.ZoneKind) *configgen.Zone {
return &configgen.Zone{
Name: name,
Kind: kind,
Mode: configgen.ZoneManaged,
DefaultTTL: src.DNS.DefaultTTL,
SOA: src.DNS.SOA,
NameServers: cloneNameServers(src.DNS.NameServers),
}
}
// Forward zones first, so that reverse zones can be checked against them
// independently of the network order.
networks := make(map[string]*ipam.Network, len(src.Networks))
forward := map[string]*configgen.Zone{}
for i := range src.Networks {
n := &src.Networks[i]
networks[n.ID] = n
if n.Domain != "" && forward[n.Domain] == nil {
forward[n.Domain] = newZone(n.Domain, configgen.ZoneForward)
}
}
reverse := map[string]*configgen.Zone{}
conflicts := map[string]bool{}
for i := range src.Networks {
n := &src.Networks[i]
names, ok := reverseZones(n.CIDR)
if !ok {
w.add(WarnDNSReverseTooLarge, "cidr", n.CIDR.String())
continue
}
for _, name := range names {
switch {
case reverse[name] != nil:
case forward[name] != nil:
if !conflicts[name] {
conflicts[name] = true
w.add(WarnDNSZoneConflict, "zone", name)
}
default:
reverse[name] = newZone(name, configgen.ZoneReverse)
}
}
}
ptrs := map[netip.Addr][]string{}
for i := range src.Addresses {
a := &src.Addresses[i]
n := networks[a.NetworkID]
if n == nil {
w.add(WarnDNSUnknownNetwork, "ip", a.IP.String(), "network_id", a.NetworkID)
continue
}
if !a.Type.PublishesDNS() || !a.IP.IsValid() {
continue
}
if a.Hostname != "" {
if z := forward[n.Domain]; z != nil {
t := configgen.TypeAAAA
if a.IP.Is4() {
t = configgen.TypeA
}
z.Records = append(z.Records, configgen.Record{Name: a.Hostname, Type: t, Addr: a.IP})
} else {
w.add(WarnDNSNoDomain, "ip", a.IP.String(), "hostname", a.Hostname)
}
}
if a.FQDN != "" {
ptrs[a.IP] = append(ptrs[a.IP], a.FQDN)
}
}
// One PTR per address; with conflicting entries the smallest FQDN wins.
for ip, fqdns := range ptrs {
slices.Sort(fqdns)
fqdns = slices.Compact(fqdns)
for _, dropped := range fqdns[1:] {
w.add(WarnDNSDuplicatePTR, "ip", ip.String(), "fqdn", dropped)
}
// Addresses of networks without reverse zone get no PTR; the network
// is already reported.
if zone, owner, ok := splitReverse(reverseName(ip), reverse); ok {
z := reverse[zone]
z.Records = append(z.Records, configgen.Record{Name: owner, Type: configgen.TypePTR, Target: fqdns[0]})
}
}
cfg.Zones = make([]configgen.Zone, 0, len(forward)+len(reverse))
for _, zones := range []map[string]*configgen.Zone{forward, reverse} {
for _, z := range zones {
slices.SortFunc(z.Records, compareRecords)
z.Records = slices.CompactFunc(z.Records, func(a, b configgen.Record) bool { return compareRecords(a, b) == 0 })
cfg.Zones = append(cfg.Zones, *z)
}
}
slices.SortFunc(cfg.Zones, func(a, b configgen.Zone) int { return strings.Compare(a.Name, b.Name) })
return cfg, w.sorted()
}
// compareRecords orders address records by owner, type and data.
func compareRecords(a, b configgen.Record) int {
return cmp.Or(
strings.Compare(a.Name, b.Name),
strings.Compare(string(a.Type), string(b.Type)),
a.Addr.Compare(b.Addr),
strings.Compare(a.Target, b.Target),
)
}
// cloneNameServers deep-copies name servers so zones never alias the input.
func cloneNameServers(in []configgen.NameServer) []configgen.NameServer {
if in == nil {
return nil
}
out := make([]configgen.NameServer, len(in))
for i, ns := range in {
out[i] = configgen.NameServer{Name: ns.Name, Addrs: slices.Clone(ns.Addrs)}
}
return out
}
+138
View File
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package derive
import (
"encoding/binary"
"net/netip"
"strconv"
"strings"
)
// Reverse zone suffixes (RFC 1035 §3.5, RFC 3596 §2.5).
const (
suffixV4 = "in-addr.arpa"
suffixV6 = "ip6.arpa"
)
// reverseZones returns the names of the reverse zones that hold the PTR
// records of network p. ok is false if p is too large (or invalid).
//
// IPv4 zones sit on octet boundaries; classless delegation (RFC 2317) is not
// used:
// - /24 … /32: the containing /24 zone ("2.0.192.in-addr.arpa");
// - /16 … /23: every /24 zone inside the network (at most 256);
// - /8 … /15: every /16 zone inside the network (at most 256);
// - shorter: none.
//
// IPv6 zones sit on nibble boundaries: a prefix length divisible by 4 gives
// one zone, any other length is rounded up to the next multiple of 4 and the
// 2, 4 or 8 zones covering exactly the prefix are returned. Prefixes shorter
// than /16 give none.
func reverseZones(p netip.Prefix) (names []string, ok bool) {
if !p.IsValid() {
return nil, false
}
p = p.Masked()
bits := p.Bits()
addr := p.Addr()
if addr.Is4() {
b := addr.As4()
base := binary.BigEndian.Uint32(b[:])
var octets int
var count, step uint32
switch {
case bits >= 24:
octets, count, step = 3, 1, 0
case bits >= 16:
octets, count, step = 3, 1<<(24-bits), 1<<8
case bits >= 8:
octets, count, step = 2, 1<<(16-bits), 1<<16
default:
return nil, false
}
names = make([]string, 0, count)
for i := range count {
names = append(names, reverseV4(base+i*step, octets))
}
return names, true
}
if bits < 16 {
return nil, false
}
zoneBits := (bits + 3) / 4 * 4
nibbles := nibblesOf(addr)
last := zoneBits/4 - 1
first := nibbles[last]
count := byte(1) << (zoneBits - bits)
names = make([]string, 0, count)
for i := range count {
nibbles[last] = first + i
names = append(names, reverseV6(nibbles, zoneBits/4))
}
return names, true
}
// reverseName returns the full reverse name of an address, e.g.
// "10.2.0.192.in-addr.arpa".
func reverseName(a netip.Addr) string {
if a.Is4() {
b := a.As4()
return reverseV4(binary.BigEndian.Uint32(b[:]), 4)
}
n := nibblesOf(a)
return reverseV6(n, len(n))
}
// reverseV4 returns the in-addr.arpa name of the first octets (1–4) of a.
func reverseV4(a uint32, octets int) string {
labels := make([]string, 0, octets+1)
for i := octets - 1; i >= 0; i-- {
labels = append(labels, strconv.FormatUint(uint64(a>>(24-8*i)&0xff), 10))
}
labels = append(labels, suffixV4)
return strings.Join(labels, ".")
}
// nibblesOf returns the 32 nibbles of an IPv6 address (IPv4-mapped
// addresses in their IPv6 form), most significant first.
func nibblesOf(a netip.Addr) []byte {
b := a.As16()
n := make([]byte, 0, 32)
for _, x := range b {
n = append(n, x>>4, x&0x0f)
}
return n
}
// reverseV6 returns the ip6.arpa name of the first count nibbles.
func reverseV6(nibbles []byte, count int) string {
const hex = "0123456789abcdef"
var sb strings.Builder
for i := count - 1; i >= 0; i-- {
sb.WriteByte(hex[nibbles[i]])
sb.WriteByte('.')
}
sb.WriteString(suffixV6)
return sb.String()
}
// splitReverse finds the most specific zone in zones that contains name and
// returns it with the owner name relative to it ("@" for the apex).
func splitReverse[Z any](name string, zones map[string]Z) (zone, owner string, ok bool) {
labels := strings.Split(name, ".")
// The last two labels are the suffix (in-addr.arpa, ip6.arpa).
for i := 0; i < len(labels)-2; i++ {
zone = strings.Join(labels[i:], ".")
if _, found := zones[zone]; !found {
continue
}
if i == 0 {
return zone, "@", true
}
return zone, strings.Join(labels[:i], "."), true
}
return "", "", false
}
@@ -0,0 +1,291 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
//go:build integration
// Package integration checks generated configuration with the real BIND and
// Kea programs in containers (PROJECT_BRIEF §7). Run with
//
// go test -tags integration ./server/internal/configgen/integration/
//
// Requires the docker CLI. Files are copied into the containers (docker cp),
// never bind-mounted, so the tests also work against a remote or shared
// Docker daemon. Image lists can be overridden with DDI_BIND_IMAGES and
// DDI_KEA_IMAGES (space separated).
package integration
import (
"bytes"
"context"
"net/netip"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"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/configgen/keagen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
)
const (
bindConfDir = "/etc/bind/ddi-suite"
zoneDir = "/etc/bind/ddi-suite/zones"
)
func images(env, def string) []string {
if v := strings.TrimSpace(os.Getenv(env)); v != "" {
return strings.Fields(v)
}
return strings.Fields(def)
}
func docker(t *testing.T, args ...string) (string, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
var out bytes.Buffer
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdout, cmd.Stderr = &out, &out
err := cmd.Run()
return out.String(), err
}
// runInContainer copies root/. into a fresh container and runs script with
// sh. It returns the combined output and whether the script succeeded.
func runInContainer(t *testing.T, image, root, script string, env ...string) (string, bool) {
t.Helper()
args := []string{"create", "--entrypoint", "sh"}
for _, e := range env {
args = append(args, "-e", e)
}
args = append(args, image, "-c", script)
out, err := docker(t, args...)
if err != nil {
t.Fatalf("docker create %s: %v\n%s", image, err, out)
}
id := strings.TrimSpace(out)
defer func() { _, _ = docker(t, "rm", "-f", id) }()
if out, err := docker(t, "cp", root+string(filepath.Separator)+".", id+":/"); err != nil {
t.Fatalf("docker cp: %v\n%s", err, out)
}
out, err = docker(t, "start", "-a", id)
return out, err == nil
}
func writeFile(t *testing.T, path string, content []byte) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, content, 0o644); err != nil { //nolint:gosec // test files in a temp dir
t.Fatal(err)
}
}
func ptr[T any](v T) *T { return &v }
// sampleSource is a realistic data set covering all address types, IPv4
// and IPv6, several reverse-zone sizes and DHCP options.
func sampleSource() configgen.Source {
office := ipam.Network{
ID: "n-office", CIDR: netip.MustParsePrefix("192.0.2.0/24"), Name: "Office",
Gateway: ptr(netip.MustParseAddr("192.0.2.1")), Domain: "office.test", SearchDomains: []string{"office.test", "test"},
DHCPEnabled: true, LeaseTime: ptr(7200),
DNSServers: []netip.Addr{netip.MustParseAddr("192.0.2.53")},
NTPServers: []netip.Addr{netip.MustParseAddr("192.0.2.123")},
Pools: []ipam.Pool{{Start: netip.MustParseAddr("192.0.2.100"), End: netip.MustParseAddr("192.0.2.199")}},
}
lab := ipam.Network{
ID: "n-lab", CIDR: netip.MustParsePrefix("198.51.100.0/22"), Name: "Lab", Domain: "lab.test",
Gateway: ptr(netip.MustParseAddr("198.51.100.1")), DHCPEnabled: true,
Pools: []ipam.Pool{{Start: netip.MustParseAddr("198.51.101.0"), End: netip.MustParseAddr("198.51.101.255")}},
}
v6 := ipam.Network{ID: "n-v6", CIDR: netip.MustParsePrefix("2001:db8:1::/64"), Name: "Office v6", Domain: "office.test"}
bare := ipam.Network{ID: "n-bare", CIDR: netip.MustParsePrefix("203.0.113.0/25"), Name: "No domain"}
addr := func(net ipam.Network, ip string, typ ipam.AddressType, mac, host string) ipam.Address {
return ipam.Address{NetworkID: net.ID, IP: netip.MustParseAddr(ip), Type: typ, MAC: mac, Hostname: host,
FQDN: ipam.FQDN(host, net.Domain)}
}
return configgen.Source{
Serial: 2026101301,
Networks: []ipam.Network{office, lab, v6, bare},
Addresses: []ipam.Address{
addr(office, "192.0.2.53", ipam.TypeStatic, "", "ns1"),
addr(office, "192.0.2.10", ipam.TypeStatic, "", "www"),
addr(office, "192.0.2.150", ipam.TypeManualDHCP, "00:11:22:33:44:55", "printer"),
addr(office, "192.0.2.120", ipam.TypeReserved, "", ""),
addr(office, "192.0.2.160", ipam.TypeDynamic, "00:11:22:33:44:66", "laptop"),
addr(lab, "198.51.100.20", ipam.TypeStatic, "", "build"),
addr(lab, "198.51.102.7", ipam.TypeManualDHCP, "00:aa:bb:cc:dd:ee", "scope"),
addr(v6, "2001:db8:1::10", ipam.TypeStatic, "", "www6"),
addr(bare, "203.0.113.5", ipam.TypeStatic, "", "orphan"),
},
SubnetIDs: map[string]uint32{"n-office": 1, "n-lab": 2},
DNS: configgen.DNSSettings{
ZoneDir: zoneDir, DefaultTTL: 3600,
SOA: configgen.SOA{PrimaryNS: "ns1.office.test", Hostmaster: "hostmaster.office.test",
Refresh: 3600, Retry: 600, Expire: 1209600, Minimum: 300},
NameServers: []configgen.NameServer{{Name: "ns1.office.test", Addrs: []netip.Addr{netip.MustParseAddr("192.0.2.53")}}},
},
DHCP: configgen.DHCPSettings{Interfaces: []string{"*"}, DirectClients: false,
ControlSocket: "/var/run/kea/kea4-ctrl-socket", LeaseFile: "/var/lib/kea/kea-leases4.csv", ValidLifetime: 3600},
}
}
// dnsConfig derives the sample DNS config and adds what derivation does not
// produce yet: extra record types and a dynamic zone.
func dnsConfig(t *testing.T) configgen.DNSConfig {
t.Helper()
cfg, warnings := derive.BuildDNS(sampleSource())
t.Logf("derive warnings: %+v", warnings)
for i := range cfg.Zones {
if cfg.Zones[i].Name != "office.test" {
continue
}
cfg.Zones[i].Records = append(cfg.Zones[i].Records,
configgen.Record{Name: "@", Type: configgen.TypeMX, Preference: 10, Target: "mail.office.test"},
configgen.Record{Name: "mail", Type: configgen.TypeA, Addr: netip.MustParseAddr("192.0.2.25")},
configgen.Record{Name: "intranet", Type: configgen.TypeCNAME, Target: "www.office.test"},
configgen.Record{Name: "@", Type: configgen.TypeTXT, Text: []string{
"v=spf1 mx -all", `quote " and backslash \ and ` + strings.Repeat("x", 300),
}},
configgen.Record{Name: "_ldap._tcp", Type: configgen.TypeSRV, Priority: 0, Weight: 100, Port: 389, Target: "www.office.test"},
)
}
cfg.Zones = append(cfg.Zones, configgen.Zone{
Name: "ad.office.test", Kind: configgen.ZoneForward, Mode: configgen.ZoneDynamic, DefaultTTL: 3600,
SOA: cfg.Zones[0].SOA, NameServers: []configgen.NameServer{{Name: "ns1.office.test"}},
AllowUpdate: []netip.Prefix{netip.MustParsePrefix("192.0.2.10/32")},
})
return cfg
}
// bindTree writes the generated files plus a distribution-like named.conf.
func bindTree(t *testing.T, arts []configgen.Artifact) (root string, zones map[string]string) {
t.Helper()
root = t.TempDir()
zones = map[string]string{}
for _, a := range arts {
writeFile(t, filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(bindConfDir, "/")), filepath.FromSlash(a.Path)), a.Content)
if name, ok := strings.CutPrefix(a.Path, "zones/db."); ok {
zones[name] = bindConfDir + "/" + a.Path
}
}
named := `options {
directory "/var/cache/bind";
listen-on { 127.0.0.1; };
listen-on-v6 { none; };
include "` + bindConfDir + `/ddi-suite-options.conf";
};
include "` + bindConfDir + `/ddi-suite.conf";
`
writeFile(t, filepath.Join(root, "etc", "bind", "named.conf"), []byte(named))
return root, zones
}
func checkScript(zones map[string]string) string {
var b strings.Builder
b.WriteString("set -e\nnamed-checkconf -z /etc/bind/named.conf\n")
for name, file := range zones {
b.WriteString("named-checkzone -k fail -m fail -M fail -S fail -i local " + name + " " + file + "\n")
}
return b.String()
}
func TestBINDAcceptsGeneratedConfig(t *testing.T) {
cfg := dnsConfig(t)
arts, err := bindgen.Render(cfg)
if err != nil {
t.Fatalf("render: %v", err)
}
root, zones := bindTree(t, arts)
if len(zones) < 4 {
t.Fatalf("expected forward, reverse and dynamic zones, got %v", zones)
}
for _, image := range images("DDI_BIND_IMAGES", "internetsystemsconsortium/bind9:9.18 internetsystemsconsortium/bind9:9.20") {
t.Run(image, func(t *testing.T) {
if out, ok := runInContainer(t, image, root, checkScript(zones)); !ok {
t.Fatalf("BIND rejected the generated configuration:\n%s", out)
}
})
}
}
// TestBINDHarnessDetectsErrors proves that the check above can fail.
func TestBINDHarnessDetectsErrors(t *testing.T) {
arts, err := bindgen.Render(dnsConfig(t))
if err != nil {
t.Fatalf("render: %v", err)
}
for i := range arts {
if arts[i].Path == "zones/db.office.test" {
arts[i].Content = append(arts[i].Content, []byte("broken IN A not-an-address\n")...)
}
}
root, zones := bindTree(t, arts)
image := images("DDI_BIND_IMAGES", "internetsystemsconsortium/bind9:9.20")[0]
if out, ok := runInContainer(t, image, root, checkScript(zones)); ok {
t.Fatalf("a broken zone file was accepted:\n%s", out)
}
}
func keaHooksDir(t *testing.T, image string) string {
t.Helper()
out, err := docker(t, "run", "--rm", "--entrypoint", "sh", image, "-c",
"dirname \"$(find / -name libdhcp_lease_cmds.so 2>/dev/null | head -n1)\"")
if err != nil {
t.Fatalf("find hooks dir in %s: %v\n%s", image, err, out)
}
dir := strings.TrimSpace(out)
if dir == "" || dir == "." {
t.Fatalf("libdhcp_lease_cmds.so not found in %s", image)
}
return dir
}
func keaTree(t *testing.T, content []byte) string {
t.Helper()
root := t.TempDir()
writeFile(t, filepath.Join(root, "etc", "kea", "kea-dhcp4.conf"), content)
return root
}
const keaCheck = "set -e\nmkdir -p /var/run/kea /var/lib/kea\nkea-dhcp4 -t /etc/kea/kea-dhcp4.conf"
func TestKeaAcceptsGeneratedConfig(t *testing.T) {
for _, image := range images("DDI_KEA_IMAGES",
"docker.cloudsmith.io/isc/docker/kea-dhcp4:2.6.5 docker.cloudsmith.io/isc/docker/kea-dhcp4:3.2.1") {
t.Run(image, func(t *testing.T) {
src := sampleSource()
src.DHCP.HooksDir = keaHooksDir(t, image)
cfg, warnings := derive.BuildDHCP(src)
t.Logf("derive warnings: %+v", warnings)
if len(cfg.Subnets) != 2 {
t.Fatalf("expected two DHCP subnets, got %d", len(cfg.Subnets))
}
arts, err := keagen.Render(cfg)
if err != nil {
t.Fatalf("render: %v", err)
}
root := keaTree(t, arts[0].Content)
out, ok := runInContainer(t, image, root, keaCheck, "KEA_CONTROL_SOCKET_DIR=/var/run/kea")
if !ok {
t.Fatalf("Kea rejected the generated configuration:\n%s\n--- config ---\n%s", out, arts[0].Content)
}
})
}
}
// TestKeaHarnessDetectsErrors proves that the check above can fail.
func TestKeaHarnessDetectsErrors(t *testing.T) {
image := images("DDI_KEA_IMAGES", "docker.cloudsmith.io/isc/docker/kea-dhcp4:3.2.1")[0]
root := keaTree(t, []byte(`{"Dhcp4": {"subnet4": [{"id": 1, "subnet": "192.0.2.0/24", "pools": [{"pool": "10.0.0.1 - 10.0.0.9"}]}]}}`))
if out, ok := runInContainer(t, image, root, keaCheck, "KEA_CONTROL_SOCKET_DIR=/var/run/kea"); ok {
t.Fatalf("an invalid Kea configuration was accepted:\n%s", out)
}
}
+259
View File
@@ -0,0 +1,259 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package keagen renders the Kea DHCPv4 configuration (kea-dhcp4.conf) from a
// configgen.DHCPConfig (ADR 0005, ADR 0014).
//
// The configuration is modeled with typed structs and serialized with
// encoding/json, so no input value can leave its JSON string. Every value is
// validated before rendering; invalid input yields a
// *configgen.ValidationError and no output.
package keagen
import (
"bytes"
"cmp"
"encoding/json"
"fmt"
"net/netip"
"path"
"slices"
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
)
// FileName is the path of the generated artifact relative to the agent's
// configuration root.
const FileName = "kea-dhcp4.conf"
// FileMode is the Unix file mode of the generated artifact.
const FileMode = 0o640
const (
// leaseCmdsLibrary is the free hook used for lease polling (ADR 0005).
leaseCmdsLibrary = "libdhcp_lease_cmds.so"
// lfcInterval is the lease file cleanup interval in seconds.
lfcInterval = 3600
// listSep separates the values of list options ("data" in csv format).
listSep = ", "
)
// Render validates cfg and returns exactly one artifact: the complete Kea
// DHCPv4 configuration as JSON. The output is a pure function of cfg; subnets
// are sorted by ID, pools by start, reservations by IP. cfg is not modified.
func Render(cfg configgen.DHCPConfig) ([]configgen.Artifact, error) {
if verr := validate(cfg); verr != nil {
return nil, verr
}
content, err := encode(build(cfg))
if err != nil {
return nil, fmt.Errorf("keagen: encode configuration: %w", err)
}
return []configgen.Artifact{{Path: FileName, Mode: FileMode, Content: content}}, nil
}
// ---------------------------------------------------------------------------
// Kea configuration model (only the parameters the generator writes)
// ---------------------------------------------------------------------------
// keaConfig is the top-level document.
type keaConfig struct {
Dhcp4 dhcp4 `json:"Dhcp4"`
}
// dhcp4 is the "Dhcp4" object.
type dhcp4 struct {
InterfacesConfig interfacesConfig `json:"interfaces-config"`
ControlSocket controlSocket `json:"control-socket"`
LeaseDatabase leaseDatabase `json:"lease-database"`
HooksLibraries []hookLibrary `json:"hooks-libraries"`
ValidLifetime uint32 `json:"valid-lifetime"`
CalculateTeeTimes bool `json:"calculate-tee-times"`
Subnet4 []subnet4 `json:"subnet4"`
}
// interfacesConfig selects the listening interfaces and the socket type.
type interfacesConfig struct {
Interfaces []string `json:"interfaces"`
DHCPSocketType string `json:"dhcp-socket-type"`
}
// controlSocket is the Unix control socket used for config-reload and the
// lease commands.
type controlSocket struct {
SocketType string `json:"socket-type"`
SocketName string `json:"socket-name"`
}
// leaseDatabase is the memfile lease storage.
type leaseDatabase struct {
Type string `json:"type"`
Persist bool `json:"persist"`
Name string `json:"name"`
LFCInterval uint32 `json:"lfc-interval"`
}
// hookLibrary is one entry of "hooks-libraries".
type hookLibrary struct {
Library string `json:"library"`
}
// subnet4 is one entry of "subnet4".
type subnet4 struct {
ID uint32 `json:"id"`
Subnet string `json:"subnet"`
ValidLifetime uint32 `json:"valid-lifetime,omitempty"`
Relay *relay `json:"relay,omitempty"`
// Pools are always written ("[]" for a subnet with reservations only).
Pools []pool `json:"pools"`
OptionData []optionData `json:"option-data,omitempty"`
// The pools are effective pools with every reserved address cut out, so
// reservations are never inside a pool and Kea can skip that check.
ReservationsInSubnet bool `json:"reservations-in-subnet"`
ReservationsOutOfPool bool `json:"reservations-out-of-pool"`
Reservations []reservation `json:"reservations,omitempty"`
}
// relay lists the giaddr values that select a subnet.
type relay struct {
IPAddresses []string `json:"ip-addresses"`
}
// pool is an address range "first - last".
type pool struct {
Pool string `json:"pool"`
}
// optionData is one option given by name; Data is in csv format.
type optionData struct {
Name string `json:"name"`
Data string `json:"data"`
}
// reservation is a host reservation by hardware address.
type reservation struct {
HWAddress string `json:"hw-address"`
IPAddress string `json:"ip-address"`
Hostname string `json:"hostname,omitempty"`
}
// ---------------------------------------------------------------------------
// Building and encoding
// ---------------------------------------------------------------------------
// build maps a validated configuration to the Kea model.
func build(cfg configgen.DHCPConfig) keaConfig {
interfaces := []string{"*"}
if len(cfg.Interfaces) > 0 {
interfaces = slices.Clone(cfg.Interfaces)
}
socketType := "udp"
if cfg.DirectClients {
socketType = "raw"
}
library := leaseCmdsLibrary
if cfg.HooksDir != "" {
library = path.Join(cfg.HooksDir, leaseCmdsLibrary)
}
subnets := slices.Clone(cfg.Subnets)
slices.SortFunc(subnets, func(a, b configgen.Subnet) int { return cmp.Compare(a.ID, b.ID) })
out := make([]subnet4, 0, len(subnets))
for _, s := range subnets {
out = append(out, buildSubnet(s))
}
return keaConfig{Dhcp4: dhcp4{
InterfacesConfig: interfacesConfig{Interfaces: interfaces, DHCPSocketType: socketType},
ControlSocket: controlSocket{SocketType: "unix", SocketName: cfg.ControlSocket},
LeaseDatabase: leaseDatabase{
Type: "memfile",
Persist: true,
Name: cfg.LeaseFile,
LFCInterval: lfcInterval,
},
HooksLibraries: []hookLibrary{{Library: library}},
ValidLifetime: cfg.ValidLifetime,
CalculateTeeTimes: true,
Subnet4: out,
}}
}
// buildSubnet maps one validated subnet; lists are sorted where order has no
// meaning (pools, reservations, relay addresses) and kept otherwise (option
// values, whose order is the client's preference).
func buildSubnet(s configgen.Subnet) subnet4 {
pools := slices.Clone(s.Pools)
slices.SortFunc(pools, func(a, b ipam.Pool) int { return a.Start.Compare(b.Start) })
kp := make([]pool, 0, len(pools))
for _, p := range pools {
kp = append(kp, pool{Pool: p.Start.String() + " - " + p.End.String()})
}
var opts []optionData
add := func(name, data string) {
if data != "" {
opts = append(opts, optionData{Name: name, Data: data})
}
}
add("routers", joinAddrs(s.Routers))
add("domain-name-servers", joinAddrs(s.DNSServers))
add("ntp-servers", joinAddrs(s.NTPServers))
add("domain-name", s.DomainName)
add("domain-search", strings.Join(s.SearchDomains, listSep))
var rel *relay
if len(s.RelayAddresses) > 0 {
addrs := slices.Clone(s.RelayAddresses)
slices.SortFunc(addrs, netip.Addr.Compare)
rel = &relay{IPAddresses: addrStrings(addrs)}
}
res := slices.Clone(s.Reservations)
slices.SortFunc(res, func(a, b configgen.Reservation) int { return a.IP.Compare(b.IP) })
var kr []reservation
for _, r := range res {
kr = append(kr, reservation{HWAddress: r.MAC, IPAddress: r.IP.String(), Hostname: r.Hostname})
}
return subnet4{
ID: s.ID,
Subnet: s.Prefix.String(),
ValidLifetime: s.ValidLifetime,
Relay: rel,
Pools: kp,
OptionData: opts,
ReservationsInSubnet: true,
ReservationsOutOfPool: true,
Reservations: kr,
}
}
// addrStrings formats addresses in the given order.
func addrStrings(addrs []netip.Addr) []string {
out := make([]string, 0, len(addrs))
for _, a := range addrs {
out = append(out, a.String())
}
return out
}
// joinAddrs formats an address list option value ("" for none).
func joinAddrs(addrs []netip.Addr) string {
return strings.Join(addrStrings(addrs), listSep)
}
// encode serializes the document with two-space indentation and a trailing
// newline. HTML escaping is off: the file is never embedded in HTML, and
// validated values cannot contain '<', '>' or '&' anyway.
func encode(doc keaConfig) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(doc); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
@@ -0,0 +1,617 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package keagen
import (
"bytes"
"encoding/json"
"errors"
"flag"
"io"
"math/rand/v2"
"net/netip"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
)
var update = flag.Bool("update", false, "rewrite the golden files in testdata")
func ip(s string) netip.Addr { return netip.MustParseAddr(s) }
func ips(s ...string) []netip.Addr {
out := make([]netip.Addr, 0, len(s))
for _, a := range s {
out = append(out, ip(a))
}
return out
}
func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) }
func ipPool(a, b string) ipam.Pool { return ipam.Pool{Start: ip(a), End: ip(b)} }
// baseConfig has the server-level settings and no subnets.
func baseConfig() configgen.DHCPConfig {
return configgen.DHCPConfig{
ControlSocket: "/run/kea/kea4-ctrl-socket",
LeaseFile: "/var/lib/kea/kea-leases4.csv",
ValidLifetime: 3600,
}
}
func minimalConfig() configgen.DHCPConfig {
c := baseConfig()
c.Subnets = []configgen.Subnet{{
ID: 1,
Prefix: pfx("192.0.2.0/24"),
Pools: []ipam.Pool{ipPool("192.0.2.100", "192.0.2.199")},
}}
return c
}
// fullConfig uses every field; lists are deliberately unsorted.
func fullConfig() configgen.DHCPConfig {
c := baseConfig()
c.Interfaces = []string{"eth0", "eth1"}
c.DirectClients = true
c.HooksDir = "/usr/lib/x86_64-linux-gnu/kea/hooks"
c.ValidLifetime = 7200
c.Subnets = []configgen.Subnet{{
ID: 10,
Prefix: pfx("10.20.0.0/22"),
Pools: []ipam.Pool{
ipPool("10.20.2.0", "10.20.3.254"),
ipPool("10.20.0.100", "10.20.0.199"),
ipPool("10.20.1.0", "10.20.1.99"),
},
Routers: ips("10.20.0.1"),
DNSServers: ips("10.20.0.53", "10.20.0.54"),
NTPServers: ips("10.20.0.123"),
DomainName: "office.example.test",
SearchDomains: []string{"office.example.test", "example.test"},
ValidLifetime: 1800,
RelayAddresses: ips("10.99.0.3", "10.99.0.1"),
Reservations: []configgen.Reservation{
{MAC: "00:11:22:33:44:66", IP: ip("10.20.0.20"), Hostname: "printer-2"},
{MAC: "00:11:22:33:44:55", IP: ip("10.20.0.10"), Hostname: "printer-1"},
{MAC: "aa:bb:cc:dd:ee:ff", IP: ip("10.20.1.200")},
},
}}
return c
}
type goldenCase struct {
name string
cfg configgen.DHCPConfig
}
func goldenCases() []goldenCase {
direct := minimalConfig()
direct.DirectClients = true
sorting := baseConfig()
sorting.Subnets = []configgen.Subnet{
{
ID: 7,
Prefix: pfx("198.51.100.0/24"),
Pools: []ipam.Pool{ipPool("198.51.100.10", "198.51.100.20")},
Routers: ips("198.51.100.1"),
},
{
ID: 3,
Prefix: pfx("192.0.2.0/25"),
Pools: []ipam.Pool{ipPool("192.0.2.10", "192.0.2.100")},
Routers: ips("192.0.2.1"),
ValidLifetime: 600,
},
}
resOnly := baseConfig()
resOnly.Subnets = []configgen.Subnet{{
ID: 5,
Prefix: pfx("203.0.113.0/24"),
Reservations: []configgen.Reservation{{MAC: "02:00:00:00:00:01", IP: ip("203.0.113.7"), Hostname: "ap-1"}},
}}
return []goldenCase{
{"empty", baseConfig()},
{"minimal", minimalConfig()},
{"direct_clients", direct},
{"full", fullConfig()},
{"sorting", sorting},
{"reservations_only", resOnly},
}
}
// renderOne renders cfg and checks the artifact metadata.
func renderOne(t *testing.T, cfg configgen.DHCPConfig) []byte {
t.Helper()
arts, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
if len(arts) != 1 {
t.Fatalf("got %d artifacts, want 1", len(arts))
}
a := arts[0]
if a.Path != "kea-dhcp4.conf" || a.Mode != 0o640 || a.CreateOnly {
t.Errorf("artifact metadata = %q %o %v", a.Path, a.Mode, a.CreateOnly)
}
return a.Content
}
func TestRenderGolden(t *testing.T) {
for _, tc := range goldenCases() {
t.Run(tc.name, func(t *testing.T) {
got := renderOne(t, tc.cfg)
golden := filepath.Join("testdata", tc.name+".golden.json")
if *update {
if err := os.WriteFile(golden, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("%v (run with -update to create it)", err)
}
if !bytes.Equal(got, want) {
t.Errorf("%s does not match; complete actual output:\n%s", golden, got)
}
})
}
}
// decodeStrict parses exactly one JSON document into the Kea model,
// rejecting unknown keys and trailing data.
func decodeStrict(t *testing.T, content []byte) keaConfig {
t.Helper()
if !json.Valid(content) {
t.Fatalf("invalid JSON:\n%s", content)
}
dec := json.NewDecoder(bytes.NewReader(content))
dec.DisallowUnknownFields()
var doc keaConfig
if err := dec.Decode(&doc); err != nil {
t.Fatalf("decode: %v\n%s", err, content)
}
if err := dec.Decode(new(json.RawMessage)); !errors.Is(err, io.EOF) {
t.Fatalf("trailing data after the document: %v", err)
}
return doc
}
func TestRenderParsesBack(t *testing.T) {
for _, tc := range goldenCases() {
t.Run(tc.name, func(t *testing.T) {
got := renderOne(t, tc.cfg)
if !bytes.HasSuffix(got, []byte("}\n")) || bytes.HasSuffix(got, []byte("\n\n")) {
t.Errorf("output must end with exactly one newline")
}
doc := decodeStrict(t, got)
again, err := encode(doc)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(again, got) {
t.Errorf("round trip differs:\n%s", again)
}
if len(doc.Dhcp4.Subnet4) != len(tc.cfg.Subnets) {
t.Errorf("got %d subnets, want %d", len(doc.Dhcp4.Subnet4), len(tc.cfg.Subnets))
}
})
}
}
func TestRenderFields(t *testing.T) {
doc := decodeStrict(t, renderOne(t, fullConfig()))
d := doc.Dhcp4
if d.InterfacesConfig.DHCPSocketType != "raw" ||
!slices.Equal(d.InterfacesConfig.Interfaces, []string{"eth0", "eth1"}) {
t.Errorf("interfaces-config = %+v", d.InterfacesConfig)
}
if d.HooksLibraries[0].Library != "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_lease_cmds.so" {
t.Errorf("hook library = %q", d.HooksLibraries[0].Library)
}
s := d.Subnet4[0]
if !s.ReservationsInSubnet || !s.ReservationsOutOfPool {
t.Errorf("reservation flags not set: %+v", s)
}
if s.Relay == nil || !slices.Equal(s.Relay.IPAddresses, []string{"10.99.0.1", "10.99.0.3"}) {
t.Errorf("relay = %+v", s.Relay)
}
var names []string
for _, o := range s.OptionData {
names = append(names, o.Name)
}
want := []string{"routers", "domain-name-servers", "ntp-servers", "domain-name", "domain-search"}
if !slices.Equal(names, want) {
t.Errorf("option order = %v, want %v", names, want)
}
minimal := decodeStrict(t, renderOne(t, minimalConfig())).Dhcp4
if minimal.InterfacesConfig.DHCPSocketType != "udp" ||
!slices.Equal(minimal.InterfacesConfig.Interfaces, []string{"*"}) {
t.Errorf("default interfaces-config = %+v", minimal.InterfacesConfig)
}
if minimal.HooksLibraries[0].Library != "libdhcp_lease_cmds.so" {
t.Errorf("hook library = %q", minimal.HooksLibraries[0].Library)
}
if len(minimal.Subnet4[0].OptionData) != 0 || minimal.Subnet4[0].Relay != nil {
t.Errorf("empty options or relay rendered: %+v", minimal.Subnet4[0])
}
}
// cloneConfig deep-copies every slice of c.
func cloneConfig(c configgen.DHCPConfig) configgen.DHCPConfig {
c.Interfaces = slices.Clone(c.Interfaces)
c.Subnets = slices.Clone(c.Subnets)
for i := range c.Subnets {
s := &c.Subnets[i]
s.Pools = slices.Clone(s.Pools)
s.Routers = slices.Clone(s.Routers)
s.DNSServers = slices.Clone(s.DNSServers)
s.NTPServers = slices.Clone(s.NTPServers)
s.SearchDomains = slices.Clone(s.SearchDomains)
s.RelayAddresses = slices.Clone(s.RelayAddresses)
s.Reservations = slices.Clone(s.Reservations)
}
return c
}
// largeConfig has several subnets with several pools, relays and
// reservations each.
func largeConfig() configgen.DHCPConfig {
c := fullConfig()
c.Subnets = append(c.Subnets,
configgen.Subnet{
ID: 2,
Prefix: pfx("192.0.2.0/24"),
Pools: []ipam.Pool{
ipPool("192.0.2.200", "192.0.2.250"),
ipPool("192.0.2.20", "192.0.2.29"),
ipPool("192.0.2.100", "192.0.2.149"),
},
RelayAddresses: ips("192.0.2.3", "192.0.2.1", "192.0.2.2"),
Reservations: []configgen.Reservation{
{MAC: "02:00:00:00:00:03", IP: ip("192.0.2.12")},
{MAC: "02:00:00:00:00:01", IP: ip("192.0.2.30"), Hostname: "b"},
{MAC: "02:00:00:00:00:02", IP: ip("192.0.2.2"), Hostname: "a"},
{MAC: "02:00:00:00:00:04", IP: ip("192.0.2.160")},
},
},
configgen.Subnet{ID: 30, Prefix: pfx("198.51.100.0/24"), Pools: []ipam.Pool{ipPool("198.51.100.1", "198.51.100.254")}},
configgen.Subnet{ID: 4, Prefix: pfx("203.0.113.0/26")},
)
return c
}
func TestRenderDeterministic(t *testing.T) {
want := renderOne(t, largeConfig())
rng := rand.New(rand.NewPCG(1, 2))
for n := range 50 {
c := cloneConfig(largeConfig())
rng.Shuffle(len(c.Subnets), func(i, j int) { c.Subnets[i], c.Subnets[j] = c.Subnets[j], c.Subnets[i] })
for k := range c.Subnets {
s := &c.Subnets[k]
rng.Shuffle(len(s.Pools), func(i, j int) { s.Pools[i], s.Pools[j] = s.Pools[j], s.Pools[i] })
rng.Shuffle(len(s.Reservations), func(i, j int) {
s.Reservations[i], s.Reservations[j] = s.Reservations[j], s.Reservations[i]
})
rng.Shuffle(len(s.RelayAddresses), func(i, j int) {
s.RelayAddresses[i], s.RelayAddresses[j] = s.RelayAddresses[j], s.RelayAddresses[i]
})
}
if got := renderOne(t, c); !bytes.Equal(got, want) {
t.Fatalf("iteration %d: output differs after shuffling:\n%s", n, got)
}
}
}
func TestRenderDoesNotModifyInput(t *testing.T) {
c := largeConfig()
before := cloneConfig(c)
renderOne(t, c)
if !reflect.DeepEqual(c, before) {
t.Errorf("Render modified its input")
}
}
func TestRenderAccepts(t *testing.T) {
tests := []struct {
name string
mod func(c *configgen.DHCPConfig)
}{
{"interface names", func(c *configgen.DHCPConfig) {
c.Interfaces = []string{"*", "eth0.100", "enp0s31f6", "br-lan", "eth0:1", "veth1@if2", "ens_3", strings.Repeat("a", 64)}
}},
{"paths with allowed characters", func(c *configgen.DHCPConfig) {
c.ControlSocket = "/run/kea/kea4_ctrl-socket.1"
c.LeaseFile = "/var/lib/kea/leases+v4@a.csv"
c.HooksDir = "/"
}},
{"control socket of 107 bytes", func(c *configgen.DHCPConfig) {
c.ControlSocket = "/run/" + strings.Repeat("a", 102)
}},
{"/31 and /32 prefixes use every address", func(c *configgen.DHCPConfig) {
c.Subnets = []configgen.Subnet{
{ID: 1, Prefix: pfx("192.0.2.0/31"), Pools: []ipam.Pool{ipPool("192.0.2.0", "192.0.2.1")}},
{ID: 2, Prefix: pfx("192.0.2.5/32"), Reservations: []configgen.Reservation{{MAC: "00:00:00:00:00:01", IP: ip("192.0.2.5")}}},
}
}},
{"adjacent pools and prefixes", func(c *configgen.DHCPConfig) {
c.Subnets = []configgen.Subnet{
{ID: 1, Prefix: pfx("192.0.2.0/25"), Pools: []ipam.Pool{ipPool("192.0.2.1", "192.0.2.10"), ipPool("192.0.2.11", "192.0.2.126")}},
{ID: 2, Prefix: pfx("192.0.2.128/25")},
}
}},
{"duplicate hostname", func(c *configgen.DHCPConfig) {
c.Subnets[0].Reservations[1].Hostname = "host-a"
}},
{"63-character hostname and single-label domain", func(c *configgen.DHCPConfig) {
c.Subnets[0].Reservations[0].Hostname = strings.Repeat("h", 63)
c.Subnets[0].DomainName = "test"
}},
{"subnet with prefix only", func(c *configgen.DHCPConfig) {
c.Subnets = []configgen.Subnet{{ID: 4294967295, Prefix: pfx("0.0.0.0/0")}}
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := validConfig()
tc.mod(&c)
if _, err := Render(c); err != nil {
t.Errorf("rejected: %v", err)
}
})
}
}
// validConfig is the starting point of the validation tests.
func validConfig() configgen.DHCPConfig {
return configgen.DHCPConfig{
Interfaces: []string{"eth0", "eth1"},
ControlSocket: "/run/kea/kea4-ctrl-socket",
LeaseFile: "/var/lib/kea/kea-leases4.csv",
HooksDir: "/usr/lib/kea/hooks",
ValidLifetime: 3600,
Subnets: []configgen.Subnet{
{
ID: 1,
Prefix: pfx("192.0.2.0/24"),
Pools: []ipam.Pool{ipPool("192.0.2.100", "192.0.2.149"), ipPool("192.0.2.150", "192.0.2.199")},
Routers: ips("192.0.2.1"),
DNSServers: ips("192.0.2.53", "192.0.2.54"),
NTPServers: ips("192.0.2.123"),
DomainName: "example.test",
SearchDomains: []string{"example.test", "corp.test"},
RelayAddresses: ips("192.0.2.1", "192.0.2.2"),
Reservations: []configgen.Reservation{
{MAC: "00:11:22:33:44:55", IP: ip("192.0.2.10"), Hostname: "host-a"},
{MAC: "00:11:22:33:44:66", IP: ip("192.0.2.11")},
},
},
{ID: 2, Prefix: pfx("198.51.100.0/24"), Pools: []ipam.Pool{ipPool("198.51.100.10", "198.51.100.20")}},
},
}
}
func TestValidConfigRenders(t *testing.T) {
renderOne(t, validConfig())
}
func TestRenderValidation(t *testing.T) {
type cfg = configgen.DHCPConfig
s0 := func(c *cfg) *configgen.Subnet { return &c.Subnets[0] }
r0 := func(c *cfg) *configgen.Reservation { return &c.Subnets[0].Reservations[0] }
tests := []struct {
name string
mod func(c *cfg)
path string
}{
// Server-level paths.
{"control socket missing", func(c *cfg) { c.ControlSocket = "" }, "control_socket"},
{"control socket relative", func(c *cfg) { c.ControlSocket = "run/kea/sock" }, "control_socket"},
{"control socket double quote", func(c *cfg) { c.ControlSocket = `/run/kea/a"b` }, "control_socket"},
{"control socket single quote", func(c *cfg) { c.ControlSocket = "/run/kea/a'b" }, "control_socket"},
{"control socket newline", func(c *cfg) { c.ControlSocket = "/run/kea/a\nb" }, "control_socket"},
{"control socket NUL", func(c *cfg) { c.ControlSocket = "/run/kea/a\x00" }, "control_socket"},
{"control socket non-ASCII", func(c *cfg) { c.ControlSocket = "/run/kéa/sock" }, "control_socket"},
{"control socket dot-dot", func(c *cfg) { c.ControlSocket = "/run/kea/../sock" }, "control_socket"},
{"control socket trailing slash", func(c *cfg) { c.ControlSocket = "/run/kea/" }, "control_socket"},
{"control socket double slash", func(c *cfg) { c.ControlSocket = "/run//kea/sock" }, "control_socket"},
{"control socket root", func(c *cfg) { c.ControlSocket = "/" }, "control_socket"},
{"control socket too long", func(c *cfg) { c.ControlSocket = "/run/" + strings.Repeat("a", 103) }, "control_socket"},
{"lease file missing", func(c *cfg) { c.LeaseFile = "" }, "lease_file"},
{"lease file relative", func(c *cfg) { c.LeaseFile = "kea-leases4.csv" }, "lease_file"},
{"lease file backslash", func(c *cfg) { c.LeaseFile = `/var/lib/kea\leases.csv` }, "lease_file"},
{"lease file space", func(c *cfg) { c.LeaseFile = "/var/lib/kea/a b.csv" }, "lease_file"},
{"lease file tab", func(c *cfg) { c.LeaseFile = "/var/lib/kea/a\tb.csv" }, "lease_file"},
{"lease file DEL", func(c *cfg) { c.LeaseFile = "/var/lib/kea/a\x7f.csv" }, "lease_file"},
{"lease file dot", func(c *cfg) { c.LeaseFile = "/var/lib/kea/./leases.csv" }, "lease_file"},
{"lease file too long", func(c *cfg) { c.LeaseFile = "/" + strings.Repeat("a", 4095) }, "lease_file"},
{"hooks dir relative", func(c *cfg) { c.HooksDir = "usr/lib/kea/hooks" }, "hooks_dir"},
{"hooks dir quote", func(c *cfg) { c.HooksDir = `/usr/lib/"kea` }, "hooks_dir"},
{"hooks dir trailing slash", func(c *cfg) { c.HooksDir = "/usr/lib/kea/hooks/" }, "hooks_dir"},
{"hooks dir with control character", func(c *cfg) { c.HooksDir = "/usr/lib/kea\r/hooks" }, "hooks_dir"},
{"valid lifetime 0", func(c *cfg) { c.ValidLifetime = 0 }, "valid_lifetime"},
// Interfaces.
{"interface empty", func(c *cfg) { c.Interfaces[1] = "" }, "interfaces[1]"},
{"interface space", func(c *cfg) { c.Interfaces[0] = "eth 0" }, "interfaces[0]"},
{"interface slash", func(c *cfg) { c.Interfaces[0] = "eth0/192.0.2.1" }, "interfaces[0]"},
{"interface quote", func(c *cfg) { c.Interfaces[0] = `eth0"` }, "interfaces[0]"},
{"interface newline", func(c *cfg) { c.Interfaces[0] = "eth0\n" }, "interfaces[0]"},
{"interface too long", func(c *cfg) { c.Interfaces[0] = strings.Repeat("a", 65) }, "interfaces[0]"},
{"interface duplicate", func(c *cfg) { c.Interfaces[1] = "eth0" }, "interfaces[1]"},
// Subnet ID and prefix.
{"subnet ID 0", func(c *cfg) { c.Subnets[0].ID = 0 }, "subnets[0].id"},
{"subnet ID duplicate", func(c *cfg) { c.Subnets[1].ID = 1 }, "subnets[1].id"},
{"prefix missing", func(c *cfg) { c.Subnets[0].Prefix = netip.Prefix{} }, "subnets[0].prefix"},
{"prefix IPv6", func(c *cfg) { c.Subnets[1].Prefix = pfx("2001:db8::/64") }, "subnets[1].prefix"},
{"prefix IPv4-mapped", func(c *cfg) { c.Subnets[1].Prefix = pfx("::ffff:198.51.100.0/120") }, "subnets[1].prefix"},
{"prefix with host bits", func(c *cfg) { c.Subnets[1].Prefix = pfx("198.51.100.1/24") }, "subnets[1].prefix"},
{"prefix duplicate", func(c *cfg) { c.Subnets[1].Prefix = pfx("192.0.2.0/24") }, "subnets[1].prefix"},
{"prefix inside earlier", func(c *cfg) { c.Subnets[1].Prefix = pfx("192.0.2.128/25") }, "subnets[1].prefix"},
{"prefix around earlier", func(c *cfg) { c.Subnets[1].Prefix = pfx("192.0.0.0/16") }, "subnets[1].prefix"},
// Pools.
{"pool start IPv6", func(c *cfg) { s0(c).Pools[0].Start = ip("2001:db8::1") }, "subnets[0].pools[0]"},
{"pool end missing", func(c *cfg) { s0(c).Pools[1].End = netip.Addr{} }, "subnets[0].pools[1]"},
{"pool IPv4-mapped", func(c *cfg) { s0(c).Pools[0] = ipPool("::ffff:192.0.2.100", "::ffff:192.0.2.149") }, "subnets[0].pools[0]"},
{"pool start after end", func(c *cfg) { s0(c).Pools[0] = ipPool("192.0.2.149", "192.0.2.100") }, "subnets[0].pools[0]"},
{"pool outside prefix", func(c *cfg) { s0(c).Pools[1] = ipPool("198.51.100.30", "198.51.100.40") }, "subnets[0].pools[1]"},
{"pool crosses prefix end", func(c *cfg) { s0(c).Pools[1] = ipPool("192.0.2.150", "192.0.3.10") }, "subnets[0].pools[1]"},
{"pool includes network address", func(c *cfg) { s0(c).Pools[0] = ipPool("192.0.2.0", "192.0.2.5") }, "subnets[0].pools[0]"},
{"pool includes broadcast address", func(c *cfg) { s0(c).Pools[1] = ipPool("192.0.2.150", "192.0.2.255") }, "subnets[0].pools[1]"},
{"pools overlap", func(c *cfg) { s0(c).Pools[1] = ipPool("192.0.2.149", "192.0.2.199") }, "subnets[0].pools[1]"},
{"pools identical", func(c *cfg) { s0(c).Pools[1] = s0(c).Pools[0] }, "subnets[0].pools[1]"},
{"pool contains other", func(c *cfg) { s0(c).Pools[1] = ipPool("192.0.2.50", "192.0.2.250") }, "subnets[0].pools[1]"},
{"pool error in second subnet", func(c *cfg) { c.Subnets[1].Pools[0] = ipPool("198.51.100.20", "198.51.100.10") }, "subnets[1].pools[0]"},
// Options.
{"router IPv6", func(c *cfg) { s0(c).Routers[0] = ip("2001:db8::1") }, "subnets[0].routers[0]"},
{"router missing", func(c *cfg) { s0(c).Routers = []netip.Addr{{}} }, "subnets[0].routers[0]"},
{"DNS server IPv4-mapped", func(c *cfg) { s0(c).DNSServers[1] = ip("::ffff:192.0.2.54") }, "subnets[0].dns_servers[1]"},
{"NTP server IPv6", func(c *cfg) { s0(c).NTPServers[0] = ip("2001:db8::123") }, "subnets[0].ntp_servers[0]"},
{"domain upper case", func(c *cfg) { s0(c).DomainName = "Example.test" }, "subnets[0].domain_name"},
{"domain trailing dot", func(c *cfg) { s0(c).DomainName = "example.test." }, "subnets[0].domain_name"},
{"domain space", func(c *cfg) { s0(c).DomainName = "exa mple.test" }, "subnets[0].domain_name"},
{"domain quote", func(c *cfg) { s0(c).DomainName = `a"b.test` }, "subnets[0].domain_name"},
{"domain comma", func(c *cfg) { s0(c).DomainName = "a.test,b.test" }, "subnets[0].domain_name"},
{"domain empty label", func(c *cfg) { s0(c).DomainName = "a..test" }, "subnets[0].domain_name"},
{"domain underscore", func(c *cfg) { s0(c).DomainName = "under_score.test" }, "subnets[0].domain_name"},
{"domain too long", func(c *cfg) { s0(c).DomainName = strings.Repeat("a.", 127) + "test" }, "subnets[0].domain_name"},
{"search domain hyphen", func(c *cfg) { s0(c).SearchDomains[1] = "-bad.test" }, "subnets[0].search_domains[1]"},
{"search domain empty", func(c *cfg) { s0(c).SearchDomains[0] = "" }, "subnets[0].search_domains[0]"},
{"search domain newline", func(c *cfg) { s0(c).SearchDomains[0] = "a.test\n" }, "subnets[0].search_domains[0]"},
// Relay.
{"relay IPv6", func(c *cfg) { s0(c).RelayAddresses[0] = ip("2001:db8::1") }, "subnets[0].relay_addresses[0]"},
{"relay missing", func(c *cfg) { s0(c).RelayAddresses[1] = netip.Addr{} }, "subnets[0].relay_addresses[1]"},
{"relay duplicate", func(c *cfg) { s0(c).RelayAddresses[1] = ip("192.0.2.1") }, "subnets[0].relay_addresses[1]"},
// Reservations.
{"MAC upper case", func(c *cfg) { r0(c).MAC = "00:11:22:33:44:AA" }, "subnets[0].reservations[0].mac"},
{"MAC dashes", func(c *cfg) { r0(c).MAC = "00-11-22-33-44-55" }, "subnets[0].reservations[0].mac"},
{"MAC bare hex", func(c *cfg) { r0(c).MAC = "001122334455" }, "subnets[0].reservations[0].mac"},
{"MAC dotted", func(c *cfg) { r0(c).MAC = "0011.2233.4455" }, "subnets[0].reservations[0].mac"},
{"MAC padded", func(c *cfg) { r0(c).MAC = " 00:11:22:33:44:55" }, "subnets[0].reservations[0].mac"},
{"MAC short", func(c *cfg) { r0(c).MAC = "00:11:22:33:44" }, "subnets[0].reservations[0].mac"},
{"MAC EUI-64", func(c *cfg) { r0(c).MAC = "00:11:22:33:44:55:66:77" }, "subnets[0].reservations[0].mac"},
{"MAC empty", func(c *cfg) { r0(c).MAC = "" }, "subnets[0].reservations[0].mac"},
{"MAC quote", func(c *cfg) { r0(c).MAC = `00:11:22:33:44:5"` }, "subnets[0].reservations[0].mac"},
{"MAC duplicate", func(c *cfg) { c.Subnets[0].Reservations[1].MAC = "00:11:22:33:44:55" }, "subnets[0].reservations[1].mac"},
{"IP missing", func(c *cfg) { r0(c).IP = netip.Addr{} }, "subnets[0].reservations[0].ip"},
{"IP IPv6", func(c *cfg) { r0(c).IP = ip("2001:db8::10") }, "subnets[0].reservations[0].ip"},
{"IP IPv4-mapped", func(c *cfg) { r0(c).IP = ip("::ffff:192.0.2.10") }, "subnets[0].reservations[0].ip"},
{"IP outside subnet", func(c *cfg) { r0(c).IP = ip("198.51.100.5") }, "subnets[0].reservations[0].ip"},
{"IP network address", func(c *cfg) { r0(c).IP = ip("192.0.2.0") }, "subnets[0].reservations[0].ip"},
{"IP broadcast address", func(c *cfg) { r0(c).IP = ip("192.0.2.255") }, "subnets[0].reservations[0].ip"},
{"IP inside first pool", func(c *cfg) { r0(c).IP = ip("192.0.2.100") }, "subnets[0].reservations[0].ip"},
{"IP inside second pool", func(c *cfg) { r0(c).IP = ip("192.0.2.199") }, "subnets[0].reservations[0].ip"},
{"IP duplicate", func(c *cfg) { c.Subnets[0].Reservations[1].IP = ip("192.0.2.10") }, "subnets[0].reservations[1].ip"},
{"hostname upper case", func(c *cfg) { r0(c).Hostname = "Host-A" }, "subnets[0].reservations[0].hostname"},
{"hostname FQDN", func(c *cfg) { r0(c).Hostname = "host.example.test" }, "subnets[0].reservations[0].hostname"},
{"hostname underscore", func(c *cfg) { r0(c).Hostname = "host_a" }, "subnets[0].reservations[0].hostname"},
{"hostname leading hyphen", func(c *cfg) { r0(c).Hostname = "-host" }, "subnets[0].reservations[0].hostname"},
{"hostname padded", func(c *cfg) { r0(c).Hostname = "host " }, "subnets[0].reservations[0].hostname"},
{"hostname too long", func(c *cfg) { r0(c).Hostname = strings.Repeat("h", 64) }, "subnets[0].reservations[0].hostname"},
{"hostname quote", func(c *cfg) { r0(c).Hostname = `a","x":"y` }, "subnets[0].reservations[0].hostname"},
{"hostname newline", func(c *cfg) { r0(c).Hostname = "a\nb" }, "subnets[0].reservations[0].hostname"},
{"hostname in second subnet", func(c *cfg) {
c.Subnets[1].Reservations = []configgen.Reservation{{MAC: "00:11:22:33:44:77", IP: ip("198.51.100.5"), Hostname: "a b"}}
}, "subnets[1].reservations[0].hostname"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := validConfig()
tc.mod(&c)
arts, err := Render(c)
if err == nil {
t.Fatalf("accepted; output:\n%s", arts[0].Content)
}
if arts != nil {
t.Errorf("artifacts returned together with an error")
}
var ve *configgen.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error %v (%T) is not a *configgen.ValidationError", err, err)
}
if ve.Path != tc.path {
t.Errorf("path = %q, want %q (%v)", ve.Path, tc.path, err)
}
if ve.Message == "" {
t.Errorf("empty message")
}
})
}
}
// fuzzConfig is the base of FuzzRender: one subnet with one reservation.
func fuzzConfig(hostname, domain, mac string) configgen.DHCPConfig {
c := minimalConfig()
s := &c.Subnets[0]
s.DomainName = domain
if domain != "" {
s.SearchDomains = []string{domain}
}
s.Reservations = []configgen.Reservation{{MAC: mac, IP: ip("192.0.2.10"), Hostname: hostname}}
return c
}
func FuzzRender(f *testing.F) {
f.Add("printer", "example.test", "00:11:22:33:44:55")
f.Add("", "", "aa:bb:cc:dd:ee:ff")
f.Add(`a","x":"y`, `example.test"},{"name":"routers`, `00:11:22:33:44:55","ip-address":"10.0.0.1`)
f.Add("a\nb", "a\\b.test", "00:11:22:33:44:5\x00")
f.Add("<script>", "a&b.test", "AA:BB:CC:DD:EE:FF")
f.Add("host
", "exämple.test", "0011.2233.4455")
f.Add("h", "a.test, b.test", "00:11:22:33:44:55 ")
f.Fuzz(func(t *testing.T, hostname, domain, mac string) {
arts, err := Render(fuzzConfig(hostname, domain, mac))
if err != nil {
var ve *configgen.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error %v (%T) is not a *configgen.ValidationError", err, err)
}
return
}
doc := decodeStrict(t, arts[0].Content)
if len(doc.Dhcp4.Subnet4) != 1 {
t.Fatalf("got %d subnets", len(doc.Dhcp4.Subnet4))
}
s := doc.Dhcp4.Subnet4[0]
if s.ID != 1 || s.Subnet != "192.0.2.0/24" || len(s.Pools) != 1 || s.Pools[0].Pool != "192.0.2.100 - 192.0.2.199" {
t.Fatalf("subnet structure changed: %+v", s)
}
if len(s.Reservations) != 1 {
t.Fatalf("got %d reservations", len(s.Reservations))
}
r := s.Reservations[0]
if r.HWAddress != mac || r.IPAddress != "192.0.2.10" || r.Hostname != hostname {
t.Fatalf("reservation = %+v, want %q %q", r, mac, hostname)
}
opts := map[string]string{}
for _, o := range s.OptionData {
if _, dup := opts[o.Name]; dup {
t.Fatalf("option %q twice", o.Name)
}
opts[o.Name] = o.Data
}
wantOpts := map[string]string{}
if domain != "" {
wantOpts["domain-name"] = domain
wantOpts["domain-search"] = domain
}
if !reflect.DeepEqual(opts, wantOpts) {
t.Fatalf("options = %v, want %v", opts, wantOpts)
}
})
}
@@ -0,0 +1,40 @@
{
"Dhcp4": {
"interfaces-config": {
"interfaces": [
"*"
],
"dhcp-socket-type": "raw"
},
"control-socket": {
"socket-type": "unix",
"socket-name": "/run/kea/kea4-ctrl-socket"
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv",
"lfc-interval": 3600
},
"hooks-libraries": [
{
"library": "libdhcp_lease_cmds.so"
}
],
"valid-lifetime": 3600,
"calculate-tee-times": true,
"subnet4": [
{
"id": 1,
"subnet": "192.0.2.0/24",
"pools": [
{
"pool": "192.0.2.100 - 192.0.2.199"
}
],
"reservations-in-subnet": true,
"reservations-out-of-pool": true
}
]
}
}
@@ -0,0 +1,28 @@
{
"Dhcp4": {
"interfaces-config": {
"interfaces": [
"*"
],
"dhcp-socket-type": "udp"
},
"control-socket": {
"socket-type": "unix",
"socket-name": "/run/kea/kea4-ctrl-socket"
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv",
"lfc-interval": 3600
},
"hooks-libraries": [
{
"library": "libdhcp_lease_cmds.so"
}
],
"valid-lifetime": 3600,
"calculate-tee-times": true,
"subnet4": []
}
}
@@ -0,0 +1,92 @@
{
"Dhcp4": {
"interfaces-config": {
"interfaces": [
"eth0",
"eth1"
],
"dhcp-socket-type": "raw"
},
"control-socket": {
"socket-type": "unix",
"socket-name": "/run/kea/kea4-ctrl-socket"
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv",
"lfc-interval": 3600
},
"hooks-libraries": [
{
"library": "/usr/lib/x86_64-linux-gnu/kea/hooks/libdhcp_lease_cmds.so"
}
],
"valid-lifetime": 7200,
"calculate-tee-times": true,
"subnet4": [
{
"id": 10,
"subnet": "10.20.0.0/22",
"valid-lifetime": 1800,
"relay": {
"ip-addresses": [
"10.99.0.1",
"10.99.0.3"
]
},
"pools": [
{
"pool": "10.20.0.100 - 10.20.0.199"
},
{
"pool": "10.20.1.0 - 10.20.1.99"
},
{
"pool": "10.20.2.0 - 10.20.3.254"
}
],
"option-data": [
{
"name": "routers",
"data": "10.20.0.1"
},
{
"name": "domain-name-servers",
"data": "10.20.0.53, 10.20.0.54"
},
{
"name": "ntp-servers",
"data": "10.20.0.123"
},
{
"name": "domain-name",
"data": "office.example.test"
},
{
"name": "domain-search",
"data": "office.example.test, example.test"
}
],
"reservations-in-subnet": true,
"reservations-out-of-pool": true,
"reservations": [
{
"hw-address": "00:11:22:33:44:55",
"ip-address": "10.20.0.10",
"hostname": "printer-1"
},
{
"hw-address": "00:11:22:33:44:66",
"ip-address": "10.20.0.20",
"hostname": "printer-2"
},
{
"hw-address": "aa:bb:cc:dd:ee:ff",
"ip-address": "10.20.1.200"
}
]
}
]
}
}
@@ -0,0 +1,40 @@
{
"Dhcp4": {
"interfaces-config": {
"interfaces": [
"*"
],
"dhcp-socket-type": "udp"
},
"control-socket": {
"socket-type": "unix",
"socket-name": "/run/kea/kea4-ctrl-socket"
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv",
"lfc-interval": 3600
},
"hooks-libraries": [
{
"library": "libdhcp_lease_cmds.so"
}
],
"valid-lifetime": 3600,
"calculate-tee-times": true,
"subnet4": [
{
"id": 1,
"subnet": "192.0.2.0/24",
"pools": [
{
"pool": "192.0.2.100 - 192.0.2.199"
}
],
"reservations-in-subnet": true,
"reservations-out-of-pool": true
}
]
}
}
@@ -0,0 +1,43 @@
{
"Dhcp4": {
"interfaces-config": {
"interfaces": [
"*"
],
"dhcp-socket-type": "udp"
},
"control-socket": {
"socket-type": "unix",
"socket-name": "/run/kea/kea4-ctrl-socket"
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv",
"lfc-interval": 3600
},
"hooks-libraries": [
{
"library": "libdhcp_lease_cmds.so"
}
],
"valid-lifetime": 3600,
"calculate-tee-times": true,
"subnet4": [
{
"id": 5,
"subnet": "203.0.113.0/24",
"pools": [],
"reservations-in-subnet": true,
"reservations-out-of-pool": true,
"reservations": [
{
"hw-address": "02:00:00:00:00:01",
"ip-address": "203.0.113.7",
"hostname": "ap-1"
}
]
}
]
}
}
@@ -0,0 +1,64 @@
{
"Dhcp4": {
"interfaces-config": {
"interfaces": [
"*"
],
"dhcp-socket-type": "udp"
},
"control-socket": {
"socket-type": "unix",
"socket-name": "/run/kea/kea4-ctrl-socket"
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "/var/lib/kea/kea-leases4.csv",
"lfc-interval": 3600
},
"hooks-libraries": [
{
"library": "libdhcp_lease_cmds.so"
}
],
"valid-lifetime": 3600,
"calculate-tee-times": true,
"subnet4": [
{
"id": 3,
"subnet": "192.0.2.0/25",
"valid-lifetime": 600,
"pools": [
{
"pool": "192.0.2.10 - 192.0.2.100"
}
],
"option-data": [
{
"name": "routers",
"data": "192.0.2.1"
}
],
"reservations-in-subnet": true,
"reservations-out-of-pool": true
},
{
"id": 7,
"subnet": "198.51.100.0/24",
"pools": [
{
"pool": "198.51.100.10 - 198.51.100.20"
}
],
"option-data": [
{
"name": "routers",
"data": "198.51.100.1"
}
],
"reservations-in-subnet": true,
"reservations-out-of-pool": true
}
]
}
}
@@ -0,0 +1,255 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package keagen
import (
"fmt"
"net/netip"
"path"
"regexp"
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
)
const (
// maxPathLen is PATH_MAX on Linux minus the terminating NUL.
maxPathLen = 4095
// maxSocketPathLen is the size of sun_path (108) minus the terminating
// NUL; longer Unix socket paths cannot be bound.
maxSocketPathLen = 107
// pathChars are the characters allowed in file system paths.
pathChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._+@-/"
)
// interfaceRE matches an interface name ("*" means all interfaces).
var interfaceRE = regexp.MustCompile(`^[A-Za-z0-9._:@*-]{1,64}$`)
// invalidf builds the validation error for the value at p.
func invalidf(p, format string, args ...any) *configgen.ValidationError {
return &configgen.ValidationError{Path: p, Message: fmt.Sprintf(format, args...)}
}
// validate checks every value of cfg and returns the first problem found.
// Paths in errors use the input indices, not the sorted output order.
func validate(cfg configgen.DHCPConfig) *configgen.ValidationError {
if err := checkFilePath("control_socket", cfg.ControlSocket); err != nil {
return err
}
if len(cfg.ControlSocket) > maxSocketPathLen {
return invalidf("control_socket", "must be at most %d bytes long (Unix socket path)", maxSocketPathLen)
}
if err := checkFilePath("lease_file", cfg.LeaseFile); err != nil {
return err
}
if cfg.HooksDir != "" {
if err := checkPath("hooks_dir", cfg.HooksDir); err != nil {
return err
}
}
if cfg.ValidLifetime == 0 {
return invalidf("valid_lifetime", "must be greater than 0")
}
seenIface := map[string]int{}
for i, name := range cfg.Interfaces {
p := fmt.Sprintf("interfaces[%d]", i)
if !interfaceRE.MatchString(name) {
return invalidf(p, "must be 1 to 64 characters of A-Z, a-z, 0-9 and . _ : @ * -")
}
if j, dup := seenIface[name]; dup {
return invalidf(p, "duplicate interface %q (also interfaces[%d])", name, j)
}
seenIface[name] = i
}
seenID := map[uint32]int{}
for i, s := range cfg.Subnets {
p := fmt.Sprintf("subnets[%d]", i)
if s.ID == 0 {
return invalidf(p+".id", "must not be 0")
}
if j, dup := seenID[s.ID]; dup {
return invalidf(p+".id", "duplicate subnet ID %d (also subnets[%d])", s.ID, j)
}
seenID[s.ID] = i
if err := checkPrefix(p+".prefix", s.Prefix); err != nil {
return err
}
// Earlier prefixes are valid here; report the later of two subnets.
for j := range i {
if q := cfg.Subnets[j].Prefix; q.Overlaps(s.Prefix) {
if q == s.Prefix {
return invalidf(p+".prefix", "duplicate prefix %s (also subnets[%d])", s.Prefix, j)
}
return invalidf(p+".prefix", "%s overlaps %s of subnets[%d]", s.Prefix, q, j)
}
}
if err := checkSubnet(p, s); err != nil {
return err
}
}
return nil
}
// checkSubnet validates everything of a subnet except ID and prefix.
func checkSubnet(p string, s configgen.Subnet) *configgen.ValidationError {
first, last := usableBounds(s.Prefix)
for j, pl := range s.Pools {
pp := fmt.Sprintf("%s.pools[%d]", p, j)
switch {
case !pl.Start.Is4() || !pl.End.Is4():
return invalidf(pp, "start and end must be IPv4 addresses")
case pl.Start.Compare(pl.End) > 0:
return invalidf(pp, "start %s is after end %s", pl.Start, pl.End)
case pl.Start.Compare(first) < 0 || pl.End.Compare(last) > 0:
return invalidf(pp, "%s - %s is not inside the usable range %s - %s of %s",
pl.Start, pl.End, first, last, s.Prefix)
}
for k := range j {
if q := s.Pools[k]; pl.Start.Compare(q.End) <= 0 && q.Start.Compare(pl.End) <= 0 {
return invalidf(pp, "%s - %s overlaps pools[%d] (%s - %s)", pl.Start, pl.End, k, q.Start, q.End)
}
}
}
for _, list := range []struct {
name string
addrs []netip.Addr
}{
{"routers", s.Routers},
{"dns_servers", s.DNSServers},
{"ntp_servers", s.NTPServers},
} {
for k, a := range list.addrs {
if !a.Is4() {
return invalidf(fmt.Sprintf("%s.%s[%d]", p, list.name, k), "must be an IPv4 address")
}
}
}
if s.DomainName != "" && !canonicalDomain(s.DomainName) {
return invalidf(p+".domain_name", "must be a lower-case DNS domain name without trailing dot")
}
for k, d := range s.SearchDomains {
if !canonicalDomain(d) {
return invalidf(fmt.Sprintf("%s.search_domains[%d]", p, k),
"must be a lower-case DNS domain name without trailing dot")
}
}
seenRelay := map[netip.Addr]int{}
for k, a := range s.RelayAddresses {
rp := fmt.Sprintf("%s.relay_addresses[%d]", p, k)
if !a.Is4() {
return invalidf(rp, "must be an IPv4 address")
}
if j, dup := seenRelay[a]; dup {
return invalidf(rp, "duplicate relay address %s (also relay_addresses[%d])", a, j)
}
seenRelay[a] = k
}
return checkReservations(p, s, first, last)
}
// checkReservations validates the reservations of a subnet whose pools are
// already valid.
func checkReservations(p string, s configgen.Subnet, first, last netip.Addr) *configgen.ValidationError {
seenMAC := map[string]int{}
seenIP := map[netip.Addr]int{}
for k, r := range s.Reservations {
rp := fmt.Sprintf("%s.reservations[%d]", p, k)
if mac, err := ipam.NormalizeMAC(r.MAC); err != nil || mac != r.MAC {
return invalidf(rp+".mac", "must be a MAC address in lower-case colon notation (00:11:22:aa:bb:cc)")
}
if j, dup := seenMAC[r.MAC]; dup {
return invalidf(rp+".mac", "duplicate MAC %s (also reservations[%d])", r.MAC, j)
}
seenMAC[r.MAC] = k
if !r.IP.Is4() {
return invalidf(rp+".ip", "must be an IPv4 address")
}
if r.IP.Compare(first) < 0 || r.IP.Compare(last) > 0 {
return invalidf(rp+".ip", "%s is not a usable address of %s", r.IP, s.Prefix)
}
for j, pl := range s.Pools {
if pl.Contains(r.IP) {
return invalidf(rp+".ip", "%s is inside pools[%d]; reservations must be outside the pools", r.IP, j)
}
}
if j, dup := seenIP[r.IP]; dup {
return invalidf(rp+".ip", "duplicate IP %s (also reservations[%d])", r.IP, j)
}
seenIP[r.IP] = k
if r.Hostname != "" {
if h, err := ipam.NormalizeHostname(r.Hostname); err != nil || h != r.Hostname {
return invalidf(rp+".hostname", "must be a single lower-case DNS label")
}
}
}
return nil
}
// checkPrefix accepts a masked IPv4 prefix.
func checkPrefix(p string, pfx netip.Prefix) *configgen.ValidationError {
switch {
case !pfx.IsValid() || !pfx.Addr().Is4():
return invalidf(p, "must be an IPv4 prefix")
case pfx.Masked() != pfx:
return invalidf(p, "%s has host bits set (network is %s)", pfx, pfx.Masked())
}
return nil
}
// checkPath accepts a clean absolute path of conservative characters, so the
// value is safe in JSON, in log lines and on a command line.
func checkPath(p, value string) *configgen.ValidationError {
switch {
case value == "":
return invalidf(p, "is required")
case len(value) > maxPathLen:
return invalidf(p, "must be at most %d bytes long", maxPathLen)
case strings.IndexFunc(value, notPathChar) >= 0:
return invalidf(p, "may contain only A-Z, a-z, 0-9 and . _ + @ - /")
case !path.IsAbs(value):
return invalidf(p, "must be an absolute path")
case path.Clean(value) != value:
return invalidf(p, "must be a clean path (no '.' or '..' elements, no repeated or trailing '/')")
}
return nil
}
// checkFilePath is checkPath for a path that names a file.
func checkFilePath(p, value string) *configgen.ValidationError {
if err := checkPath(p, value); err != nil {
return err
}
if value == "/" {
return invalidf(p, "must name a file")
}
return nil
}
// notPathChar reports runes outside pathChars.
func notPathChar(r rune) bool { return !strings.ContainsRune(pathChars, r) }
// canonicalDomain reports whether d is a valid DNS domain name in the form
// the deriving step produces (lower case, no trailing dot).
func canonicalDomain(d string) bool {
n, err := ipam.NormalizeDomain(d)
return err == nil && n == d
}
// usableBounds returns the first and last assignable addresses of an IPv4
// prefix: network and broadcast address are excluded below /31 (RFC 3021).
func usableBounds(pfx netip.Prefix) (first, last netip.Addr) {
first, last = pfx.Masked().Addr(), ipam.Broadcast(pfx)
if pfx.Bits() >= 31 {
return first, last
}
return first.Next(), last.Prev()
}
+303
View File
@@ -0,0 +1,303 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package configgen generates BIND and Kea configuration from the IPAM data
// (ADR 0014). Everything here is a pure function of its input: no database,
// no clock, no randomness. Equal input yields byte-identical output, so
// revisions can be diffed meaningfully.
//
// This package holds the input types – the contract between the parts,
// which live in sub-packages:
//
// - derive: Source → DNSConfig / DHCPConfig (zones, records, subnets);
// - bindgen: DNSConfig → BIND include files and zone files;
// - keagen: DHCPConfig → Kea DHCPv4 JSON.
//
// Renderers validate every value again before writing it; they never trust
// the deriving step. Values are written through typed writers, never through
// text templates or string concatenation of unchecked input.
package configgen
import (
"net/netip"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
)
// Artifact is one generated file.
type Artifact struct {
// Path is relative to the agent's configuration root and uses '/'.
// It never contains "..", is never absolute and never empty.
Path string
// Mode is the Unix file mode (e.g. 0o640).
Mode uint32
// Content is the complete file content.
Content []byte
// CreateOnly marks files the agent writes only if they do not exist yet
// (initial zone files of dynamic zones, which BIND owns afterwards).
CreateOnly bool
}
// ---------------------------------------------------------------------------
// DNS (BIND 9)
// ---------------------------------------------------------------------------
// DNSConfig is the complete input for one BIND server.
type DNSConfig struct {
// Serial is the SOA serial of every managed zone (the revision number,
// strictly increasing).
Serial uint32
// ZoneDir is the absolute directory of the zone files on the agent host,
// e.g. "/etc/bind/ddi-suite/zones". Zone file artifacts are written to
// "zones/<file>" relative to the configuration root, which the agent
// maps to this directory; the generated named.conf include refers to
// ZoneDir.
ZoneDir string
Options DNSOptions
// Zones in any order; renderers sort by name.
Zones []Zone
}
// DNSOptions are server-wide settings rendered into the include file.
type DNSOptions struct {
// Recursion enables recursive resolution for AllowRecursion clients.
// Default (false) is authoritative-only operation (ADR 0004).
Recursion bool
// AllowRecursion lists clients allowed to recurse (only with Recursion).
AllowRecursion []netip.Prefix
// Forwarders for recursive queries (only with Recursion).
Forwarders []netip.Addr
}
// ZoneKind distinguishes forward and reverse zones.
type ZoneKind string
// Zone kinds.
const (
ZoneForward ZoneKind = "forward"
ZoneReverse ZoneKind = "reverse"
)
// ZoneMode says who owns a zone's data (PROJECT_BRIEF §4).
type ZoneMode string
// Zone modes.
const (
// ZoneManaged zones are generated completely from the database.
ZoneManaged ZoneMode = "managed"
// ZoneDynamic zones accept RFC 2136 updates; their data belongs to BIND
// and is never overwritten (only an initial file is created).
ZoneDynamic ZoneMode = "dynamic"
)
// Zone is one authoritative zone.
type Zone struct {
// Name is the zone apex: lower case, no trailing dot, e.g.
// "office.example.com" or "2.0.192.in-addr.arpa".
Name string
Kind ZoneKind
Mode ZoneMode
// DefaultTTL is the $TTL of the zone file (seconds).
DefaultTTL uint32
SOA SOA
// NameServers are the NS records at the apex. Glue A/AAAA records are
// written for name servers inside the zone.
NameServers []NameServer
// Records of managed zones (ignored for dynamic zones except in the
// initial file).
Records []Record
// AllowUpdate lists sources allowed to send dynamic updates (dynamic
// zones only; e.g. the Active Directory domain controllers).
AllowUpdate []netip.Prefix
}
// SOA holds the SOA fields except the serial (DNSConfig.Serial).
type SOA struct {
// PrimaryNS is the MNAME (FQDN, no trailing dot).
PrimaryNS string
// Hostmaster is the RNAME in domain form, e.g. "hostmaster.example.com".
Hostmaster string
Refresh uint32
Retry uint32
Expire uint32
// Minimum is the negative-caching TTL.
Minimum uint32
}
// NameServer is an NS target with optional glue addresses.
type NameServer struct {
// Name is the FQDN of the name server, no trailing dot.
Name string
// Addrs are glue addresses, written only if Name is inside the zone.
Addrs []netip.Addr
}
// RRType is a resource record type supported by the generator.
type RRType string
// Supported record types.
const (
TypeA RRType = "A"
TypeAAAA RRType = "AAAA"
TypePTR RRType = "PTR"
TypeCNAME RRType = "CNAME"
TypeMX RRType = "MX"
TypeTXT RRType = "TXT"
TypeSRV RRType = "SRV"
TypeNS RRType = "NS"
)
// Record is one resource record of a managed zone. Only the fields used by
// its Type are set.
type Record struct {
// Name is the owner relative to the zone: "@" for the apex, otherwise one
// or more labels without the zone suffix and without trailing dot
// ("www", "_ldap._tcp", "10" in a reverse zone).
Name string
// TTL in seconds; 0 means the zone default.
TTL uint32
Type RRType
// Addr for A (IPv4) and AAAA (IPv6).
Addr netip.Addr
// Target FQDN (no trailing dot) for PTR, CNAME, MX, SRV and NS.
Target string
// Preference for MX.
Preference uint16
// Priority, Weight and Port for SRV.
Priority uint16
Weight uint16
Port uint16
// Text for TXT: the logical strings; the renderer splits each into
// character-strings of at most 255 bytes and escapes them.
Text []string
}
// ---------------------------------------------------------------------------
// DHCP (Kea DHCPv4)
// ---------------------------------------------------------------------------
// DHCPConfig is the complete input for one Kea DHCPv4 server.
type DHCPConfig struct {
// Interfaces Kea listens on; empty means all ("*").
Interfaces []string
// DirectClients selects raw sockets (needed for clients on directly
// attached networks). Without it Kea uses UDP sockets and serves only
// relayed traffic.
DirectClients bool
// ControlSocket is the Unix control socket path (Kea ≥2.6.3/3.0.1 only
// accepts paths in its socket directory, e.g. /run/kea).
ControlSocket string
// LeaseFile is the memfile lease database (must be in Kea's data
// directory, e.g. /var/lib/kea/kea-leases4.csv).
LeaseFile string
// HooksDir is the directory of the hook libraries; empty means the
// library is referenced by file name only (Kea ≥3.0 resolves it in its
// default directory).
HooksDir string
// ValidLifetime is the default lease time in seconds.
ValidLifetime uint32
// Subnets in any order; renderers sort by ID.
Subnets []Subnet
}
// Subnet is one Kea subnet4.
type Subnet struct {
// ID is stable across revisions (stored per network).
ID uint32
Prefix netip.Prefix
// Pools are the effective pools (already split around fixed entries,
// see ipam.EffectivePools).
Pools []ipam.Pool
// Routers, DNSServers, NTPServers become options 3, 6 and 42.
Routers []netip.Addr
DNSServers []netip.Addr
NTPServers []netip.Addr
// DomainName (option 15) and SearchDomains (option 119).
DomainName string
SearchDomains []string
// ValidLifetime in seconds; 0 means the global default.
ValidLifetime uint32
// RelayAddresses are giaddr values that select this subnet for relayed
// traffic (Kea "relay" → "ip-addresses"); empty means none.
RelayAddresses []netip.Addr
// Reservations from manual_dhcp entries.
Reservations []Reservation
}
// Reservation is a host reservation MAC → IP.
type Reservation struct {
// MAC in lower-case colon notation.
MAC string
IP netip.Addr
// Hostname (single label) or empty.
Hostname string
}
// ---------------------------------------------------------------------------
// Derivation input (derive.go)
// ---------------------------------------------------------------------------
// Source is everything the derivation needs, loaded from the database by the
// caller. The derivation must not modify it.
type Source struct {
// Serial becomes DNSConfig.Serial.
Serial uint32
// Networks with their pools.
Networks []ipam.Network
// Addresses of all networks.
Addresses []ipam.Address
// SubnetIDs maps network ID → stable Kea subnet ID.
SubnetIDs map[string]uint32
DNS DNSSettings
DHCP DHCPSettings
}
// DNSSettings are server-level DNS settings (until clusters arrive in M5,
// one set for all zones).
type DNSSettings struct {
ZoneDir string
Options DNSOptions
DefaultTTL uint32
// SOA is the template for every zone (PrimaryNS, Hostmaster, timers).
SOA SOA
// NameServers are the NS records of every zone.
NameServers []NameServer
}
// DHCPSettings are server-level DHCP settings copied into DHCPConfig.
type DHCPSettings struct {
Interfaces []string
DirectClients bool
ControlSocket string
LeaseFile string
HooksDir string
ValidLifetime uint32
}
// Warning reports something the derivation skipped or adjusted; the UI
// translates Code (e.g. "dns.duplicate_ptr").
type Warning struct {
Code string `json:"code"`
Params map[string]string `json:"params,omitempty"`
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
// ValidationError is returned by the renderers for input they refuse to
// write. Path locates the offending value, e.g. `zones["x.test"].records[3].target`.
type ValidationError struct {
Path string
Message string
}
func (e *ValidationError) Error() string { return e.Path + ": " + e.Message }
// ---------------------------------------------------------------------------
// Functions implemented in the sub-packages
// ---------------------------------------------------------------------------
//
// func bindgen.Render(cfg configgen.DNSConfig) ([]configgen.Artifact, error)
// func keagen.Render(cfg configgen.DHCPConfig) ([]configgen.Artifact, error)
// func derive.BuildDNS(src configgen.Source) (configgen.DNSConfig, []configgen.Warning)
// func derive.BuildDHCP(src configgen.Source) (configgen.DHCPConfig, []configgen.Warning)