Files
DDI/server/internal/service/generator.go
T

329 lines
11 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package service
import (
"context"
"errors"
"net/netip"
"path"
"regexp"
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
// GeneratorSettings are the server-level inputs of the configuration
// generator that are not part of the IPAM data (until DNS/DHCP clusters
// arrive in M5, one set applies to all zones and subnets).
type GeneratorSettings struct {
DNS GeneratorDNS `json:"dns"`
DHCP GeneratorDHCP `json:"dhcp"`
}
// GeneratorDNS holds SOA/NS data and resolver options.
type GeneratorDNS struct {
// PrimaryNS is the MNAME of every zone (FQDN).
PrimaryNS string `json:"primary_ns"`
// Hostmaster is the responsible mailbox, e.g. "hostmaster@example.com"
// or "hostmaster.example.com".
Hostmaster string `json:"hostmaster"`
NameServers []GeneratorNS `json:"name_servers"`
DefaultTTL uint32 `json:"default_ttl"`
Refresh uint32 `json:"refresh"`
Retry uint32 `json:"retry"`
Expire uint32 `json:"expire"`
Minimum uint32 `json:"minimum"`
Recursion bool `json:"recursion"`
AllowRecursion []string `json:"allow_recursion"`
Forwarders []string `json:"forwarders"`
// ZoneDir is the directory on the DNS hosts that holds the zone files
// (must match the DNS agent configuration).
ZoneDir string `json:"zone_dir"`
// DefaultClusterID is the DNS cluster of zones without own cluster;
// nil for the default group (ADR 0023).
DefaultClusterID *string `json:"default_cluster_id"`
}
// GeneratorNS is a name server with optional glue addresses.
type GeneratorNS struct {
Name string `json:"name"`
Addresses []string `json:"addresses"`
}
// GeneratorDHCP holds Kea defaults.
type GeneratorDHCP struct {
Interfaces []string `json:"interfaces"`
DirectClients bool `json:"direct_clients"`
ValidLifetime uint32 `json:"valid_lifetime"`
// Paths on the DHCP hosts. Kea 3.x only accepts the control socket in
// its socket directory and the lease file in its data directory.
ControlSocket string `json:"control_socket"`
LeaseFile string `json:"lease_file"`
// HooksDir is empty to load hooks from the directory Kea was built with.
HooksDir string `json:"hooks_dir"`
// DefaultClusterID is the DHCP cluster of networks without own
// cluster; nil for the default group (ADR 0023).
DefaultClusterID *string `json:"default_cluster_id"`
// HAPasswordFile is the path of the Kea HA password file.
HAPasswordFile string `json:"ha_password_file"`
}
// DefaultHAPasswordFile is the default path of the Kea HA password file.
const DefaultHAPasswordFile = "/etc/kea/ddi-suite-ha-password" //nolint:gosec // a path, not a credential
// DefaultGeneratorSettings are used until an administrator saves settings.
func DefaultGeneratorSettings() GeneratorSettings {
return GeneratorSettings{
DNS: GeneratorDNS{
NameServers: []GeneratorNS{}, DefaultTTL: 3600, Refresh: 3600, Retry: 600, Expire: 1209600, Minimum: 300,
AllowRecursion: []string{}, Forwarders: []string{}, ZoneDir: "/var/lib/bind/ddi-suite",
},
DHCP: GeneratorDHCP{
Interfaces: []string{"*"}, DirectClients: true, ValidLifetime: 3600,
ControlSocket: "/run/kea/kea4-ctrl-socket", LeaseFile: "/var/lib/kea/kea-leases4.csv",
HAPasswordFile: DefaultHAPasswordFile,
},
}
}
// GetGeneratorSettings returns the stored settings or the defaults.
func (s *Service) GetGeneratorSettings(ctx context.Context) (GeneratorSettings, error) {
return s.generatorSettings(ctx, s.st.Q())
}
// generatorSettings reads the settings within tx. Settings stored by an
// older version get the defaults of new fields.
func (s *Service) generatorSettings(ctx context.Context, tx *store.Tx) (GeneratorSettings, error) {
cfg := DefaultGeneratorSettings()
err := tx.GetSetting(ctx, store.SettingGenerator, &cfg)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return GeneratorSettings{}, translate(err)
}
if cfg.DHCP.HAPasswordFile == "" {
cfg.DHCP.HAPasswordFile = DefaultHAPasswordFile
}
return cfg, nil
}
var interfaceName = regexp.MustCompile(`^[A-Za-z0-9._:@*-]{1,64}$`)
// hostPath matches the absolute paths the generators accept.
var hostPath = regexp.MustCompile(`^(/[A-Za-z0-9._+@-]+)+$`)
// validHostPath reports whether p is a clean absolute path the BIND and Kea
// generators can render (socket paths are limited by sun_path).
func validHostPath(p string, maxLen int) bool {
return len(p) <= maxLen && hostPath.MatchString(p) && path.Clean(p) == p
}
// normalizeGenerator validates settings in place.
func normalizeGenerator(g *GeneratorSettings) *Error {
var problems []Problem
add := func(code, field string) { problems = append(problems, Problem{Code: code, Field: field}) }
d := &g.DNS
if d.PrimaryNS != "" {
n, err := ipam.NormalizeDomain(d.PrimaryNS)
if err != nil {
add("invalid_domain", "dns.primary_ns")
}
d.PrimaryNS = n
}
if d.Hostmaster != "" {
hm := strings.TrimSpace(d.Hostmaster)
// Accept the mail form and convert it: the local part becomes the
// first label (dots in it are not supported).
if local, domain, ok := strings.Cut(hm, "@"); ok {
if strings.Contains(local, ".") {
add("invalid_hostmaster", "dns.hostmaster")
}
hm = local + "." + domain
}
n, err := ipam.NormalizeDomain(hm)
if err != nil {
add("invalid_hostmaster", "dns.hostmaster")
}
d.Hostmaster = n
}
for i := range d.NameServers {
n, err := ipam.NormalizeDomain(d.NameServers[i].Name)
if err != nil {
add("invalid_domain", "dns.name_servers")
break
}
d.NameServers[i].Name = n
for _, a := range d.NameServers[i].Addresses {
if _, err := netip.ParseAddr(a); err != nil {
add("invalid_ip", "dns.name_servers")
break
}
}
}
if d.DefaultTTL == 0 || d.DefaultTTL > 2147483647 {
add("invalid_ttl", "dns.default_ttl")
}
for field, v := range map[string]uint32{"dns.refresh": d.Refresh, "dns.retry": d.Retry, "dns.expire": d.Expire, "dns.minimum": d.Minimum} {
if v == 0 || v > 2147483647 {
add("invalid_ttl", field)
}
}
for _, p := range d.AllowRecursion {
if pf, err := netip.ParsePrefix(p); err != nil || pf.Masked() != pf {
add("invalid_cidr", "dns.allow_recursion")
break
}
}
for _, f := range d.Forwarders {
if _, err := netip.ParseAddr(f); err != nil {
add("invalid_ip", "dns.forwarders")
break
}
}
if len(g.DHCP.Interfaces) == 0 {
g.DHCP.Interfaces = []string{"*"}
}
for _, i := range g.DHCP.Interfaces {
if !interfaceName.MatchString(i) {
add("invalid_interface", "dhcp.interfaces")
break
}
}
if g.DHCP.ValidLifetime < 60 || g.DHCP.ValidLifetime > 31536000 {
add("lease_time_range", "dhcp.valid_lifetime")
}
if !validHostPath(d.ZoneDir, 255) {
add("invalid_path", "dns.zone_dir")
}
if !validHostPath(g.DHCP.ControlSocket, 100) {
add("invalid_path", "dhcp.control_socket")
}
if !validHostPath(g.DHCP.LeaseFile, 255) {
add("invalid_path", "dhcp.lease_file")
}
if g.DHCP.HooksDir != "" && !validHostPath(g.DHCP.HooksDir, 255) {
add("invalid_path", "dhcp.hooks_dir")
}
if g.DHCP.HAPasswordFile == "" {
g.DHCP.HAPasswordFile = DefaultHAPasswordFile
}
if !validHostPath(g.DHCP.HAPasswordFile, 255) {
add("invalid_path", "dhcp.ha_password_file")
}
// An empty ID means the default group, like null.
for _, id := range []**string{&d.DefaultClusterID, &g.DHCP.DefaultClusterID} {
if *id != nil && **id == "" {
*id = nil
}
}
if d.NameServers == nil {
d.NameServers = []GeneratorNS{}
}
if d.AllowRecursion == nil {
d.AllowRecursion = []string{}
}
if d.Forwarders == nil {
d.Forwarders = []string{}
}
if len(problems) > 0 {
return invalid(problems...)
}
return nil
}
// SetGeneratorSettings validates and stores the settings.
func (s *Service) SetGeneratorSettings(ctx context.Context, a *Actor, g GeneratorSettings) (GeneratorSettings, error) {
if err := s.requireWritable(); err != nil {
return GeneratorSettings{}, err
}
if e := normalizeGenerator(&g); e != nil {
return GeneratorSettings{}, e
}
err := s.inTx(ctx, func(tx *store.Tx) error {
for _, c := range []struct {
id *string
role, field string
}{{g.DNS.DefaultClusterID, RoleDNS, "dns.default_cluster_id"}, {g.DHCP.DefaultClusterID, RoleDHCP, "dhcp.default_cluster_id"}} {
if c.id == nil {
continue
}
if err := requireCluster(ctx, tx, *c.id, c.role, c.field); err != nil {
return err
}
}
if err := tx.LockGeneratorSettings(ctx); err != nil {
return err
}
before, err := s.generatorSettings(ctx, tx)
if err != nil {
return err
}
if err := tx.SetSetting(ctx, store.SettingGenerator, g); err != nil {
return err
}
return s.audit(ctx, tx, a, "settings.generator", "settings", "generator", "Generator", before, g)
})
if err != nil {
return GeneratorSettings{}, err
}
return g, nil
}
// dnsSettings converts the settings into generator input (parsing is safe:
// values were validated when they were stored).
func (g GeneratorSettings) dnsSettings() configgen.DNSSettings {
d := g.DNS
out := configgen.DNSSettings{
DefaultClusterID: deref(d.DefaultClusterID),
ZoneDir: d.ZoneDir,
DefaultTTL: d.DefaultTTL,
SOA: configgen.SOA{PrimaryNS: d.PrimaryNS, Hostmaster: d.Hostmaster, Refresh: d.Refresh, Retry: d.Retry,
Expire: d.Expire, Minimum: d.Minimum},
Options: configgen.DNSOptions{Recursion: d.Recursion},
}
for _, ns := range d.NameServers {
out.NameServers = append(out.NameServers, nameServer(ns))
}
for _, p := range d.AllowRecursion {
if pf, err := netip.ParsePrefix(p); err == nil {
out.Options.AllowRecursion = append(out.Options.AllowRecursion, pf)
}
}
for _, f := range d.Forwarders {
if ip, err := netip.ParseAddr(f); err == nil {
out.Options.Forwarders = append(out.Options.Forwarders, ip.Unmap())
}
}
return out
}
// nameServer converts a validated name server into generator input.
func nameServer(ns GeneratorNS) configgen.NameServer {
n := configgen.NameServer{Name: ns.Name}
for _, a := range ns.Addresses {
if ip, err := netip.ParseAddr(a); err == nil {
n.Addrs = append(n.Addrs, ip.Unmap())
}
}
return n
}
// dhcpSettings converts the settings into generator input.
func (g GeneratorSettings) dhcpSettings() configgen.DHCPSettings {
d := g.DHCP
return configgen.DHCPSettings{
DefaultClusterID: deref(d.DefaultClusterID), Interfaces: d.Interfaces, DirectClients: d.DirectClients,
ControlSocket: d.ControlSocket, LeaseFile: d.LeaseFile, HooksDir: d.HooksDir, HAPasswordFile: d.HAPasswordFile,
ValidLifetime: d.ValidLifetime,
}
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}