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