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:
@@ -29,6 +29,7 @@ import (
|
||||
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
|
||||
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
|
||||
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
|
||||
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
||||
@@ -61,6 +62,7 @@ import (
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
|
||||
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
|
||||
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||
)
|
||||
@@ -453,6 +455,12 @@ func main() {
|
||||
}
|
||||
handlers.NewDHCPHandler(dhcpsvc.New(pool), auditRepo, nodeID, withFW(keaReloader)).Register(authed)
|
||||
|
||||
// RADIUS (FreeRADIUS) — re-render clients.conf + authorize + service lifecycle.
|
||||
radiusReloader := func(ctx context.Context) error {
|
||||
return radiusrender.New(pool, secretsBox).Render(ctx)
|
||||
}
|
||||
handlers.NewRADIUSHandler(radiussvc.New(pool, secretsBox), auditRepo, nodeID, withFW(radiusReloader)).Register(authed)
|
||||
|
||||
// Wire all service reloaders into systemHdl so RenderConfigs
|
||||
// re-renders every service from DB state in one shot.
|
||||
systemHdl.WithAllReloaders(map[string]func(context.Context) error{
|
||||
@@ -462,6 +470,7 @@ func main() {
|
||||
"unbound": unboundReloader,
|
||||
"chrony": chronyReloader,
|
||||
"kea": keaReloader,
|
||||
"freeradius": radiusReloader,
|
||||
})
|
||||
|
||||
// License — node-local key store + DB-mirror of last verify
|
||||
|
||||
@@ -80,6 +80,7 @@ var localOnlyTables = []string{
|
||||
"dns_settings", // listen_addresses ist node-spezifisch
|
||||
"ntp_settings", // listen_addresses ist node-spezifisch
|
||||
"dhcp_settings", // ob DIESE Node DHCP betreibt (Dual-DHCP vermeiden)
|
||||
"radius_settings", // ob DIESE Node RADIUS betreibt + Listen-Adressen
|
||||
"system_settings", // Hostname, Maintenance-Mode etc.
|
||||
"join_tokens_used", // Token-Tracking nur auf Primary relevant
|
||||
"audit_log", // Lokales Audit-Protokoll
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/kea"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
@@ -63,10 +64,12 @@ func cmdRenderConfig(args []string) int {
|
||||
ub := unbound.New(pool)
|
||||
cn := chrony.New(pool)
|
||||
ke := kea.New(pool)
|
||||
fr := freeradius.New(pool, secrets.New(""))
|
||||
if skipReload {
|
||||
hap.SkipReload = true
|
||||
fw.SkipReload = true
|
||||
ke.SkipReload = true
|
||||
fr.SkipReload = true
|
||||
}
|
||||
|
||||
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
|
||||
@@ -75,7 +78,7 @@ func cmdRenderConfig(args []string) int {
|
||||
ka = keepalived.New(pool, lc.NodeID)
|
||||
}
|
||||
|
||||
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke}
|
||||
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr}
|
||||
if ka != nil {
|
||||
gens = append(gens, ka)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
51
internal/database/migrations/0042_radius.sql
Normal file
51
internal/database/migrations/0042_radius.sql
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
153
internal/freeradius/freeradius.go
Normal file
153
internal/freeradius/freeradius.go
Normal 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)
|
||||
}
|
||||
83
internal/freeradius/freeradius_test.go
Normal file
83
internal/freeradius/freeradius_test.go
Normal 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
346
internal/handlers/radius.go
Normal 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
43
internal/models/radius.go
Normal 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" }
|
||||
239
internal/services/radius/radius.go
Normal file
239
internal/services/radius/radius.go
Normal 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
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const SSLPage = lazy(() => import('./pages/SSL'))
|
||||
const FirewallPage = lazy(() => import('./pages/Firewall'))
|
||||
const WireguardPage = lazy(() => import('./pages/Wireguard'))
|
||||
const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
|
||||
const RADIUSPage = lazy(() => import('./pages/RADIUS'))
|
||||
const DNSPage = lazy(() => import('./pages/DNS'))
|
||||
const DHCPPage = lazy(() => import('./pages/DHCP'))
|
||||
const NTPPage = lazy(() => import('./pages/NTP'))
|
||||
@@ -161,6 +162,7 @@ export default function App() {
|
||||
<Route path="/firewall/live" element={<FirewallLivePage />} />
|
||||
<Route path="/vpn/wireguard" element={<WireguardPage />} />
|
||||
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
|
||||
<Route path="/radius" element={<RADIUSPage />} />
|
||||
<Route path="/dns" element={<DNSPage />} />
|
||||
<Route path="/dhcp" element={<DHCPPage />} />
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
|
||||
@@ -23,6 +23,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/ntp': 'nav.ntp',
|
||||
'/vpn/wireguard': 'nav.wireguard',
|
||||
'/forward-proxy': 'nav.forwardProxy',
|
||||
'/radius': 'nav.radius',
|
||||
'/firewall/live': 'nav.firewallLive',
|
||||
'/firewall': 'nav.firewall',
|
||||
'/cluster': 'nav.cluster',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DatabaseOutlined,
|
||||
FireOutlined,
|
||||
GlobalOutlined,
|
||||
IdcardOutlined,
|
||||
NodeIndexOutlined,
|
||||
RadarChartOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
@@ -80,6 +81,7 @@ const NAV: NavSection[] = [
|
||||
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
|
||||
{ path: '/crowdsec', labelKey: 'nav.crowdsec', icon: <RadarChartOutlined /> },
|
||||
{ path: '/waf', labelKey: 'nav.waf', icon: <SafetyCertificateOutlined /> },
|
||||
{ path: '/radius', labelKey: 'nav.radius', icon: <IdcardOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"security": "Sicherheit",
|
||||
"system": "System"
|
||||
},
|
||||
"waf": "WAF"
|
||||
"waf": "WAF",
|
||||
"radius": "RADIUS"
|
||||
},
|
||||
"fw": {
|
||||
"title": "Firewall",
|
||||
@@ -1855,6 +1856,39 @@
|
||||
"confirmRemove": "Collection {{name}} wirklich entfernen?"
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"title": "RADIUS (FreeRADIUS)",
|
||||
"intro": "RADIUS-Authentifizierung (PAP/CHAP): NAS-Clients + Benutzer für VPN-/Switch-/AP-Login.",
|
||||
"csvHint": "Komma-getrennt; leer = alle Adressen.",
|
||||
"saved": "Gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"deleted": "Gelöscht",
|
||||
"deleteFailed": "Löschen fehlgeschlagen",
|
||||
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
|
||||
"secretUnset": "Noch nichts gespeichert.",
|
||||
"tabs": { "settings": "Einstellungen", "clients": "Clients (NAS)", "users": "Benutzer" },
|
||||
"settings": {
|
||||
"enabled": "RADIUS auf dieser Node aktiv",
|
||||
"listen": "Listen-Adressen"
|
||||
},
|
||||
"client": {
|
||||
"name": "Name",
|
||||
"ipaddr": "IP / CIDR",
|
||||
"secret": "Shared Secret",
|
||||
"description": "Beschreibung",
|
||||
"add": "Client hinzufügen",
|
||||
"edit": "Client bearbeiten",
|
||||
"deleteConfirm": "Client \"{{name}}\" wirklich löschen?"
|
||||
},
|
||||
"user": {
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"papHint": "Benutzer-Passwörter werden für PAP/CHAP als Cleartext in der FreeRADIUS-Datei hinterlegt (verschlüsselt at-rest in der DB, Datei nur für root/freeradius lesbar).",
|
||||
"add": "Benutzer hinzufügen",
|
||||
"edit": "Benutzer bearbeiten",
|
||||
"deleteConfirm": "Benutzer \"{{name}}\" wirklich löschen?"
|
||||
}
|
||||
},
|
||||
"waf": {
|
||||
"title": "Web Application Firewall",
|
||||
"intro": "Domänen-spezifische HTTP-Request-Inspektion via Coraza/OWASP CRS. Standard: für alle Domains deaktiviert.",
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"security": "Security",
|
||||
"system": "System"
|
||||
},
|
||||
"waf": "WAF"
|
||||
"waf": "WAF",
|
||||
"radius": "RADIUS"
|
||||
},
|
||||
"fw": {
|
||||
"title": "Firewall",
|
||||
@@ -1855,6 +1856,39 @@
|
||||
"confirmRemove": "Really remove collection {{name}}?"
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"title": "RADIUS (FreeRADIUS)",
|
||||
"intro": "RADIUS authentication (PAP/CHAP): NAS clients + users for VPN/switch/AP login.",
|
||||
"csvHint": "Comma-separated; empty = all addresses.",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Save failed",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Delete failed",
|
||||
"secretSet": "Stored — leave empty to keep unchanged.",
|
||||
"secretUnset": "Nothing stored yet.",
|
||||
"tabs": { "settings": "Settings", "clients": "Clients (NAS)", "users": "Users" },
|
||||
"settings": {
|
||||
"enabled": "RADIUS active on this node",
|
||||
"listen": "Listen addresses"
|
||||
},
|
||||
"client": {
|
||||
"name": "Name",
|
||||
"ipaddr": "IP / CIDR",
|
||||
"secret": "Shared secret",
|
||||
"description": "Description",
|
||||
"add": "Add client",
|
||||
"edit": "Edit client",
|
||||
"deleteConfirm": "Really delete client \"{{name}}\"?"
|
||||
},
|
||||
"user": {
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"papHint": "User passwords are stored as cleartext in the FreeRADIUS file for PAP/CHAP (encrypted at-rest in the DB; the file is readable only by root/freeradius).",
|
||||
"add": "Add user",
|
||||
"edit": "Edit user",
|
||||
"deleteConfirm": "Really delete user \"{{name}}\"?"
|
||||
}
|
||||
},
|
||||
"waf": {
|
||||
"title": "Web Application Firewall",
|
||||
"intro": "Per-domain HTTP request inspection via Coraza/OWASP CRS. Default: off for all domains.",
|
||||
|
||||
226
management-ui/src/pages/RADIUS/index.tsx
Normal file
226
management-ui/src/pages/RADIUS/index.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Form, Input, Modal, Popconfirm, Space, Switch,
|
||||
Table, Tabs, Tag, Tooltip, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { IdcardOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
|
||||
interface RADIUSSettings { id: number; enabled: boolean; listen_addresses: string }
|
||||
interface RADIUSClient {
|
||||
id: number; name: string; ipaddr: string; active: boolean; description: string; secret_configured: boolean
|
||||
}
|
||||
interface RADIUSUser { id: number; username: string; active: boolean; password_configured: boolean }
|
||||
|
||||
export default function RADIUSPage() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div>
|
||||
<PageHeader icon={<IdcardOutlined />} title={t('radius.title')} subtitle={t('radius.intro')} />
|
||||
<Tabs
|
||||
defaultActiveKey="settings"
|
||||
items={[
|
||||
{ key: 'settings', label: t('radius.tabs.settings'), children: <SettingsTab /> },
|
||||
{ key: 'clients', label: t('radius.tabs.clients'), children: <ClientsTab /> },
|
||||
{ key: 'users', label: t('radius.tabs.users'), children: <UsersTab /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<RADIUSSettings>()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['radius', 'settings'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/radius/settings')
|
||||
return isEnvelope(r.data) ? (r.data.data as RADIUSSettings) : null
|
||||
},
|
||||
})
|
||||
useEffect(() => { if (data) form.setFieldsValue(data) }, [data, form])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: RADIUSSettings) => apiClient.put('/radius/settings', v),
|
||||
onSuccess: () => { msg.success(t('radius.saved')); void qc.invalidateQueries({ queryKey: ['radius', 'settings'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{msgCtx}
|
||||
<Form<RADIUSSettings> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('radius.settings.enabled')} name="enabled" valuePropName="checked">
|
||||
<Switch disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('radius.settings.listen')} name="listen_addresses" extra={t('radius.csvHint')}>
|
||||
<Input placeholder="0.0.0.0" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending} disabled={isViewer}>{t('common.save')}</Button>
|
||||
</Tooltip>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface ClientForm { name: string; ipaddr: string; secret?: string; active: boolean; description: string }
|
||||
|
||||
function ClientsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<ClientForm>()
|
||||
const [editing, setEditing] = useState<RADIUSClient | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['radius', 'clients'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/radius/clients')
|
||||
return isEnvelope(r.data) ? ((r.data.data as { clients?: RADIUSClient[] }).clients ?? []) : []
|
||||
},
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: ClientForm) => {
|
||||
const body: Record<string, unknown> = { ...v }
|
||||
if (!v.secret) delete body.secret // leer = unverändert
|
||||
return editing ? apiClient.put(`/radius/clients/${editing.id}`, body) : apiClient.post('/radius/clients', body)
|
||||
},
|
||||
onSuccess: () => { msg.success(t('radius.saved')); setOpen(false); setEditing(null); void qc.invalidateQueries({ queryKey: ['radius', 'clients'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/radius/clients/${id}`),
|
||||
onSuccess: () => { msg.success(t('radius.deleted')); void qc.invalidateQueries({ queryKey: ['radius', 'clients'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.deleteFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<RADIUSClient> = [
|
||||
{ title: t('radius.client.name'), dataIndex: 'name' },
|
||||
{ title: t('radius.client.ipaddr'), dataIndex: 'ipaddr' },
|
||||
{ title: t('radius.client.secret'), dataIndex: 'secret_configured', render: (v: boolean) => (v ? <Tag color="green">✓</Tag> : <Tag>—</Tag>) },
|
||||
{ title: t('common.status'), dataIndex: 'active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag> },
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 200,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue({ name: r.name, ipaddr: r.ipaddr, active: r.active, description: r.description, secret: '' }); setOpen(true) }}>{t('common.edit')}</Button>}
|
||||
{!isViewer && <Popconfirm title={t('radius.client.deleteConfirm', { name: r.name })} onConfirm={() => del.mutate(r.id)}><Button size="small" danger>{t('common.delete')}</Button></Popconfirm>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{msgCtx}
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer}
|
||||
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<ClientForm>); setOpen(true) }}>
|
||||
{t('radius.client.add')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table<RADIUSClient> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
|
||||
<Modal title={editing ? t('radius.client.edit') : t('radius.client.add')} open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose>
|
||||
<Form<ClientForm> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('radius.client.name')} name="name" rules={[{ required: true }]}><Input placeholder="switch-core" /></Form.Item>
|
||||
<Form.Item label={t('radius.client.ipaddr')} name="ipaddr" rules={[{ required: true }]}><Input placeholder="10.0.0.0/24" /></Form.Item>
|
||||
<Form.Item label={t('radius.client.secret')} name="secret" extra={editing ? (editing.secret_configured ? t('radius.secretSet') : t('radius.secretUnset')) : undefined} rules={editing ? [] : [{ required: true }]}>
|
||||
<Input.Password autoComplete="new-password" placeholder={editing?.secret_configured ? '••••••••' : ''} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('radius.client.description')} name="description"><Input /></Form.Item>
|
||||
<Form.Item label={t('common.active')} name="active" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface UserForm { username: string; password?: string; active: boolean }
|
||||
|
||||
function UsersTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<UserForm>()
|
||||
const [editing, setEditing] = useState<RADIUSUser | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['radius', 'users'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/radius/users')
|
||||
return isEnvelope(r.data) ? ((r.data.data as { users?: RADIUSUser[] }).users ?? []) : []
|
||||
},
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: UserForm) => {
|
||||
const body: Record<string, unknown> = { ...v }
|
||||
if (!v.password) delete body.password
|
||||
return editing ? apiClient.put(`/radius/users/${editing.id}`, body) : apiClient.post('/radius/users', body)
|
||||
},
|
||||
onSuccess: () => { msg.success(t('radius.saved')); setOpen(false); setEditing(null); void qc.invalidateQueries({ queryKey: ['radius', 'users'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/radius/users/${id}`),
|
||||
onSuccess: () => { msg.success(t('radius.deleted')); void qc.invalidateQueries({ queryKey: ['radius', 'users'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.deleteFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<RADIUSUser> = [
|
||||
{ title: t('radius.user.username'), dataIndex: 'username' },
|
||||
{ title: t('radius.user.password'), dataIndex: 'password_configured', render: (v: boolean) => (v ? <Tag color="green">✓</Tag> : <Tag>—</Tag>) },
|
||||
{ title: t('common.status'), dataIndex: 'active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag> },
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 200,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue({ username: r.username, active: r.active, password: '' }); setOpen(true) }}>{t('common.edit')}</Button>}
|
||||
{!isViewer && <Popconfirm title={t('radius.user.deleteConfirm', { name: r.username })} onConfirm={() => del.mutate(r.id)}><Button size="small" danger>{t('common.delete')}</Button></Popconfirm>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{msgCtx}
|
||||
<Alert type="info" showIcon className="mb-16" message={t('radius.user.papHint')} />
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer}
|
||||
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<UserForm>); setOpen(true) }}>
|
||||
{t('radius.user.add')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table<RADIUSUser> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
|
||||
<Modal title={editing ? t('radius.user.edit') : t('radius.user.add')} open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose>
|
||||
<Form<UserForm> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('radius.user.username')} name="username" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item label={t('radius.user.password')} name="password" extra={editing ? (editing.password_configured ? t('radius.secretSet') : t('radius.secretUnset')) : undefined} rules={editing ? [] : [{ required: true }]}>
|
||||
<Input.Password autoComplete="new-password" placeholder={editing?.password_configured ? '••••••••' : ''} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('common.active')} name="active" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Fir
|
||||
PG Streaming Replication + provider Floating-IP for HTTP ingress).
|
||||
.
|
||||
This package ships the management API, scheduler and CLI.
|
||||
Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, kea-dhcp4-server, nftables, keepalived, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
|
||||
Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, kea-dhcp4-server, freeradius, nftables, keepalived, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd
|
||||
Recommends: edgeguard-keydb (>= 6.3.4-edgeguard1), apparmor, fail2ban
|
||||
Section: admin
|
||||
Priority: optional
|
||||
|
||||
@@ -31,6 +31,11 @@ case "$1" in
|
||||
if getent group unbound >/dev/null; then
|
||||
usermod -a -G unbound "$EG_USER" || true
|
||||
fi
|
||||
# freeradius: edgeguard muss die gerenderten clients.conf/authorize
|
||||
# ins freeradius-lesbare Verzeichnis schreiben (setgid-Dir unten).
|
||||
if getent group freeradius >/dev/null; then
|
||||
usermod -a -G freeradius "$EG_USER" || true
|
||||
fi
|
||||
# systemd-journal + adm: damit edgeguard-api `journalctl -u …`
|
||||
# ohne sudo lesen kann — wird für /api/v1/logs gebraucht
|
||||
# (zentrale Log-Übersicht über alle Services).
|
||||
@@ -128,6 +133,15 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable kea-dhcp4-server.servic
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable kea-dhcp4-server.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable kea-dhcp4-server.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable kea-dhcp4-server.service
|
||||
# RADIUS (FreeRADIUS): Service-Lifecycle an radius_settings.enabled gekoppelt.
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable freeradius.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update -qq
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update
|
||||
# Auto-Update-Toggle (Settings → Automatische Updates) schreibt/entfernt
|
||||
@@ -251,6 +265,28 @@ SUDOERS
|
||||
ln -sfn /etc/edgeguard/kea/kea-dhcp4.conf /etc/kea/kea-dhcp4.conf
|
||||
systemctl disable --now kea-dhcp4-server.service 2>/dev/null || true
|
||||
|
||||
# FreeRADIUS: managed clients.conf + authorize unter /etc/edgeguard/
|
||||
# freeradius (setgid → Dateien erben Gruppe freeradius, 0640 → der
|
||||
# freeradius-Dienst kann lesen). Symlinks von den Distro-Pfaden.
|
||||
# Dienst default AUS (kein RADIUS bis Operator aktiviert).
|
||||
if getent group freeradius >/dev/null; then
|
||||
install -d -m 2750 -o "$EG_USER" -g freeradius /etc/edgeguard/freeradius
|
||||
else
|
||||
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" /etc/edgeguard/freeradius
|
||||
fi
|
||||
if [ -d /etc/freeradius/3.0 ]; then
|
||||
if [ -e /etc/freeradius/3.0/clients.conf ] && [ ! -L /etc/freeradius/3.0/clients.conf ]; then
|
||||
mv /etc/freeradius/3.0/clients.conf /etc/freeradius/3.0/clients.conf.distro-bak
|
||||
fi
|
||||
ln -sfn /etc/edgeguard/freeradius/clients.conf /etc/freeradius/3.0/clients.conf
|
||||
if [ -e /etc/freeradius/3.0/mods-config/files/authorize ] && [ ! -L /etc/freeradius/3.0/mods-config/files/authorize ]; then
|
||||
mv /etc/freeradius/3.0/mods-config/files/authorize /etc/freeradius/3.0/mods-config/files/authorize.distro-bak
|
||||
fi
|
||||
install -d /etc/freeradius/3.0/mods-config/files
|
||||
ln -sfn /etc/edgeguard/freeradius/authorize /etc/freeradius/3.0/mods-config/files/authorize
|
||||
fi
|
||||
systemctl disable --now freeradius.service 2>/dev/null || true
|
||||
|
||||
chmod 0440 /etc/sudoers.d/edgeguard
|
||||
|
||||
# ── Sysctl-Profil für Edge-Gateway (NAT + HAProxy + Forwarding) ──
|
||||
|
||||
Reference in New Issue
Block a user