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 { return errors.New("secret ist erforderlich") } if b.Secret != nil { if len(*b.Secret) < 6 { return errors.New("secret muss mind. 6 Zeichen haben (leer löscht es nicht)") } if strings.ContainsAny(*b.Secret, "\r\n") { return errors.New("secret darf keine Zeilenumbrüche enthalten") } } 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") } if b.Password != nil { if *b.Password == "" { return errors.New("password darf nicht leer sein (löscht es nicht)") } if strings.ContainsAny(*b.Password, "\r\n") { return errors.New("password darf keine Zeilenumbrüche enthalten") } } 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) }