From b031725dfe968e06fc58a44820b69c903e14bd37 Mon Sep 17 00:00:00 2001 From: Debian Date: Tue, 12 May 2026 23:50:26 +0200 Subject: [PATCH] feat(routes): Static-Routes-Management + Live-View (Networks-Tab) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0019: static_routes (id, destination, gateway, dev, metric, table_name, active, comment). internal/services/staticroutes/: - CRUD-Repo - Generator schreibt /etc/edgeguard/routes.conf (pipe-format) und triggert `sudo systemctl restart edgeguard-routes.service` - LiveAll() ruft `ip -j route show table all` und parsed JSON internal/handlers/routes.go: GET /api/v1/routes — managed (DB) POST/PUT/DELETE — CRUD (re-render + apply on mutate) GET /api/v1/routes/live — kernel-state via ip(8) postinst: - /usr/sbin/edgeguard-apply-routes (root-owned shell-script). Liest routes.conf, flusht `proto 250` (= edgeguard), setzt neue Routen mit proto 250. Andere Quellen (kernel/dhcp/manuell) bleiben unangetastet. - /etc/systemd/system/edgeguard-routes.service (Type=oneshot, After=network-online.target). Beim Boot automatisch via multi-user.target. - /etc/iproute2/rt_protos.d/edgeguard.conf — Symbol "edgeguard" = 250 damit `ip route show proto edgeguard` funktioniert. (Debian 13 hat kein /etc/iproute2 default → .d-Pattern statt rt_protos-Anhängen.) - sudoers: edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart edgeguard-routes.service UI: Networks-Page jetzt mit Tabs (Interfaces + Routen). Routes-Tab hat zwei Cards: - Live-Routen (read-only, 30s refresh, `proto edgeguard` farblich hervorgehoben) - Verwaltete Routen (CRUD-Tabelle, Add/Edit-Modal mit destination/ gateway/dev/metric/table/active/comment) Co-Authored-By: Claude Opus 4.7 (1M context) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 5 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- .../migrations/0019_static_routes.sql | 39 +++ internal/handlers/routes.go | 128 ++++++++ .../services/staticroutes/staticroutes.go | 280 ++++++++++++++++++ .../src/components/Layout/Sidebar.tsx | 2 +- management-ui/src/i18n/locales/de/common.json | 38 ++- management-ui/src/i18n/locales/en/common.json | 38 ++- .../src/pages/Networks/Interfaces.tsx | 277 +++++++++++++++++ management-ui/src/pages/Networks/Routes.tsx | 274 +++++++++++++++++ management-ui/src/pages/Networks/index.tsx | 275 +---------------- .../debian/edgeguard-api/DEBIAN/postinst | 75 +++++ 14 files changed, 1162 insertions(+), 275 deletions(-) create mode 100644 internal/database/migrations/0019_static_routes.sql create mode 100644 internal/handlers/routes.go create mode 100644 internal/services/staticroutes/staticroutes.go create mode 100644 management-ui/src/pages/Networks/Interfaces.tsx create mode 100644 management-ui/src/pages/Networks/Routes.tsx diff --git a/VERSION b/VERSION index 7b8d6b7..c8b4742 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.65 +1.0.67 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 3b2621d..f873893 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -45,13 +45,14 @@ import ( ntpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/ntp" "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/staticroutes" "git.netcell-it.de/projekte/edgeguard-native/internal/services/session" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.65" +var version = "1.0.67" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -183,6 +184,8 @@ func main() { handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewNetworksHandler(ifsRepo, ipsRepo, fwZones, auditRepo, nodeID).Register(authed) handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed) + handlers.NewRoutesHandler(staticroutes.New(pool), staticroutes.NewGenerator(pool), + auditRepo, nodeID).Register(authed) handlers.NewClusterHandler(clusterStore, nodeID).Register(authed) handlers.NewAuditHandler(auditRepo).Register(authed) handlers.NewHAProxyStatsHandler().Register(authed) diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index c164e32..50193e5 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.65" +var version = "1.0.67" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index a4fce3d..d5caefa 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -25,7 +25,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.65" +var version = "1.0.67" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/database/migrations/0019_static_routes.sql b/internal/database/migrations/0019_static_routes.sql new file mode 100644 index 0000000..7f4759e --- /dev/null +++ b/internal/database/migrations/0019_static_routes.sql @@ -0,0 +1,39 @@ +-- +goose Up +-- +goose StatementBegin + +-- Static routes — managed durch EdgeGuard, geschrieben in +-- /etc/edgeguard/routes.conf und appliziert via `ip route … proto +-- edgeguard`. Markierung mit Protocol-ID 250 (in der apply-script +-- gemappt) damit der Operator manuell-gesetzte Routen im Kernel +-- erkennt und nicht beim flush wegradiert. +-- +-- destination: CIDR ("10.0.5.0/24" oder "0.0.0.0/0"). +-- gateway: IPv4/v6 oder NULL (für on-link via dev). +-- dev: interface name (optional; bei Gateway-only kann ip das +-- selbst auflösen, aber explizit ist besser). +-- metric: Priorität — niedriger gewinnt (default 100). +-- table: routing table name oder "main" (default). + +CREATE TABLE IF NOT EXISTS static_routes ( + id BIGSERIAL PRIMARY KEY, + destination TEXT NOT NULL, + gateway TEXT, + dev TEXT, + metric INTEGER NOT NULL DEFAULT 100, + table_name TEXT NOT NULL DEFAULT 'main', + active BOOLEAN NOT NULL DEFAULT TRUE, + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT static_routes_metric_check CHECK (metric >= 0 AND metric <= 65535) +); + +CREATE INDEX IF NOT EXISTS idx_static_routes_active ON static_routes (active) WHERE active; +CREATE INDEX IF NOT EXISTS idx_static_routes_dest ON static_routes (destination); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS static_routes; +-- +goose StatementEnd diff --git a/internal/handlers/routes.go b/internal/handlers/routes.go new file mode 100644 index 0000000..93e5e33 --- /dev/null +++ b/internal/handlers/routes.go @@ -0,0 +1,128 @@ +package handlers + +import ( + "context" + "errors" + "log/slog" + "strconv" + + "github.com/gin-gonic/gin" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/staticroutes" +) + +// RoutesHandler exposes: +// +// GET /api/v1/routes — verwaltete Routen aus DB +// POST /api/v1/routes — anlegen +// PUT /api/v1/routes/:id — ändern +// DELETE /api/v1/routes/:id — löschen +// GET /api/v1/routes/live — Live-Routen vom Kernel (ip -j route) +type RoutesHandler struct { + Repo *staticroutes.Repo + Renderer *staticroutes.Generator + Audit *audit.Repo + NodeID string +} + +func NewRoutesHandler(repo *staticroutes.Repo, gen *staticroutes.Generator, + a *audit.Repo, nodeID string) *RoutesHandler { + return &RoutesHandler{Repo: repo, Renderer: gen, Audit: a, NodeID: nodeID} +} + +func (h *RoutesHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/routes") + g.GET("", h.List) + g.POST("", h.Create) + g.PUT("/:id", h.Update) + g.DELETE("/:id", h.Delete) + g.GET("/live", h.Live) +} + +func (h *RoutesHandler) reload(ctx context.Context, op string) { + if h.Renderer == nil { + return + } + if err := h.Renderer.Render(ctx); err != nil { + slog.Warn("routes: render+apply failed", "op", op, "error", err) + } +} + +func (h *RoutesHandler) List(c *gin.Context) { + out, err := h.Repo.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"routes": out}) +} + +func (h *RoutesHandler) Create(c *gin.Context) { + var req staticroutes.Route + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + out, err := h.Repo.Create(c.Request.Context(), req) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "route.create", out.Destination, out, h.NodeID) + response.Created(c, out) + h.reload(c.Request.Context(), "create") +} + +func (h *RoutesHandler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req staticroutes.Route + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + out, err := h.Repo.Update(c.Request.Context(), id, req) + if err != nil { + if errors.Is(err, staticroutes.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "route.update", out.Destination, out, h.NodeID) + response.OK(c, out) + h.reload(c.Request.Context(), "update") +} + +func (h *RoutesHandler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.Repo.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, staticroutes.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "route.delete", + strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + response.NoContent(c) + h.reload(c.Request.Context(), "delete") +} + +func (h *RoutesHandler) Live(c *gin.Context) { + routes, err := staticroutes.LiveAll(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"routes": routes}) +} diff --git a/internal/services/staticroutes/staticroutes.go b/internal/services/staticroutes/staticroutes.go new file mode 100644 index 0000000..db2de63 --- /dev/null +++ b/internal/services/staticroutes/staticroutes.go @@ -0,0 +1,280 @@ +// Package staticroutes verwaltet die static_routes-Tabelle, schreibt +// /etc/edgeguard/routes.conf und triggert das Apply-Skript via +// `sudo systemctl reload-or-restart edgeguard-routes.service`. +// +// Live-Routen (was der Kernel aktuell hat) liest /api/v1/routes/live +// direkt via `ip -j route` — kein DB-Roundtrip nötig. +package staticroutes + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/configgen" +) + +var ErrNotFound = errors.New("route not found") + +// ConfPath ist der Pfad der vom apply-Skript gelesen wird. Postinst +// erzeugt das Skript (/usr/sbin/edgeguard-apply-routes) und die +// systemd-Unit (edgeguard-routes.service Type=oneshot). +const ConfPath = "/etc/edgeguard/routes.conf" + +type Repo struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } + +type Route struct { + ID int64 `json:"id"` + Destination string `json:"destination"` + Gateway *string `json:"gateway,omitempty"` + Dev *string `json:"dev,omitempty"` + Metric int `json:"metric"` + TableName string `json:"table_name"` + Active bool `json:"active"` + Comment *string `json:"comment,omitempty"` +} + +const baseSelect = ` +SELECT id, destination, gateway, dev, metric, table_name, active, comment +FROM static_routes +` + +func (r *Repo) List(ctx context.Context) ([]Route, error) { + rows, err := r.Pool.Query(ctx, baseSelect+ + " ORDER BY metric ASC, destination ASC, id ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := []Route{} + for rows.Next() { + var x Route + if err := rows.Scan(&x.ID, &x.Destination, &x.Gateway, &x.Dev, + &x.Metric, &x.TableName, &x.Active, &x.Comment); err != nil { + return nil, err + } + out = append(out, x) + } + return out, rows.Err() +} + +func (r *Repo) Get(ctx context.Context, id int64) (*Route, error) { + row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id) + var x Route + if err := row.Scan(&x.ID, &x.Destination, &x.Gateway, &x.Dev, + &x.Metric, &x.TableName, &x.Active, &x.Comment); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &x, nil +} + +func (r *Repo) Create(ctx context.Context, x Route) (*Route, error) { + if x.Metric == 0 { + x.Metric = 100 + } + if x.TableName == "" { + x.TableName = "main" + } + row := r.Pool.QueryRow(ctx, ` +INSERT INTO static_routes (destination, gateway, dev, metric, table_name, active, comment) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, destination, gateway, dev, metric, table_name, active, comment`, + x.Destination, x.Gateway, x.Dev, x.Metric, x.TableName, x.Active, x.Comment) + var out Route + if err := row.Scan(&out.ID, &out.Destination, &out.Gateway, &out.Dev, + &out.Metric, &out.TableName, &out.Active, &out.Comment); err != nil { + return nil, err + } + return &out, nil +} + +func (r *Repo) Update(ctx context.Context, id int64, x Route) (*Route, error) { + if x.Metric == 0 { + x.Metric = 100 + } + if x.TableName == "" { + x.TableName = "main" + } + row := r.Pool.QueryRow(ctx, ` +UPDATE static_routes SET + destination = $1, gateway = $2, dev = $3, metric = $4, + table_name = $5, active = $6, comment = $7, updated_at = NOW() +WHERE id = $8 +RETURNING id, destination, gateway, dev, metric, table_name, active, comment`, + x.Destination, x.Gateway, x.Dev, x.Metric, x.TableName, x.Active, x.Comment, id) + var out Route + if err := row.Scan(&out.ID, &out.Destination, &out.Gateway, &out.Dev, + &out.Metric, &out.TableName, &out.Active, &out.Comment); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &out, nil +} + +func (r *Repo) Delete(ctx context.Context, id int64) error { + tag, err := r.Pool.Exec(ctx, `DELETE FROM static_routes WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// Render schreibt /etc/edgeguard/routes.conf und triggert das +// apply-Skript via sudo. Aufruf-Pattern wie andere Renderer +// (configgen.AtomicWrite + ReloadService). +type Generator struct { + Pool *pgxpool.Pool + Repo *Repo + Out string +} + +func NewGenerator(pool *pgxpool.Pool) *Generator { + return &Generator{Pool: pool, Repo: New(pool), Out: ConfPath} +} + +func (g *Generator) Render(ctx context.Context) error { + routes, err := g.Repo.List(ctx) + if err != nil { + return fmt.Errorf("list: %w", err) + } + var buf bytes.Buffer + buf.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n") + buf.WriteString("# Read by /usr/sbin/edgeguard-apply-routes on `systemctl restart\n") + buf.WriteString("# edgeguard-routes.service`. Format: destination|gateway|dev|metric|table\n") + buf.WriteString("# (pipe-delimited; empty fields stay empty). Lines beginning with '#'\n") + buf.WriteString("# are ignored. proto edgeguard → safe für `ip route flush proto edgeguard`.\n") + for _, r := range routes { + if !r.Active { + continue + } + gw := "" + if r.Gateway != nil { + gw = *r.Gateway + } + dev := "" + if r.Dev != nil { + dev = *r.Dev + } + fmt.Fprintf(&buf, "%s|%s|%s|%d|%s\n", + sanitize(r.Destination), sanitize(gw), sanitize(dev), + r.Metric, sanitize(r.TableName)) + } + if err := configgen.AtomicWrite(g.Out, buf.Bytes(), 0o644); err != nil { + return fmt.Errorf("write %s: %w", g.Out, err) + } + // Apply — sudo systemctl restart edgeguard-routes.service. Failures + // loggen wir; das File ist geschrieben, ein retry über die UI ist + // trivial. + if err := applyRoutes(ctx); err != nil { + return fmt.Errorf("apply: %w", err) + } + return nil +} + +func applyRoutes(_ context.Context) error { + // Whitelist in postinst: sudo -n /usr/bin/systemctl restart + // edgeguard-routes.service + cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", + "restart", "edgeguard-routes.service") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("systemctl restart edgeguard-routes.service: %s: %w", + strings.TrimSpace(string(out)), err) + } + return nil +} + +// sanitize verhindert dass ein Operator-eingegebenes Feld den Pipe- +// Separator brechen kann. Bei legitimen IP/CIDR-Strings kein Effekt. +func sanitize(s string) string { + s = strings.ReplaceAll(s, "|", "") + s = strings.ReplaceAll(s, "\n", "") + return strings.TrimSpace(s) +} + +// LiveRoute ist die UI-Sicht auf `ip -j route show`. Wir parsen das +// JSON da rein damit das Frontend nicht selbst regex'en muss. +type LiveRoute struct { + Destination string `json:"destination"` + Gateway string `json:"gateway,omitempty"` + Dev string `json:"dev,omitempty"` + Protocol string `json:"protocol,omitempty"` + Scope string `json:"scope,omitempty"` + Source string `json:"src,omitempty"` + Metric int `json:"metric,omitempty"` + Table string `json:"table,omitempty"` + Flags []string `json:"flags,omitempty"` +} + +// ipRouteRaw mirrors `ip -j route`'s shape. Felder die uns nicht +// interessieren ignoriert der JSON-Decoder. +type ipRouteRaw struct { + Dst string `json:"dst"` + Gateway string `json:"gateway"` + Dev string `json:"dev"` + Protocol string `json:"protocol"` + Scope string `json:"scope"` + PrefSrc string `json:"prefsrc"` + Metric int `json:"metric"` + Table string `json:"table"` + Flags []string `json:"flags"` +} + +// LiveAll ruft `ip -j route show table all` auf und gibt die geparste +// Liste zurück. Liest stdout direkt — kein File-Buffering. +func LiveAll(ctx context.Context) ([]LiveRoute, error) { + cmd := exec.CommandContext(ctx, "/usr/sbin/ip", "-j", "route", "show", "table", "all") + out, err := cmd.Output() + if err != nil { + // Manche Distros haben ip in /sbin oder /bin — fallback via PATH. + cmd = exec.CommandContext(ctx, "ip", "-j", "route", "show", "table", "all") + out, err = cmd.Output() + if err != nil { + return nil, fmt.Errorf("ip -j route: %w", err) + } + } + // Wenn ip -j leeres Array liefert kommt "[]" — sauber parsen. + out = bytes.TrimSpace(out) + if len(out) == 0 || bytes.Equal(out, []byte("[]")) { + return []LiveRoute{}, nil + } + var raw []ipRouteRaw + if err := json.Unmarshal(out, &raw); err != nil { + return nil, fmt.Errorf("parse ip -j: %w", err) + } + res := make([]LiveRoute, 0, len(raw)) + for _, r := range raw { + res = append(res, LiveRoute{ + Destination: r.Dst, + Gateway: r.Gateway, + Dev: r.Dev, + Protocol: r.Protocol, + Scope: r.Scope, + Source: r.PrefSrc, + Metric: r.Metric, + Table: r.Table, + Flags: r.Flags, + }) + } + return res, nil +} + diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 6f5a54c..2d3531a 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -78,7 +78,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.65' +const VERSION = '1.0.67' // Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen: // -