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