diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 24966c6..1c77c23 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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) diff --git a/.golangci.yml b/.golangci.yml index d7529cb..2dfa6cf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,6 +3,8 @@ version: "2" run: timeout: 5m + build-tags: + - integration linters: default: standard # errcheck, govet, ineffassign, staticcheck, unused diff --git a/Makefile b/Makefile index e5c9b95..22c0981 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/docs/adr/0004-dns-engine-bind9.md b/docs/adr/0004-dns-engine-bind9.md index e898907..bee7ca0 100644 --- a/docs/adr/0004-dns-engine-bind9.md +++ b/docs/adr/0004-dns-engine-bind9.md @@ -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. diff --git a/docs/adr/0014-konfigurationserzeugung.md b/docs/adr/0014-konfigurationserzeugung.md index 264b5d8..d085b62 100644 --- a/docs/adr/0014-konfigurationserzeugung.md +++ b/docs/adr/0014-konfigurationserzeugung.md @@ -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. diff --git a/server/internal/configgen/bindgen/bindgen.go b/server/internal/configgen/bindgen/bindgen.go new file mode 100644 index 0000000..ae36d3c --- /dev/null +++ b/server/internal/configgen/bindgen/bindgen.go @@ -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 } diff --git a/server/internal/configgen/bindgen/bindgen_test.go b/server/internal/configgen/bindgen/bindgen_test.go new file mode 100644 index 0000000..eaef4f0 --- /dev/null +++ b/server/internal/configgen/bindgen/bindgen_test.go @@ -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) + } + } + }) +} diff --git a/server/internal/configgen/bindgen/namedconf.go b/server/internal/configgen/bindgen/namedconf.go new file mode 100644 index 0000000..37ea746 --- /dev/null +++ b/server/internal/configgen/bindgen/namedconf.go @@ -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() +} diff --git a/server/internal/configgen/bindgen/names.go b/server/internal/configgen/bindgen/names.go new file mode 100644 index 0000000..4812acb --- /dev/null +++ b/server/internal/configgen/bindgen/names.go @@ -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)) +} diff --git a/server/internal/configgen/bindgen/testdata/dynamic/db.dyn.test.golden b/server/internal/configgen/bindgen/testdata/dynamic/db.dyn.test.golden new file mode 100644 index 0000000..187a1fb --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/dynamic/db.dyn.test.golden @@ -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 diff --git a/server/internal/configgen/bindgen/testdata/dynamic/db.static.test.golden b/server/internal/configgen/bindgen/testdata/dynamic/db.static.test.golden new file mode 100644 index 0000000..8fc4f0d --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/dynamic/db.static.test.golden @@ -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. diff --git a/server/internal/configgen/bindgen/testdata/dynamic/ddi-suite-options.conf.golden b/server/internal/configgen/bindgen/testdata/dynamic/ddi-suite-options.conf.golden new file mode 100644 index 0000000..a8be196 --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/dynamic/ddi-suite-options.conf.golden @@ -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; }; diff --git a/server/internal/configgen/bindgen/testdata/dynamic/ddi-suite.conf.golden b/server/internal/configgen/bindgen/testdata/dynamic/ddi-suite.conf.golden new file mode 100644 index 0000000..5501ac3 --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/dynamic/ddi-suite.conf.golden @@ -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; }; +}; diff --git a/server/internal/configgen/bindgen/testdata/forward/db.office.test.golden b/server/internal/configgen/bindgen/testdata/forward/db.office.test.golden new file mode 100644 index 0000000..c114a81 --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/forward/db.office.test.golden @@ -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 diff --git a/server/internal/configgen/bindgen/testdata/forward/ddi-suite-options.conf.golden b/server/internal/configgen/bindgen/testdata/forward/ddi-suite-options.conf.golden new file mode 100644 index 0000000..5b97b80 --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/forward/ddi-suite-options.conf.golden @@ -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; }; diff --git a/server/internal/configgen/bindgen/testdata/forward/ddi-suite.conf.golden b/server/internal/configgen/bindgen/testdata/forward/ddi-suite.conf.golden new file mode 100644 index 0000000..d735b75 --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/forward/ddi-suite.conf.golden @@ -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; }; +}; diff --git a/server/internal/configgen/bindgen/testdata/recursion/ddi-suite-options.conf.golden b/server/internal/configgen/bindgen/testdata/recursion/ddi-suite-options.conf.golden new file mode 100644 index 0000000..9f03dcf --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/recursion/ddi-suite-options.conf.golden @@ -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; }; diff --git a/server/internal/configgen/bindgen/testdata/recursion/ddi-suite.conf.golden b/server/internal/configgen/bindgen/testdata/recursion/ddi-suite.conf.golden new file mode 100644 index 0000000..96ec0ec --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/recursion/ddi-suite.conf.golden @@ -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. diff --git a/server/internal/configgen/bindgen/testdata/reverse/db.2.0.192.in-addr.arpa.golden b/server/internal/configgen/bindgen/testdata/reverse/db.2.0.192.in-addr.arpa.golden new file mode 100644 index 0000000..ee45c66 --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/reverse/db.2.0.192.in-addr.arpa.golden @@ -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. diff --git a/server/internal/configgen/bindgen/testdata/reverse/db.8.b.d.0.1.0.0.2.ip6.arpa.golden b/server/internal/configgen/bindgen/testdata/reverse/db.8.b.d.0.1.0.0.2.ip6.arpa.golden new file mode 100644 index 0000000..0b058ff --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/reverse/db.8.b.d.0.1.0.0.2.ip6.arpa.golden @@ -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. diff --git a/server/internal/configgen/bindgen/testdata/reverse/ddi-suite-options.conf.golden b/server/internal/configgen/bindgen/testdata/reverse/ddi-suite-options.conf.golden new file mode 100644 index 0000000..5b97b80 --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/reverse/ddi-suite-options.conf.golden @@ -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; }; diff --git a/server/internal/configgen/bindgen/testdata/reverse/ddi-suite.conf.golden b/server/internal/configgen/bindgen/testdata/reverse/ddi-suite.conf.golden new file mode 100644 index 0000000..93958a7 --- /dev/null +++ b/server/internal/configgen/bindgen/testdata/reverse/ddi-suite.conf.golden @@ -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; }; +}; diff --git a/server/internal/configgen/bindgen/validate.go b/server/internal/configgen/bindgen/validate.go new file mode 100644 index 0000000..7f2e379 --- /dev/null +++ b/server/internal/configgen/bindgen/validate.go @@ -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() }) +} diff --git a/server/internal/configgen/bindgen/zonefile.go b/server/internal/configgen/bindgen/zonefile.go new file mode 100644 index 0000000..ad06fbd --- /dev/null +++ b/server/internal/configgen/bindgen/zonefile.go @@ -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 (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('"') +} diff --git a/server/internal/configgen/derive/derive.go b/server/internal/configgen/derive/derive.go new file mode 100644 index 0000000..a12f2e8 --- /dev/null +++ b/server/internal/configgen/derive/derive.go @@ -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() +} diff --git a/server/internal/configgen/derive/derive_test.go b/server/internal/configgen/derive/derive_test.go new file mode 100644 index 0000000..48b91b4 --- /dev/null +++ b/server/internal/configgen/derive/derive_test.go @@ -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") + } +} diff --git a/server/internal/configgen/derive/dhcp.go b/server/internal/configgen/derive/dhcp.go new file mode 100644 index 0000000..0c2f696 --- /dev/null +++ b/server/internal/configgen/derive/dhcp.go @@ -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() +} diff --git a/server/internal/configgen/derive/dns.go b/server/internal/configgen/derive/dns.go new file mode 100644 index 0000000..765d5fb --- /dev/null +++ b/server/internal/configgen/derive/dns.go @@ -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 +} diff --git a/server/internal/configgen/derive/reverse.go b/server/internal/configgen/derive/reverse.go new file mode 100644 index 0000000..aeb6c98 --- /dev/null +++ b/server/internal/configgen/derive/reverse.go @@ -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 +} diff --git a/server/internal/configgen/integration/integration_test.go b/server/internal/configgen/integration/integration_test.go new file mode 100644 index 0000000..b2d0df5 --- /dev/null +++ b/server/internal/configgen/integration/integration_test.go @@ -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) + } +} diff --git a/server/internal/configgen/keagen/keagen.go b/server/internal/configgen/keagen/keagen.go new file mode 100644 index 0000000..f34c580 --- /dev/null +++ b/server/internal/configgen/keagen/keagen.go @@ -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 +} diff --git a/server/internal/configgen/keagen/keagen_test.go b/server/internal/configgen/keagen/keagen_test.go new file mode 100644 index 0000000..5cbbe1b --- /dev/null +++ b/server/internal/configgen/keagen/keagen_test.go @@ -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("