Erster Schritt des golangci-lint-Rollouts (non-blocking): 44 misspell + 4 staticcheck automatisch behoben (32 Dateien, nur Tippfehler/mechanisch). build+test grün. Kein Runtime-Change → kein Deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
236 lines
6.8 KiB
Go
236 lines
6.8 KiB
Go
// Package kea renders the Kea DHCPv4 server config from the dhcp_*
|
|
// tables and manages the kea-dhcp4-server service lifecycle.
|
|
//
|
|
// The config is built as a Go struct and json-marshaled (NOT a text
|
|
// template) so the output is always syntactically valid JSON. Managed
|
|
// at /etc/edgeguard/kea/kea-dhcp4.conf (edgeguard-owned); postinst
|
|
// symlinks /etc/kea/kea-dhcp4.conf to it.
|
|
//
|
|
// Safety: the service runs ONLY when dhcp_settings.enabled is true on
|
|
// THIS node (a DHCP server is network-sensitive; default off). enabled
|
|
// → enable + restart; disabled → disable + stop.
|
|
package kea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
|
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
|
|
)
|
|
|
|
const (
|
|
ConfPath = configgen.EtcEdgeguard + "/kea/kea-dhcp4.conf"
|
|
serviceName = "kea-dhcp4-server"
|
|
leaseFile = "/var/lib/kea/kea-leases4.csv"
|
|
keaBinary = "/usr/sbin/kea-dhcp4"
|
|
)
|
|
|
|
type Generator struct {
|
|
Pool *pgxpool.Pool
|
|
Repo *dhcpsvc.Repo
|
|
SkipReload bool
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool) *Generator {
|
|
return &Generator{Pool: pool, Repo: dhcpsvc.New(pool)}
|
|
}
|
|
|
|
func (g *Generator) Name() string { return "kea" }
|
|
|
|
// ── Kea config JSON shape ────────────────────────────────────────────
|
|
|
|
type keaConfig struct {
|
|
Dhcp4 dhcp4 `json:"Dhcp4"`
|
|
}
|
|
type dhcp4 struct {
|
|
InterfacesConfig ifcfg `json:"interfaces-config"`
|
|
LeaseDatabase leaseDB `json:"lease-database"`
|
|
ValidLifetime int `json:"valid-lifetime"`
|
|
MaxValidLifetime int `json:"max-valid-lifetime"`
|
|
OptionData []optionData `json:"option-data,omitempty"`
|
|
Subnet4 []subnet4 `json:"subnet4"`
|
|
Loggers []logger `json:"loggers"`
|
|
}
|
|
type ifcfg struct {
|
|
Interfaces []string `json:"interfaces"`
|
|
}
|
|
type leaseDB struct {
|
|
Type string `json:"type"`
|
|
Persist bool `json:"persist"`
|
|
Name string `json:"name"`
|
|
}
|
|
type optionData struct {
|
|
Name string `json:"name"`
|
|
Data string `json:"data"`
|
|
}
|
|
type subnet4 struct {
|
|
ID int64 `json:"id"`
|
|
Subnet string `json:"subnet"`
|
|
Pools []pool `json:"pools,omitempty"`
|
|
OptionData []optionData `json:"option-data,omitempty"`
|
|
Reservations []reservation `json:"reservations,omitempty"`
|
|
}
|
|
type pool struct {
|
|
Pool string `json:"pool"`
|
|
}
|
|
type reservation struct {
|
|
HWAddress string `json:"hw-address"`
|
|
IPAddress string `json:"ip-address"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
}
|
|
type logger struct {
|
|
Name string `json:"name"`
|
|
Severity string `json:"severity"`
|
|
OutputOptions []outOpt `json:"output_options"`
|
|
}
|
|
type outOpt struct {
|
|
Output string `json:"output"`
|
|
}
|
|
|
|
// buildConfig assembliert die Kea-Config aus dem DB-State.
|
|
func (g *Generator) buildConfig(ctx context.Context) (*keaConfig, *bool, error) {
|
|
settings, err := g.Repo.GetSettings(ctx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("get dhcp settings: %w", err)
|
|
}
|
|
subnets, err := g.Repo.ListSubnets(ctx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("list subnets: %w", err)
|
|
}
|
|
resv, err := g.Repo.ListAllReservations(ctx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("list reservations: %w", err)
|
|
}
|
|
bySubnet := map[int64][]reservation{}
|
|
for _, r := range resv {
|
|
if !r.Active {
|
|
continue
|
|
}
|
|
bySubnet[r.SubnetID] = append(bySubnet[r.SubnetID], reservation{
|
|
HWAddress: r.MACAddress, IPAddress: r.IPAddress, Hostname: r.Hostname,
|
|
})
|
|
}
|
|
|
|
ifaceSet := map[string]bool{}
|
|
ifaces := []string{} // nie nil → JSON "[]" statt "null" (Kea lehnt null ab)
|
|
var sn4 []subnet4
|
|
|
|
for _, s := range subnets {
|
|
if !s.Active {
|
|
continue
|
|
}
|
|
if !ifaceSet[s.InterfaceName] {
|
|
ifaceSet[s.InterfaceName] = true
|
|
ifaces = append(ifaces, s.InterfaceName)
|
|
}
|
|
sub := subnet4{ID: s.ID, Subnet: s.SubnetCIDR}
|
|
if s.PoolStart != "" && s.PoolEnd != "" {
|
|
sub.Pools = []pool{{Pool: s.PoolStart + " - " + s.PoolEnd}}
|
|
}
|
|
if s.Gateway != "" {
|
|
sub.OptionData = append(sub.OptionData, optionData{Name: "routers", Data: s.Gateway})
|
|
}
|
|
dns := s.DNSServers
|
|
if dns == "" {
|
|
dns = settings.DNSServers
|
|
}
|
|
if dns != "" {
|
|
sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name-servers", Data: normalizeCSV(dns)})
|
|
}
|
|
sub.Reservations = bySubnet[s.ID]
|
|
sn4 = append(sn4, sub)
|
|
}
|
|
|
|
d := dhcp4{
|
|
InterfacesConfig: ifcfg{Interfaces: ifaces},
|
|
LeaseDatabase: leaseDB{Type: "memfile", Persist: true, Name: leaseFile},
|
|
ValidLifetime: settings.DefaultLease,
|
|
MaxValidLifetime: settings.MaxLease,
|
|
Subnet4: sn4,
|
|
Loggers: []logger{{
|
|
Name: "kea-dhcp4", Severity: "INFO",
|
|
OutputOptions: []outOpt{{Output: "stdout"}},
|
|
}},
|
|
}
|
|
if settings.DNSServers != "" {
|
|
d.OptionData = append(d.OptionData, optionData{Name: "domain-name-servers", Data: normalizeCSV(settings.DNSServers)})
|
|
}
|
|
if settings.DomainName != "" {
|
|
d.OptionData = append(d.OptionData, optionData{Name: "domain-name", Data: settings.DomainName})
|
|
}
|
|
if d.Subnet4 == nil {
|
|
d.Subnet4 = []subnet4{}
|
|
}
|
|
return &keaConfig{Dhcp4: d}, &settings.Enabled, nil
|
|
}
|
|
|
|
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
|
cfg, _, err := g.buildConfig(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b) + "\n", nil
|
|
}
|
|
|
|
func (g *Generator) Render(ctx context.Context) error {
|
|
cfg, enabled, err := g.buildConfig(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Default-off / disabled: Service stoppen + disablen, nichts weiter.
|
|
if enabled == nil || !*enabled {
|
|
if g.SkipReload {
|
|
return nil
|
|
}
|
|
_ = configgen.DisableService(serviceName)
|
|
_ = configgen.StopService(serviceName)
|
|
return nil
|
|
}
|
|
|
|
b, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := configgen.AtomicWrite(ConfPath, append(b, '\n'), 0o644); err != nil {
|
|
return fmt.Errorf("write kea config: %w", err)
|
|
}
|
|
if g.SkipReload {
|
|
return nil
|
|
}
|
|
// Best-effort Config-Test (verhindert Restart mit kaputter Semantik).
|
|
if _, statErr := os.Stat(keaBinary); statErr == nil {
|
|
if out, terr := exec.Command(keaBinary, "-t", ConfPath).CombinedOutput(); terr != nil {
|
|
return fmt.Errorf("kea-dhcp4 -t rejected config: %w (output: %s)", terr, strings.TrimSpace(string(out)))
|
|
}
|
|
}
|
|
if err := configgen.EnableService(serviceName); err != nil {
|
|
return err
|
|
}
|
|
return configgen.RestartService(serviceName)
|
|
}
|
|
|
|
// normalizeCSV trimmt Whitespace um Komma-getrennte Werte (Kea will
|
|
// "a,b,c" ohne Leerzeichen-Toleranz-Probleme).
|
|
func normalizeCSV(s string) string {
|
|
parts := strings.Split(s, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return strings.Join(out, ",")
|
|
}
|