feat(dhcp): DHCPv4-Server via Kea (kea-dhcp4-server) — v1.2.92
Verwalteter DHCPv4-Server analog Unbound/Squid/Chrony. - Migration 0041: dhcp_settings (singleton, node-lokal), dhcp_subnets, dhcp_reservations. - internal/kea: Renderer baut Kea-JSON via Go-Struct→Marshal (garantiert valide), managed /etc/edgeguard/kea/kea-dhcp4.conf (Symlink von /etc/kea), Service-Lifecycle an enabled gekoppelt (default AUS, kein rogue DHCP). Interface per NAME (cluster-sicher, kein node-lokaler FK). - internal/services/dhcp + internal/handlers/dhcp.go: Settings + Subnet/Reservation-CRUD, Validierung (CIDR/IP/MAC/interface exists). - configgen: Stop/Enable/DisableService. Firewall: AutoFWRule.Iface → udp/67 pro LAN-Interface gescopt (kein WAN). Cluster: subnets/reservations repliziert (hashSpec), dhcp_settings node-lokal (localOnlyTables). - main.go + render.go + WithAllReloaders Wiring. Packaging: kea-dhcp4-server Dependency, /etc/edgeguard/kea Dir, Symlink, disable-on-install, sudoers (restart/stop/enable/disable). - UI: DHCP-Seite (Settings + Subnets + Reservierungen pro Subnet), Route/Nav/i18n de/en, HA-Warnung 'nur auf einer Node aktivieren'. - Tests (guarded EG_FWTEST_DSN): Kea-Renderer gegen DB (valides JSON + Felder), FW-Auto-Rule-Iface inkl. nft -c. Scope v1: DHCPv4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
346
internal/handlers/dhcp.go
Normal file
346
internal/handlers/dhcp.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
|
||||
)
|
||||
|
||||
// DHCPHandler exposes /api/v1/dhcp/{settings,subnets,reservations} for
|
||||
// the Kea DHCPv4 server.
|
||||
type DHCPHandler struct {
|
||||
Repo *dhcpsvc.Repo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Reloader func(ctx context.Context) error
|
||||
}
|
||||
|
||||
func NewDHCPHandler(repo *dhcpsvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DHCPHandler {
|
||||
return &DHCPHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) reload(ctx context.Context, op string) {
|
||||
if h.Reloader == nil {
|
||||
return
|
||||
}
|
||||
if err := h.Reloader(ctx); err != nil {
|
||||
slog.Warn("kea: reload after mutation failed", "op", op, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/dhcp")
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
|
||||
s := g.Group("/subnets")
|
||||
s.GET("", h.ListSubnets)
|
||||
s.POST("", h.CreateSubnet)
|
||||
s.GET("/:id", h.GetSubnet)
|
||||
s.PUT("/:id", h.UpdateSubnet)
|
||||
s.DELETE("/:id", h.DeleteSubnet)
|
||||
s.GET("/:id/reservations", h.ListReservationsForSubnet)
|
||||
s.POST("/:id/reservations", h.CreateReservation)
|
||||
|
||||
r := g.Group("/reservations")
|
||||
r.GET("", h.ListAllReservations)
|
||||
r.GET("/:id", h.GetReservation)
|
||||
r.PUT("/:id", h.UpdateReservation)
|
||||
r.DELETE("/:id", h.DeleteReservation)
|
||||
}
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
|
||||
func (h *DHCPHandler) GetSettings(c *gin.Context) {
|
||||
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, s)
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) UpdateSettings(c *gin.Context) {
|
||||
var req models.DHCPSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.DefaultLease <= 0 {
|
||||
req.DefaultLease = 3600
|
||||
}
|
||||
if req.MaxLease < req.DefaultLease {
|
||||
req.MaxLease = req.DefaultLease
|
||||
}
|
||||
if err := validateIPList(req.DNSServers); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.settings.update", "",
|
||||
gin.H{"enabled": out.Enabled}, h.NodeID)
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "settings.update")
|
||||
}
|
||||
|
||||
// ── Subnets ──────────────────────────────────────────────────────────
|
||||
|
||||
func (h *DHCPHandler) ListSubnets(c *gin.Context) {
|
||||
out, err := h.Repo.ListSubnets(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"subnets": out})
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) GetSubnet(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s, err := h.Repo.GetSubnet(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.subnetErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, s)
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) CreateSubnet(c *gin.Context) {
|
||||
var req models.DHCPSubnet
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.validateSubnet(c, &req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.CreateSubnet(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.create", out.Name, out, h.NodeID)
|
||||
response.Created(c, out)
|
||||
h.reload(c.Request.Context(), "subnet.create")
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) UpdateSubnet(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req models.DHCPSubnet
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.validateSubnet(c, &req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateSubnet(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
h.subnetErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.update", out.Name, out, h.NodeID)
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "subnet.update")
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) DeleteSubnet(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Repo.DeleteSubnet(c.Request.Context(), id); err != nil {
|
||||
h.subnetErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.delete", "", gin.H{"id": id}, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
h.reload(c.Request.Context(), "subnet.delete")
|
||||
}
|
||||
|
||||
// ── Reservations ─────────────────────────────────────────────────────
|
||||
|
||||
func (h *DHCPHandler) ListAllReservations(c *gin.Context) {
|
||||
out, err := h.Repo.ListAllReservations(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"reservations": out})
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) ListReservationsForSubnet(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.ListReservationsForSubnet(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"reservations": out})
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) GetReservation(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, err := h.Repo.GetReservation(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.resvErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, v)
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) CreateReservation(c *gin.Context) {
|
||||
subnetID, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req models.DHCPReservation
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
req.SubnetID = subnetID
|
||||
if err := validateReservation(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.CreateReservation(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.create", out.MACAddress, out, h.NodeID)
|
||||
response.Created(c, out)
|
||||
h.reload(c.Request.Context(), "reservation.create")
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) UpdateReservation(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req models.DHCPReservation
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateReservation(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateReservation(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
h.resvErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.update", out.MACAddress, out, h.NodeID)
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "reservation.update")
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) DeleteReservation(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Repo.DeleteReservation(c.Request.Context(), id); err != nil {
|
||||
h.resvErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.delete", "", gin.H{"id": id}, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
h.reload(c.Request.Context(), "reservation.delete")
|
||||
}
|
||||
|
||||
// ── Validation + error mapping ───────────────────────────────────────
|
||||
|
||||
func (h *DHCPHandler) validateSubnet(c *gin.Context, s *models.DHCPSubnet) error {
|
||||
s.Name = strings.TrimSpace(s.Name)
|
||||
s.InterfaceName = strings.TrimSpace(s.InterfaceName)
|
||||
if s.Name == "" {
|
||||
return errors.New("name ist erforderlich")
|
||||
}
|
||||
if s.InterfaceName == "" {
|
||||
return errors.New("interface_name ist erforderlich")
|
||||
}
|
||||
if exists, err := h.Repo.InterfaceExists(c.Request.Context(), s.InterfaceName); err == nil && !exists {
|
||||
return errors.New("interface_name existiert nicht: " + s.InterfaceName)
|
||||
}
|
||||
if _, _, err := net.ParseCIDR(s.SubnetCIDR); err != nil {
|
||||
return errors.New("subnet_cidr ist kein gültiges CIDR: " + s.SubnetCIDR)
|
||||
}
|
||||
if (s.PoolStart == "") != (s.PoolEnd == "") {
|
||||
return errors.New("pool_start und pool_end müssen beide gesetzt sein (oder beide leer)")
|
||||
}
|
||||
for _, ip := range []string{s.PoolStart, s.PoolEnd, s.Gateway} {
|
||||
if ip != "" && net.ParseIP(ip) == nil {
|
||||
return errors.New("ungültige IP-Adresse: " + ip)
|
||||
}
|
||||
}
|
||||
return validateIPList(s.DNSServers)
|
||||
}
|
||||
|
||||
func validateReservation(r *models.DHCPReservation) error {
|
||||
r.MACAddress = strings.TrimSpace(strings.ToLower(r.MACAddress))
|
||||
r.IPAddress = strings.TrimSpace(r.IPAddress)
|
||||
if _, err := net.ParseMAC(r.MACAddress); err != nil {
|
||||
return errors.New("mac_address ist ungültig: " + r.MACAddress)
|
||||
}
|
||||
if net.ParseIP(r.IPAddress) == nil {
|
||||
return errors.New("ip_address ist ungültig: " + r.IPAddress)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateIPList prüft eine optionale Komma-Liste von IPs.
|
||||
func validateIPList(csv string) error {
|
||||
for _, p := range strings.Split(csv, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" && net.ParseIP(p) == nil {
|
||||
return errors.New("ungültige IP in dns_servers: " + p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) subnetErr(c *gin.Context, err error) {
|
||||
if errors.Is(err, dhcpsvc.ErrSubnetNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) resvErr(c *gin.Context, err error) {
|
||||
if errors.Is(err, dhcpsvc.ErrReservationNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
}
|
||||
Reference in New Issue
Block a user