Files-basierter RADIUS-Server (Clients + Users), managed analog DHCP/WireGuard.
- Migration 0042: radius_settings (singleton, node-lokal), radius_clients (secret_enc), radius_users (password_enc) — Secrets via secrets.Box verschlüsselt.
- internal/freeradius: Multi-File-Renderer (clients.conf + authorize) via Box.Open, Secret-Escaping (" \), Service default-off/an enabled gekoppelt. internal/services/radius + internal/handlers/radius.go: Settings + Client/User-CRUD, write-only Secret-Semantik, Validierung (IP/CIDR, name-charset), GET liefert secret_configured statt Secret.
- Firewall: udp 1812/1813 Auto-Rule bei enabled. Cluster: clients/users repliziert (hashSpec), radius_settings node-lokal.
- main.go + render.go + WithAllReloaders. Packaging: freeradius Dependency, setgid-Dir /etc/edgeguard/freeradius (Gruppe freeradius), Symlinks clients.conf+authorize, disable-on-install, sudoers.
- UI: RADIUS-Seite (Einstellungen + Clients + Benutzer) unter Sicherheit, Route/Nav/i18n de/en.
- Tests (guarded): Renderer-Inhalt + Secret-Escaping/Roundtrip + Masking. Scope v1: PAP/CHAP files-based (kein EAP/802.1X).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
7.5 KiB
Go
240 lines
7.5 KiB
Go
// Package radius provides CRUD against radius_settings (singleton),
|
|
// radius_clients and radius_users. Shared secrets / user passwords are
|
|
// sealed at rest via secrets.Box. The FreeRADIUS renderer in
|
|
// internal/freeradius consumes these.
|
|
package radius
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
|
)
|
|
|
|
var (
|
|
ErrClientNotFound = errors.New("radius client not found")
|
|
ErrUserNotFound = errors.New("radius user not found")
|
|
)
|
|
|
|
type Repo struct {
|
|
Pool *pgxpool.Pool
|
|
Box *secrets.Box
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool, box *secrets.Box) *Repo { return &Repo{Pool: pool, Box: box} }
|
|
|
|
// ── Settings ─────────────────────────────────────────────────────────
|
|
|
|
func (r *Repo) GetSettings(ctx context.Context) (*models.RADIUSSettings, error) {
|
|
var s models.RADIUSSettings
|
|
if err := r.Pool.QueryRow(ctx, `
|
|
SELECT id, enabled, listen_addresses, created_at, updated_at FROM radius_settings WHERE id=1`).Scan(
|
|
&s.ID, &s.Enabled, &s.ListenAddresses, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
func (r *Repo) UpdateSettings(ctx context.Context, s models.RADIUSSettings) (*models.RADIUSSettings, error) {
|
|
var out models.RADIUSSettings
|
|
if err := r.Pool.QueryRow(ctx, `
|
|
UPDATE radius_settings SET enabled=$1, listen_addresses=$2, updated_at=NOW() WHERE id=1
|
|
RETURNING id, enabled, listen_addresses, created_at, updated_at`,
|
|
s.Enabled, s.ListenAddresses).Scan(
|
|
&out.ID, &out.Enabled, &out.ListenAddresses, &out.CreatedAt, &out.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ── Clients ──────────────────────────────────────────────────────────
|
|
|
|
const clientCols = `id, name, ipaddr, secret_enc, active, description, created_at, updated_at`
|
|
|
|
func scanClient(row pgx.Row) (*models.RADIUSClient, error) {
|
|
var c models.RADIUSClient
|
|
if err := row.Scan(&c.ID, &c.Name, &c.IPAddr, &c.SecretEnc, &c.Active, &c.Description,
|
|
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
func (r *Repo) ListClients(ctx context.Context) ([]models.RADIUSClient, error) {
|
|
rows, err := r.Pool.Query(ctx, `SELECT `+clientCols+` FROM radius_clients ORDER BY name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.RADIUSClient, 0, 8)
|
|
for rows.Next() {
|
|
c, err := scanClient(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *Repo) GetClient(ctx context.Context, id int64) (*models.RADIUSClient, error) {
|
|
c, err := scanClient(r.Pool.QueryRow(ctx, `SELECT `+clientCols+` FROM radius_clients WHERE id=$1`, id))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrClientNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (r *Repo) CreateClient(ctx context.Context, name, ipaddr, secret string, active bool, desc string) (*models.RADIUSClient, error) {
|
|
enc, err := r.Box.Seal([]byte(secret))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanClient(r.Pool.QueryRow(ctx, `
|
|
INSERT INTO radius_clients (name, ipaddr, secret_enc, active, description)
|
|
VALUES ($1,$2,$3,$4,$5) RETURNING `+clientCols,
|
|
name, ipaddr, enc, active, desc))
|
|
}
|
|
|
|
// UpdateClient: secret==nil → unverändert, ""→löschen, sonst neu sealen.
|
|
func (r *Repo) UpdateClient(ctx context.Context, id int64, name, ipaddr string, secret *string, active bool, desc string) (*models.RADIUSClient, error) {
|
|
if secret == nil {
|
|
out, err := scanClient(r.Pool.QueryRow(ctx, `
|
|
UPDATE radius_clients SET name=$1, ipaddr=$2, active=$3, description=$4, updated_at=NOW()
|
|
WHERE id=$5 RETURNING `+clientCols, name, ipaddr, active, desc, id))
|
|
return mapClientErr(out, err)
|
|
}
|
|
var enc []byte
|
|
if *secret != "" {
|
|
sealed, err := r.Box.Seal([]byte(*secret))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
enc = sealed
|
|
}
|
|
out, err := scanClient(r.Pool.QueryRow(ctx, `
|
|
UPDATE radius_clients SET name=$1, ipaddr=$2, secret_enc=$3, active=$4, description=$5, updated_at=NOW()
|
|
WHERE id=$6 RETURNING `+clientCols, name, ipaddr, enc, active, desc, id))
|
|
return mapClientErr(out, err)
|
|
}
|
|
|
|
func mapClientErr(c *models.RADIUSClient, err error) (*models.RADIUSClient, error) {
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrClientNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (r *Repo) DeleteClient(ctx context.Context, id int64) error {
|
|
tag, err := r.Pool.Exec(ctx, `DELETE FROM radius_clients WHERE id=$1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrClientNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Users ────────────────────────────────────────────────────────────
|
|
|
|
const userCols = `id, username, password_enc, active, created_at, updated_at`
|
|
|
|
func scanUser(row pgx.Row) (*models.RADIUSUser, error) {
|
|
var u models.RADIUSUser
|
|
if err := row.Scan(&u.ID, &u.Username, &u.PasswordEnc, &u.Active, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
return &u, nil
|
|
}
|
|
|
|
func (r *Repo) ListUsers(ctx context.Context) ([]models.RADIUSUser, error) {
|
|
rows, err := r.Pool.Query(ctx, `SELECT `+userCols+` FROM radius_users ORDER BY username`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.RADIUSUser, 0, 8)
|
|
for rows.Next() {
|
|
u, err := scanUser(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *Repo) GetUser(ctx context.Context, id int64) (*models.RADIUSUser, error) {
|
|
u, err := scanUser(r.Pool.QueryRow(ctx, `SELECT `+userCols+` FROM radius_users WHERE id=$1`, id))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (r *Repo) CreateUser(ctx context.Context, username, password string, active bool) (*models.RADIUSUser, error) {
|
|
enc, err := r.Box.Seal([]byte(password))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanUser(r.Pool.QueryRow(ctx, `
|
|
INSERT INTO radius_users (username, password_enc, active) VALUES ($1,$2,$3) RETURNING `+userCols,
|
|
username, enc, active))
|
|
}
|
|
|
|
func (r *Repo) UpdateUser(ctx context.Context, id int64, username string, password *string, active bool) (*models.RADIUSUser, error) {
|
|
if password == nil {
|
|
u, err := scanUser(r.Pool.QueryRow(ctx, `
|
|
UPDATE radius_users SET username=$1, active=$2, updated_at=NOW() WHERE id=$3 RETURNING `+userCols,
|
|
username, active, id))
|
|
return mapUserErr(u, err)
|
|
}
|
|
var enc []byte
|
|
if *password != "" {
|
|
sealed, err := r.Box.Seal([]byte(*password))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
enc = sealed
|
|
}
|
|
u, err := scanUser(r.Pool.QueryRow(ctx, `
|
|
UPDATE radius_users SET username=$1, password_enc=$2, active=$3, updated_at=NOW() WHERE id=$4 RETURNING `+userCols,
|
|
username, enc, active, id))
|
|
return mapUserErr(u, err)
|
|
}
|
|
|
|
func mapUserErr(u *models.RADIUSUser, err error) (*models.RADIUSUser, error) {
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (r *Repo) DeleteUser(ctx context.Context, id int64) error {
|
|
tag, err := r.Pool.Exec(ctx, `DELETE FROM radius_users WHERE id=$1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrUserNotFound
|
|
}
|
|
return nil
|
|
}
|