Verifizierte Bugs aus dem Code-Audit behoben (je mit Test/Build/nft -c geprüft):
- session: IssueWithRoleTTL mutierte geteiltes s.TTL (Data-Race + falsche TTL) → interne issue(); -race-Test.
- auth: Fallback/Federation leiteten role/TOTP nicht aus DB ab (2FA-Bypass auf Secondary, Rolle aus Remote) → viaDB-Flag + DB-Re-Lookup.
- waf: TrustedProxies waren No-op (bogus-Direktive) → XFF-Auflösung im SPOE-Agent (rightmostXFF/ipMatchesAny); RuleExclusions/TrustedProxies validiert (Direktiven-Injection); GetForHost via net.SplitHostPort.
- firewall: Auto-Rule mit IPv6-DstIP erzeugte 'ip daddr <v6>' → bricht ganzes nft-Ruleset; jetzt familienbewusst (ip/ip6, ungültige raus).
- kea: 'interfaces': null bei 0 Subnets → leeres Array.
- cluster_repair: nodeHasPublication schluckte DB-Fehler (Resync auf falschem Node) → (bool,error) fail-closed; IPv6-Primary-URL via net.JoinHostPort.
- cluster_replication: Replikations-Passwort via stdin statt psql -c (nicht mehr in argv/Logs).
- wireguard: Config (Private Key) jetzt configgen.AtomicWrite VOR Symlink/enable; SkipReload-Feld.
- render.go: --no-reload jetzt für alle Renderer (squid/unbound/chrony/wireguard).
- radius: leeres Secret/Passwort + Newlines abgelehnt; freeradius confEscape strippt CR/LF.
- configorch: continue-on-error + errors.Join statt Abbruch mitten in der Sequenz.
- i18n: fehlender Key common.status (de/en).
Verworfen als kein Bug: WAF detection-'blocked' (DetectionOnly liefert keine Interruption), render secrets.New('') (nutzt Default-Masterkey), FanOut-Sort (nur Kommentar), pg_hba (durch nft abgesichert).
Offen/bewusst zurückgestellt (low/risk): AlertWriter-Close (langlebiger Worker, vernachlässigbar), Rolling-Update-Kleinkram (sudoers-gebundener Script-Pfad / GET-State).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
360 lines
9.9 KiB
Go
360 lines
9.9 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 {
|
|
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)
|
|
}
|