Verifizierte Bugs aus dem Code-Audit behoben (je mit Test/Build/nft -c geprüft):
- session: IssueWithRoleTTL mutierte geteiltes s.TTL (Data-Race + falsche TTL) → interne issue(); -race-Test.
- auth: Fallback/Federation leiteten role/TOTP nicht aus DB ab (2FA-Bypass auf Secondary, Rolle aus Remote) → viaDB-Flag + DB-Re-Lookup.
- waf: TrustedProxies waren No-op (bogus-Direktive) → XFF-Auflösung im SPOE-Agent (rightmostXFF/ipMatchesAny); RuleExclusions/TrustedProxies validiert (Direktiven-Injection); GetForHost via net.SplitHostPort.
- firewall: Auto-Rule mit IPv6-DstIP erzeugte 'ip daddr <v6>' → bricht ganzes nft-Ruleset; jetzt familienbewusst (ip/ip6, ungültige raus).
- kea: 'interfaces': null bei 0 Subnets → leeres Array.
- cluster_repair: nodeHasPublication schluckte DB-Fehler (Resync auf falschem Node) → (bool,error) fail-closed; IPv6-Primary-URL via net.JoinHostPort.
- cluster_replication: Replikations-Passwort via stdin statt psql -c (nicht mehr in argv/Logs).
- wireguard: Config (Private Key) jetzt configgen.AtomicWrite VOR Symlink/enable; SkipReload-Feld.
- render.go: --no-reload jetzt für alle Renderer (squid/unbound/chrony/wireguard).
- radius: leeres Secret/Passwort + Newlines abgelehnt; freeradius confEscape strippt CR/LF.
- configorch: continue-on-error + errors.Join statt Abbruch mitten in der Sequenz.
- i18n: fehlender Key common.status (de/en).
Verworfen als kein Bug: WAF detection-'blocked' (DetectionOnly liefert keine Interruption), render secrets.New('') (nutzt Default-Masterkey), FanOut-Sort (nur Kommentar), pg_hba (durch nft abgesichert).
Offen/bewusst zurückgestellt (low/risk): AlertWriter-Close (langlebiger Worker, vernachlässigbar), Rolling-Update-Kleinkram (sudoers-gebundener Script-Pfad / GET-State).
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-marshalled (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, ",")
|
|
}
|