// 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 }