feat(routes): Static-Routes-Management + Live-View (Networks-Tab)

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) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-12 23:50:26 +02:00
parent dbc14a24a4
commit b031725dfe
14 changed files with 1162 additions and 275 deletions

View File

@@ -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

128
internal/handlers/routes.go Normal file
View File

@@ -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})
}

View File

@@ -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
}