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:
238
internal/services/dhcp/dhcp.go
Normal file
238
internal/services/dhcp/dhcp.go
Normal file
@@ -0,0 +1,238 @@
|
||||
// Package dhcp provides CRUD against the dhcp_settings (singleton),
|
||||
// dhcp_subnets, and dhcp_reservations tables. The Kea config renderer
|
||||
// in internal/kea consumes these.
|
||||
package dhcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSubnetNotFound = errors.New("dhcp subnet not found")
|
||||
ErrReservationNotFound = errors.New("dhcp reservation not found")
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||
|
||||
// ── Settings (singleton id=1) ────────────────────────────────────────
|
||||
|
||||
func (r *Repo) GetSettings(ctx context.Context) (*models.DHCPSettings, error) {
|
||||
var s models.DHCPSettings
|
||||
if err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, enabled, default_lease, max_lease, domain_name, dns_servers, created_at, updated_at
|
||||
FROM dhcp_settings WHERE id=1`).Scan(
|
||||
&s.ID, &s.Enabled, &s.DefaultLease, &s.MaxLease, &s.DomainName, &s.DNSServers,
|
||||
&s.CreatedAt, &s.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateSettings(ctx context.Context, s models.DHCPSettings) (*models.DHCPSettings, error) {
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
UPDATE dhcp_settings
|
||||
SET enabled=$1, default_lease=$2, max_lease=$3, domain_name=$4, dns_servers=$5, updated_at=NOW()
|
||||
WHERE id=1
|
||||
RETURNING id, enabled, default_lease, max_lease, domain_name, dns_servers, created_at, updated_at`,
|
||||
s.Enabled, s.DefaultLease, s.MaxLease, s.DomainName, s.DNSServers)
|
||||
var out models.DHCPSettings
|
||||
if err := row.Scan(&out.ID, &out.Enabled, &out.DefaultLease, &out.MaxLease,
|
||||
&out.DomainName, &out.DNSServers, &out.CreatedAt, &out.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ── Subnets ──────────────────────────────────────────────────────────
|
||||
|
||||
const subnetCols = `id, name, interface_name, subnet_cidr, pool_start, pool_end,
|
||||
gateway, dns_servers, lease_time, active, description, created_at, updated_at`
|
||||
|
||||
func scanSubnet(row pgx.Row) (*models.DHCPSubnet, error) {
|
||||
var s models.DHCPSubnet
|
||||
if err := row.Scan(&s.ID, &s.Name, &s.InterfaceName, &s.SubnetCIDR, &s.PoolStart,
|
||||
&s.PoolEnd, &s.Gateway, &s.DNSServers, &s.LeaseTime, &s.Active, &s.Description,
|
||||
&s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *Repo) ListSubnets(ctx context.Context) ([]models.DHCPSubnet, error) {
|
||||
rows, err := r.Pool.Query(ctx, `SELECT `+subnetCols+` FROM dhcp_subnets ORDER BY name ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.DHCPSubnet, 0, 8)
|
||||
for rows.Next() {
|
||||
s, err := scanSubnet(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) GetSubnet(ctx context.Context, id int64) (*models.DHCPSubnet, error) {
|
||||
s, err := scanSubnet(r.Pool.QueryRow(ctx, `SELECT `+subnetCols+` FROM dhcp_subnets WHERE id=$1`, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSubnetNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *Repo) CreateSubnet(ctx context.Context, s models.DHCPSubnet) (*models.DHCPSubnet, error) {
|
||||
return scanSubnet(r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO dhcp_subnets (name, interface_name, subnet_cidr, pool_start, pool_end,
|
||||
gateway, dns_servers, lease_time, active, description)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
RETURNING `+subnetCols,
|
||||
s.Name, s.InterfaceName, s.SubnetCIDR, s.PoolStart, s.PoolEnd,
|
||||
s.Gateway, s.DNSServers, s.LeaseTime, s.Active, s.Description))
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateSubnet(ctx context.Context, id int64, s models.DHCPSubnet) (*models.DHCPSubnet, error) {
|
||||
out, err := scanSubnet(r.Pool.QueryRow(ctx, `
|
||||
UPDATE dhcp_subnets
|
||||
SET name=$1, interface_name=$2, subnet_cidr=$3, pool_start=$4, pool_end=$5,
|
||||
gateway=$6, dns_servers=$7, lease_time=$8, active=$9, description=$10, updated_at=NOW()
|
||||
WHERE id=$11
|
||||
RETURNING `+subnetCols,
|
||||
s.Name, s.InterfaceName, s.SubnetCIDR, s.PoolStart, s.PoolEnd,
|
||||
s.Gateway, s.DNSServers, s.LeaseTime, s.Active, s.Description, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSubnetNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) DeleteSubnet(ctx context.Context, id int64) error {
|
||||
tag, err := r.Pool.Exec(ctx, `DELETE FROM dhcp_subnets WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSubnetNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Reservations ─────────────────────────────────────────────────────
|
||||
|
||||
const resvCols = `id, subnet_id, name, mac_address, ip_address, hostname, active, created_at, updated_at`
|
||||
|
||||
func scanResv(row pgx.Row) (*models.DHCPReservation, error) {
|
||||
var r models.DHCPReservation
|
||||
if err := row.Scan(&r.ID, &r.SubnetID, &r.Name, &r.MACAddress, &r.IPAddress,
|
||||
&r.Hostname, &r.Active, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (r *Repo) ListAllReservations(ctx context.Context) ([]models.DHCPReservation, error) {
|
||||
rows, err := r.Pool.Query(ctx, `SELECT `+resvCols+` FROM dhcp_reservations ORDER BY subnet_id, ip_address`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.DHCPReservation, 0, 16)
|
||||
for rows.Next() {
|
||||
v, err := scanResv(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) ListReservationsForSubnet(ctx context.Context, subnetID int64) ([]models.DHCPReservation, error) {
|
||||
rows, err := r.Pool.Query(ctx, `SELECT `+resvCols+` FROM dhcp_reservations WHERE subnet_id=$1 ORDER BY ip_address`, subnetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.DHCPReservation, 0, 8)
|
||||
for rows.Next() {
|
||||
v, err := scanResv(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) GetReservation(ctx context.Context, id int64) (*models.DHCPReservation, error) {
|
||||
v, err := scanResv(r.Pool.QueryRow(ctx, `SELECT `+resvCols+` FROM dhcp_reservations WHERE id=$1`, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrReservationNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *Repo) CreateReservation(ctx context.Context, v models.DHCPReservation) (*models.DHCPReservation, error) {
|
||||
return scanResv(r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO dhcp_reservations (subnet_id, name, mac_address, ip_address, hostname, active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
RETURNING `+resvCols,
|
||||
v.SubnetID, v.Name, v.MACAddress, v.IPAddress, v.Hostname, v.Active))
|
||||
}
|
||||
|
||||
func (r *Repo) UpdateReservation(ctx context.Context, id int64, v models.DHCPReservation) (*models.DHCPReservation, error) {
|
||||
out, err := scanResv(r.Pool.QueryRow(ctx, `
|
||||
UPDATE dhcp_reservations
|
||||
SET name=$1, mac_address=$2, ip_address=$3, hostname=$4, active=$5, updated_at=NOW()
|
||||
WHERE id=$6
|
||||
RETURNING `+resvCols,
|
||||
v.Name, v.MACAddress, v.IPAddress, v.Hostname, v.Active, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrReservationNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) DeleteReservation(ctx context.Context, id int64) error {
|
||||
tag, err := r.Pool.Exec(ctx, `DELETE FROM dhcp_reservations WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrReservationNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InterfaceExists prüft, ob ein Interface-Name in network_interfaces existiert
|
||||
// (für die Validierung beim Anlegen/Ändern von Subnets).
|
||||
func (r *Repo) InterfaceExists(ctx context.Context, name string) (bool, error) {
|
||||
var exists bool
|
||||
err := r.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM network_interfaces WHERE name=$1)`, name).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
Reference in New Issue
Block a user