Files
edgeguard-native/internal/services/staticroutes/staticroutes.go
Debian b031725dfe 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>
2026-05-12 23:50:26 +02:00

281 lines
8.4 KiB
Go

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