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>
239 lines
7.9 KiB
Go
239 lines
7.9 KiB
Go
// 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
|
|
}
|