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>
347 lines
9.6 KiB
Go
347 lines
9.6 KiB
Go
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)
|
|
}
|