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:
39
internal/database/migrations/0019_static_routes.sql
Normal file
39
internal/database/migrations/0019_static_routes.sql
Normal 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
128
internal/handlers/routes.go
Normal 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})
|
||||
}
|
||||
280
internal/services/staticroutes/staticroutes.go
Normal file
280
internal/services/staticroutes/staticroutes.go
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user