feat(radius): RADIUS-Server via FreeRADIUS (PAP/CHAP) — v1.2.93

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>
This commit is contained in:
Debian
2026-06-05 19:46:42 +02:00
parent bac7c7e349
commit 5f92851a96
20 changed files with 1302 additions and 11 deletions

View File

@@ -76,6 +76,11 @@ var hashSpec = []hashTable{
{Name: "dhcp_subnets"},
{Name: "dhcp_reservations"},
// RADIUS: Clients + Users sind geteilte Config (repliziert).
// radius_settings ist node-lokal → NICHT hier.
{Name: "radius_clients"},
{Name: "radius_users"},
// network_interfaces + ip_addresses sind BEWUSST NICHT im Drift-Hash.
// Sie stehen in cluster_replication.go localOnlyTables, werden also NICHT
// repliziert und sind per Design node-spezifisch (jede Node hat eigene

View File

@@ -0,0 +1,51 @@
-- +goose Up
-- +goose StatementBegin
-- RADIUS (FreeRADIUS) — node-lokale Singleton-Settings (ob DIESE Node
-- RADIUS betreibt + Listen). Clients/Users sind geteilte Config (repliziert).
CREATE TABLE IF NOT EXISTS radius_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT false,
listen_addresses TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_settings_singleton CHECK (id = 1)
);
INSERT INTO radius_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
-- NAS-Clients (Geräte, die RADIUS-Requests senden): IP/CIDR + Shared Secret
-- (verschlüsselt via secrets.Box).
CREATE TABLE IF NOT EXISTS radius_clients (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
ipaddr TEXT NOT NULL,
secret_enc BYTEA,
active BOOLEAN NOT NULL DEFAULT true,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_clients_name_unique UNIQUE (name)
);
-- Benutzer (PAP/CHAP): Name + Passwort (verschlüsselt; Cleartext nur zur
-- Render-Zeit in die freeradius-lesbare authorize-Datei).
CREATE TABLE IF NOT EXISTS radius_users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL,
password_enc BYTEA,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT radius_users_username_unique UNIQUE (username)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS radius_users;
DROP TABLE IF EXISTS radius_clients;
DROP TABLE IF EXISTS radius_settings;
-- +goose StatementEnd

View File

@@ -434,6 +434,29 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
}
}
// RADIUS (FreeRADIUS): wenn aktiviert → udp 1812 (auth) + 1813 (acct).
// Pro listen-IP, sonst global. FreeRADIUS verwirft unbekannte Clients selbst.
var radiusEnabled bool
var radiusListen string
if err := g.Pool.QueryRow(ctx, `SELECT enabled, listen_addresses FROM radius_settings WHERE id=1`).Scan(&radiusEnabled, &radiusListen); err == nil && radiusEnabled {
ips := splitCSV(radiusListen)
emit := func(ip string) {
out = append(out,
AutoFWRule{Proto: "udp", Port: 1812, DstIP: ip, Comment: "RADIUS-Auth (FreeRADIUS)"},
AutoFWRule{Proto: "udp", Port: 1813, DstIP: ip, Comment: "RADIUS-Acct (FreeRADIUS)"},
)
}
if len(ips) == 0 {
emit("")
} else {
for _, ip := range ips {
if !isLoopback(ip) && ip != "0.0.0.0" && ip != "::" {
emit(ip)
}
}
}
}
return out
}

View File

@@ -0,0 +1,153 @@
// Package freeradius renders the FreeRADIUS client + user files from the
// radius_* tables and manages the freeradius service lifecycle.
//
// Two files are rendered (mirrors the multi-file WireGuard renderer):
// - clients.conf — NAS clients (ipaddr + shared secret)
// - authorize — users file ("name" Cleartext-Password := "pw")
// Both managed under /etc/edgeguard/freeradius/ and symlinked from the
// distro paths by postinst. Shared secrets / passwords are decrypted via
// secrets.Box at render time. Service runs ONLY when radius_settings.enabled
// is true on this node (default off).
package freeradius
import (
"bytes"
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
const (
ConfDir = configgen.EtcEdgeguard + "/freeradius"
ClientsPath = ConfDir + "/clients.conf"
AuthorizePath = ConfDir + "/authorize"
serviceName = "freeradius"
)
type Generator struct {
Pool *pgxpool.Pool
Repo *radiussvc.Repo
Box *secrets.Box
SkipReload bool
}
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
return &Generator{Pool: pool, Repo: radiussvc.New(pool, box), Box: box}
}
func (g *Generator) Name() string { return "freeradius" }
// confEscape escaped FreeRADIUS-double-quoted-Strings (Backslash + Quote).
func confEscape(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}
// buildClients rendert clients.conf. mask=true ersetzt Secrets durch *** (Preview).
func (g *Generator) buildClients(ctx context.Context, mask bool) (string, error) {
clients, err := g.Repo.ListClients(ctx)
if err != nil {
return "", err
}
var b bytes.Buffer
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
for _, c := range clients {
if !c.Active {
continue
}
secret := "***"
if !mask {
pt, err := g.Box.Open(c.SecretEnc)
if err != nil {
return "", fmt.Errorf("open secret for client %s: %w", c.Name, err)
}
secret = string(pt)
}
fmt.Fprintf(&b, "client %s {\n ipaddr = %s\n secret = \"%s\"\n shortname = %s\n}\n\n",
c.Name, c.IPAddr, confEscape(secret), c.Name)
}
return b.String(), nil
}
// buildAuthorize rendert die Users-Datei. mask=true ersetzt Passwörter durch ***.
func (g *Generator) buildAuthorize(ctx context.Context, mask bool) (string, error) {
users, err := g.Repo.ListUsers(ctx)
if err != nil {
return "", err
}
var b bytes.Buffer
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
for _, u := range users {
if !u.Active {
continue
}
pw := "***"
if !mask {
pt, err := g.Box.Open(u.PasswordEnc)
if err != nil {
return "", fmt.Errorf("open password for user %s: %w", u.Username, err)
}
pw = string(pt)
}
fmt.Fprintf(&b, "\"%s\" Cleartext-Password := \"%s\"\n", confEscape(u.Username), confEscape(pw))
}
return b.String(), nil
}
// RenderToString liefert beide Dateien (Secrets maskiert) für die Preview.
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
clients, err := g.buildClients(ctx, true)
if err != nil {
return "", err
}
authorize, err := g.buildAuthorize(ctx, true)
if err != nil {
return "", err
}
return "# ── clients.conf ──\n" + clients + "\n# ── authorize ──\n" + authorize, nil
}
func (g *Generator) Render(ctx context.Context) error {
settings, err := g.Repo.GetSettings(ctx)
if err != nil {
return fmt.Errorf("get radius settings: %w", err)
}
if !settings.Enabled {
if g.SkipReload {
return nil
}
_ = configgen.DisableService(serviceName)
_ = configgen.StopService(serviceName)
return nil
}
clients, err := g.buildClients(ctx, false)
if err != nil {
return err
}
authorize, err := g.buildAuthorize(ctx, false)
if err != nil {
return err
}
if err := configgen.AtomicWrite(ClientsPath, []byte(clients), 0o640); err != nil {
return fmt.Errorf("write clients.conf: %w", err)
}
if err := configgen.AtomicWrite(AuthorizePath, []byte(authorize), 0o640); err != nil {
return fmt.Errorf("write authorize: %w", err)
}
if g.SkipReload {
return nil
}
if err := configgen.EnableService(serviceName); err != nil {
return err
}
return configgen.RestartService(serviceName)
}

View File

@@ -0,0 +1,83 @@
package freeradius
import (
"context"
"os"
"strings"
"testing"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
func TestRender_ClientsAndUsers(t *testing.T) {
dsn := os.Getenv("EG_FWTEST_DSN")
if dsn == "" {
t.Skip("set EG_FWTEST_DSN to run the freeradius renderer test")
}
ctx := context.Background()
var mErr error
for i := 0; i < 3; i++ {
if mErr = database.Migrate(ctx, dsn); mErr == nil {
break
}
time.Sleep(700 * time.Millisecond)
}
if mErr != nil {
t.Fatalf("migrate: %v", mErr)
}
pool, err := database.Open(ctx, dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
defer pool.Close()
for _, q := range []string{`DELETE FROM radius_clients`, `DELETE FROM radius_users`} {
if _, err := pool.Exec(ctx, q); err != nil {
t.Fatalf("clean: %v", err)
}
}
box := secrets.New(t.TempDir() + "/master_key")
repo := radiussvc.New(pool, box)
if _, err := repo.CreateClient(ctx, "testnas", "10.0.0.0/24", `s3c"ret\x`, true, "lab"); err != nil {
t.Fatalf("create client: %v", err)
}
if _, err := repo.CreateUser(ctx, "alice", "alicepw", true); err != nil {
t.Fatalf("create user: %v", err)
}
g := New(pool, box)
clients, err := g.buildClients(ctx, false)
if err != nil {
t.Fatalf("buildClients: %v", err)
}
for _, want := range []string{
"client testnas {",
"ipaddr = 10.0.0.0/24",
`secret = "s3c\"ret\\x"`, // " und \ escaped → Secret-Roundtrip + Escaping
"shortname = testnas",
} {
if !strings.Contains(clients, want) {
t.Errorf("clients.conf missing %q\n----\n%s", want, clients)
}
}
authorize, err := g.buildAuthorize(ctx, false)
if err != nil {
t.Fatalf("buildAuthorize: %v", err)
}
if !strings.Contains(authorize, `"alice" Cleartext-Password := "alicepw"`) {
t.Errorf("authorize missing alice entry\n----\n%s", authorize)
}
// Maskierte Preview enthält keine echten Secrets.
masked, _ := g.buildClients(ctx, true)
if strings.Contains(masked, "s3c") {
t.Errorf("masked preview leaked secret:\n%s", masked)
}
}

346
internal/handlers/radius.go Normal file
View File

@@ -0,0 +1,346 @@
package handlers
import (
"context"
"errors"
"log/slog"
"net"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
)
// RADIUSHandler exposes /api/v1/radius/{settings,clients,users} for the
// FreeRADIUS server (files-based PAP/CHAP).
type RADIUSHandler struct {
Repo *radiussvc.Repo
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewRADIUSHandler(repo *radiussvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *RADIUSHandler {
return &RADIUSHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *RADIUSHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("freeradius: reload after mutation failed", "op", op, "error", err)
}
}
func (h *RADIUSHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/radius")
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
c := g.Group("/clients")
c.GET("", h.ListClients)
c.POST("", h.CreateClient)
c.GET("/:id", h.GetClient)
c.PUT("/:id", h.UpdateClient)
c.DELETE("/:id", h.DeleteClient)
u := g.Group("/users")
u.GET("", h.ListUsers)
u.POST("", h.CreateUser)
u.GET("/:id", h.GetUser)
u.PUT("/:id", h.UpdateUser)
u.DELETE("/:id", h.DeleteUser)
}
var validClientName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
// ── Settings ─────────────────────────────────────────────────────────
func (h *RADIUSHandler) GetSettings(c *gin.Context) {
s, err := h.Repo.GetSettings(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, s)
}
func (h *RADIUSHandler) UpdateSettings(c *gin.Context) {
var req models.RADIUSSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := validateIPList(req.ListenAddresses); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.settings.update", "",
gin.H{"enabled": out.Enabled}, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "settings.update")
}
// ── Clients ──────────────────────────────────────────────────────────
type clientView struct {
models.RADIUSClient
SecretConfigured bool `json:"secret_configured"`
}
func clientToView(c models.RADIUSClient) clientView {
return clientView{RADIUSClient: c, SecretConfigured: len(c.SecretEnc) > 0}
}
func (h *RADIUSHandler) ListClients(c *gin.Context) {
list, err := h.Repo.ListClients(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
out := make([]clientView, 0, len(list))
for _, cl := range list {
out = append(out, clientToView(cl))
}
response.OK(c, gin.H{"clients": out})
}
func (h *RADIUSHandler) GetClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
cl, err := h.Repo.GetClient(c.Request.Context(), id)
if err != nil {
h.clientErr(c, err)
return
}
response.OK(c, clientToView(*cl))
}
type clientBody struct {
Name string `json:"name"`
IPAddr string `json:"ipaddr"`
Secret *string `json:"secret"` // create: required; update: nil=unchanged
Active bool `json:"active"`
Description string `json:"description"`
}
func (b *clientBody) validate(creating bool) error {
b.Name = strings.TrimSpace(b.Name)
b.IPAddr = strings.TrimSpace(b.IPAddr)
if !validClientName.MatchString(b.Name) {
return errors.New("name darf nur Buchstaben/Ziffern/._- enthalten")
}
if net.ParseIP(b.IPAddr) == nil {
if _, _, err := net.ParseCIDR(b.IPAddr); err != nil {
return errors.New("ipaddr ist keine gültige IP/CIDR: " + b.IPAddr)
}
}
if creating && (b.Secret == nil || len(*b.Secret) < 6) {
return errors.New("secret ist erforderlich (mind. 6 Zeichen)")
}
if b.Secret != nil && *b.Secret != "" && len(*b.Secret) < 6 {
return errors.New("secret muss mind. 6 Zeichen haben")
}
return nil
}
func (h *RADIUSHandler) CreateClient(c *gin.Context) {
var b clientBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(true); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateClient(c.Request.Context(), b.Name, b.IPAddr, *b.Secret, b.Active, b.Description)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.create", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
response.Created(c, clientToView(*out))
h.reload(c.Request.Context(), "client.create")
}
func (h *RADIUSHandler) UpdateClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var b clientBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(false); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateClient(c.Request.Context(), id, b.Name, b.IPAddr, b.Secret, b.Active, b.Description)
if err != nil {
h.clientErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.update", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
response.OK(c, clientToView(*out))
h.reload(c.Request.Context(), "client.update")
}
func (h *RADIUSHandler) DeleteClient(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteClient(c.Request.Context(), id); err != nil {
h.clientErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "client.delete")
}
// ── Users ────────────────────────────────────────────────────────────
type userView struct {
models.RADIUSUser
PasswordConfigured bool `json:"password_configured"`
}
func userToView(u models.RADIUSUser) userView {
return userView{RADIUSUser: u, PasswordConfigured: len(u.PasswordEnc) > 0}
}
func (h *RADIUSHandler) ListUsers(c *gin.Context) {
list, err := h.Repo.ListUsers(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
out := make([]userView, 0, len(list))
for _, u := range list {
out = append(out, userToView(u))
}
response.OK(c, gin.H{"users": out})
}
func (h *RADIUSHandler) GetUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
u, err := h.Repo.GetUser(c.Request.Context(), id)
if err != nil {
h.userErr(c, err)
return
}
response.OK(c, userToView(*u))
}
type userBody struct {
Username string `json:"username"`
Password *string `json:"password"`
Active bool `json:"active"`
}
func (b *userBody) validate(creating bool) error {
b.Username = strings.TrimSpace(b.Username)
if b.Username == "" || strings.ContainsAny(b.Username, "\r\n") {
return errors.New("username ist erforderlich (ohne Zeilenumbrüche)")
}
if creating && (b.Password == nil || *b.Password == "") {
return errors.New("password ist erforderlich")
}
return nil
}
func (h *RADIUSHandler) CreateUser(c *gin.Context) {
var b userBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(true); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.CreateUser(c.Request.Context(), b.Username, *b.Password, b.Active)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.create", out.Username, nil, h.NodeID)
response.Created(c, userToView(*out))
h.reload(c.Request.Context(), "user.create")
}
func (h *RADIUSHandler) UpdateUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var b userBody
if err := c.ShouldBindJSON(&b); err != nil {
response.BadRequest(c, err)
return
}
if err := b.validate(false); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateUser(c.Request.Context(), id, b.Username, b.Password, b.Active)
if err != nil {
h.userErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.update", out.Username, nil, h.NodeID)
response.OK(c, userToView(*out))
h.reload(c.Request.Context(), "user.update")
}
func (h *RADIUSHandler) DeleteUser(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Repo.DeleteUser(c.Request.Context(), id); err != nil {
h.userErr(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.delete", "", gin.H{"id": id}, h.NodeID)
response.OK(c, gin.H{"ok": true})
h.reload(c.Request.Context(), "user.delete")
}
// ── error mapping ────────────────────────────────────────────────────
func (h *RADIUSHandler) clientErr(c *gin.Context, err error) {
if errors.Is(err, radiussvc.ErrClientNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}
func (h *RADIUSHandler) userErr(c *gin.Context, err error) {
if errors.Is(err, radiussvc.ErrUserNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
}

43
internal/models/radius.go Normal file
View File

@@ -0,0 +1,43 @@
package models
import "time"
// RADIUSSettings ist die node-lokale Singleton-Konfiguration des
// FreeRADIUS-Servers (ob diese Node RADIUS betreibt + Listen-Adressen).
type RADIUSSettings struct {
ID int `gorm:"column:id;primaryKey" json:"id"`
Enabled bool `gorm:"column:enabled" json:"enabled"`
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (RADIUSSettings) TableName() string { return "radius_settings" }
// RADIUSClient ist ein NAS-Client (IP/CIDR + Shared Secret). SecretEnc
// wird via secrets.Box verschlüsselt und nie serialisiert.
type RADIUSClient struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
Name string `gorm:"column:name" json:"name"`
IPAddr string `gorm:"column:ipaddr" json:"ipaddr"`
SecretEnc []byte `gorm:"column:secret_enc" json:"-"`
Active bool `gorm:"column:active" json:"active"`
Description string `gorm:"column:description" json:"description"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (RADIUSClient) TableName() string { return "radius_clients" }
// RADIUSUser ist ein PAP/CHAP-Benutzer. PasswordEnc wird via secrets.Box
// verschlüsselt und nie serialisiert.
type RADIUSUser struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
Username string `gorm:"column:username" json:"username"`
PasswordEnc []byte `gorm:"column:password_enc" json:"-"`
Active bool `gorm:"column:active" json:"active"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (RADIUSUser) TableName() string { return "radius_users" }

View File

@@ -0,0 +1,239 @@
// 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
}