Files
DDI/server/internal/configgen/bindgen/validate.go
T
sir_melonchenandClaude Opus 5.5 f037675596 feat(bindgen): TSIG keys file, primary/secondary zones and update keys
Render always writes the secret ddi-suite-keys.conf (only a comment
without keys). Primary zones of a cluster get "notify explicit",
"also-notify" and "allow-transfer" with the transfer key; secondary
zones transfer from their primaries with the key into
<zone_dir>/secondary/ and have no artifact. Dynamic zones accept the
update keys in their ACL. Keys are validated (name, algorithm
whitelist, canonical base64) and every referenced key must exist.

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

564 lines
17 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package bindgen
import (
"cmp"
"encoding/base64"
"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
keys []configgen.TSIGKey // sorted by name
}
// remote is a validated server of the cluster.
type remote struct {
addr netip.Addr
key string // "" for none
}
// 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
// secondary zones use only name and primaries.
secondary bool
primaries []remote // sorted by address
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
updateKeys []string // sorted, without duplicates
alsoNotify []remote // sorted by address
transferKey string
}
// 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
}
keys, err := validateKeys(cfg.Keys)
if err != nil {
return nil, err
}
m := &model{serial: cfg.Serial, zoneDir: cfg.ZoneDir, options: opts, keys: keys}
known := make(map[string]bool, len(keys))
for _, k := range keys {
known[k.Name] = true
}
seen := make(map[string]bool, len(cfg.Zones))
for i, z := range cfg.Zones {
zd, err := validateZone(i, z, known)
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, keys are the
// names of the keys in DNSConfig.Keys.
func validateZone(i int, z configgen.Zone, keys map[string]bool) (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")
}
switch z.Type {
case "", configgen.ZonePrimary:
case configgen.ZoneSecondary:
if len(z.Primaries) == 0 {
return zoneData{}, invalid(p+".primaries", "a secondary zone needs at least one primary")
}
primaries, err := checkRemotes(p+".primaries", z.Primaries, keys)
if err != nil {
return zoneData{}, err
}
return zoneData{name: z.Name, secondary: true, primaries: primaries}, nil
default:
return zoneData{}, invalid(p+".type", "must be primary or secondary")
}
if len(z.Primaries) > 0 {
return zoneData{}, invalid(p+".primaries", "only a secondary zone has primaries")
}
zd := zoneData{name: z.Name, defaultTTL: z.DefaultTTL, soa: z.SOA}
alsoNotify, err := checkRemotes(p+".also_notify", z.AlsoNotify, keys)
if err != nil {
return zoneData{}, err
}
zd.alsoNotify = alsoNotify
if z.TransferKey != "" {
if !keys[z.TransferKey] {
return zoneData{}, invalid(p+".transfer_key", "names no key of the configuration")
}
zd.transferKey = z.TransferKey
}
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 && len(z.UpdateKeys) == 0 {
return zoneData{}, invalid(p+".allow_update", "a dynamic zone must allow updates from at least one prefix or key")
}
allow, err := checkPrefixes(p+".allow_update", z.AllowUpdate)
if err != nil {
return zoneData{}, err
}
zd.allowUpdate = allow
for j, k := range z.UpdateKeys {
if !keys[k] {
return zoneData{}, invalid(fmt.Sprintf("%s.update_keys[%d]", p, j), "names no key of the configuration")
}
}
zd.updateKeys = slices.Compact(slices.Sorted(slices.Values(z.UpdateKeys)))
}
return zd, nil
}
// checkRemotes validates the servers of a primaries or also-notify list and
// returns them sorted by address.
func checkRemotes(p string, in []configgen.Remote, keys map[string]bool) ([]remote, error) {
out := make([]remote, 0, len(in))
for i, r := range in {
rp := fmt.Sprintf("%s[%d]", p, i)
if err := checkHostAddr(rp+".addr", r.Addr); err != nil {
return nil, err
}
if slices.ContainsFunc(in[:i], func(o configgen.Remote) bool { return o.Addr == r.Addr }) {
return nil, invalid(rp+".addr", "duplicate server")
}
if r.Key != "" && !keys[r.Key] {
return nil, invalid(rp+".key", "names no key of the configuration")
}
out = append(out, remote{addr: r.Addr, key: r.Key})
}
slices.SortFunc(out, func(a, b remote) int { return a.addr.Compare(b.addr) })
return out, nil
}
// keyAlgorithms are the TSIG algorithms the generator writes (ADR 0023).
var keyAlgorithms = []string{"hmac-sha256", "hmac-sha384", "hmac-sha512"}
// validateKeys checks the TSIG keys and returns them sorted by name.
func validateKeys(in []configgen.TSIGKey) ([]configgen.TSIGKey, error) {
for i, k := range in {
p := fmt.Sprintf("keys[%d]", i)
if msg := checkName(k.Name, false); msg != "" {
return nil, invalid(p+".name", msg)
}
if slices.ContainsFunc(in[:i], func(o configgen.TSIGKey) bool { return o.Name == k.Name }) {
return nil, invalid(p+".name", "duplicate key")
}
if !slices.Contains(keyAlgorithms, k.Algorithm) {
return nil, invalid(p+".algorithm", "must be one of "+strings.Join(keyAlgorithms, ", "))
}
// The canonical standard encoding only: its alphabet cannot leave
// the quoted string, and BIND decodes it.
raw, err := base64.StdEncoding.Strict().DecodeString(k.Secret)
if err != nil || len(raw) == 0 || base64.StdEncoding.EncodeToString(raw) != k.Secret {
return nil, invalid(p+".secret", "must be non-empty standard base64")
}
}
out := slices.Clone(in)
slices.SortFunc(out, func(a, b configgen.TSIGKey) int { return strings.Compare(a.Name, b.Name) })
return out, 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() })
}