329 lines
11 KiB
Go
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
|
|
}
|