From 0ee754e231237cf03ead68f71c1617ea8c8f52b1 Mon Sep 17 00:00:00 2001 From: Debian Date: Sat, 30 May 2026 13:25:26 +0200 Subject: [PATCH] feat(network): VLAN/bridge/bond interface provisioner + IP-address apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fügt zwei neue systemd-oneshot-Services hinzu: - edgeguard-interfaces.service: erstellt VLAN/bridge/bond-Interfaces (ip link add/del) wenn der Operator sie über die GUI anlegt/entfernt. Ethernet + WireGuard bleiben OS-managed. - edgeguard-ipaddresses.service: bindet/entfernt IP-Adressen (ip addr add/del) nach jeder GUI-Mutation; läuft jetzt After=edgeguard-interfaces damit Interfaces immer vor den Adressen existieren. Beide Services triggern per applyAsync() in den zugehörigen Handlern (networks.go → Interfaces, ipaddresses.go → Adressen). Diff-Ansatz über *-applied.conf verhindert dass manuell gebundene Adressen/ Interfaces angefasst werden. Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- internal/handlers/ipaddresses.go | 27 ++- internal/handlers/networks.go | 33 +++- internal/services/ipaddresses/apply.go | 87 +++++++++ internal/services/networkifs/apply.go | 103 +++++++++++ .../debian/edgeguard-api/DEBIAN/postinst | 167 ++++++++++++++++++ 6 files changed, 408 insertions(+), 11 deletions(-) create mode 100644 internal/services/ipaddresses/apply.go create mode 100644 internal/services/networkifs/apply.go diff --git a/VERSION b/VERSION index 1fc5b82..b66183a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.15 +1.2.17 diff --git a/internal/handlers/ipaddresses.go b/internal/handlers/ipaddresses.go index 1b08561..06a60b7 100644 --- a/internal/handlers/ipaddresses.go +++ b/internal/handlers/ipaddresses.go @@ -1,7 +1,9 @@ package handlers import ( + "context" "errors" + "log/slog" "strconv" "github.com/gin-gonic/gin" @@ -13,13 +15,27 @@ import ( ) type IPAddressesHandler struct { - Repo *ipaddresses.Repo - Audit *audit.Repo - NodeID string + Repo *ipaddresses.Repo + Generator *ipaddresses.Generator + Audit *audit.Repo + NodeID string } func NewIPAddressesHandler(repo *ipaddresses.Repo, a *audit.Repo, nodeID string) *IPAddressesHandler { - return &IPAddressesHandler{Repo: repo, Audit: a, NodeID: nodeID} + return &IPAddressesHandler{ + Repo: repo, + Generator: ipaddresses.NewGenerator(repo), + Audit: a, + NodeID: nodeID, + } +} + +func (h *IPAddressesHandler) applyAsync(ctx context.Context) { + go func() { + if err := h.Generator.Render(ctx); err != nil { + slog.Warn("ip-addresses: apply failed", "error", err) + } + }() } func (h *IPAddressesHandler) Register(rg *gin.RouterGroup) { @@ -70,6 +86,7 @@ func (h *IPAddressesHandler) Create(c *gin.Context) { } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.create", req.Address, out, h.NodeID) + h.applyAsync(c.Request.Context()) response.Created(c, out) } @@ -94,6 +111,7 @@ func (h *IPAddressesHandler) Update(c *gin.Context) { } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.update", out.Address, out, h.NodeID) + h.applyAsync(c.Request.Context()) response.OK(c, out) } @@ -112,5 +130,6 @@ func (h *IPAddressesHandler) Delete(c *gin.Context) { } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + h.applyAsync(c.Request.Context()) response.NoContent(c) } diff --git a/internal/handlers/networks.go b/internal/handlers/networks.go index d132d93..4d63379 100644 --- a/internal/handlers/networks.go +++ b/internal/handlers/networks.go @@ -1,7 +1,9 @@ package handlers import ( + "context" "errors" + "log/slog" "strconv" "github.com/gin-gonic/gin" @@ -15,18 +17,34 @@ import ( ) type NetworksHandler struct { - Repo *networkifs.Repo - IPs *ipaddresses.Repo - Zones *firewall.ZonesRepo - Audit *audit.Repo - NodeID string + Repo *networkifs.Repo + Generator *networkifs.Generator + IPs *ipaddresses.Repo + Zones *firewall.ZonesRepo + Audit *audit.Repo + NodeID string } func NewNetworksHandler( repo *networkifs.Repo, ips *ipaddresses.Repo, zones *firewall.ZonesRepo, a *audit.Repo, nodeID string, ) *NetworksHandler { - return &NetworksHandler{Repo: repo, IPs: ips, Zones: zones, Audit: a, NodeID: nodeID} + return &NetworksHandler{ + Repo: repo, + Generator: networkifs.NewGenerator(repo), + IPs: ips, + Zones: zones, + Audit: a, + NodeID: nodeID, + } +} + +func (h *NetworksHandler) applyAsync(ctx context.Context) { + go func() { + if err := h.Generator.Render(ctx); err != nil { + slog.Warn("network-interfaces: apply failed", "error", err) + } + }() } func (h *NetworksHandler) Register(rg *gin.RouterGroup) { @@ -88,6 +106,7 @@ func (h *NetworksHandler) Create(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.create", req.Name, out, h.NodeID) + h.applyAsync(c.Request.Context()) response.Created(c, out) } @@ -122,6 +141,7 @@ func (h *NetworksHandler) Update(c *gin.Context) { return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.update", out.Name, out, h.NodeID) + h.applyAsync(c.Request.Context()) response.OK(c, out) } @@ -140,6 +160,7 @@ func (h *NetworksHandler) Delete(c *gin.Context) { } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + h.applyAsync(c.Request.Context()) response.NoContent(c) } diff --git a/internal/services/ipaddresses/apply.go b/internal/services/ipaddresses/apply.go new file mode 100644 index 0000000..455aae6 --- /dev/null +++ b/internal/services/ipaddresses/apply.go @@ -0,0 +1,87 @@ +package ipaddresses + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + "git.netcell-it.de/projekte/edgeguard-native/internal/configgen" +) + +// ConfPath wird von edgeguard-apply-ipaddresses gelesen. +const ConfPath = "/etc/edgeguard/ip-addresses.conf" + +type Generator struct { + Repo *Repo +} + +func NewGenerator(repo *Repo) *Generator { return &Generator{Repo: repo} } + +// Render schreibt /etc/edgeguard/ip-addresses.conf (Format: dev|addr/prefix) +// und triggert das apply-Skript via sudo. +func (g *Generator) Render(ctx context.Context) error { + type addrRow struct { + dev string + addr string + prefix int + } + + rows, err := g.Repo.Pool.Query(ctx, ` + SELECT ni.name, ia.address, ia.prefix + FROM ip_addresses ia + JOIN network_interfaces ni ON ni.id = ia.interface_id + WHERE ia.active = true + ORDER BY ni.name, ia.address`, + ) + if err != nil { + return fmt.Errorf("query: %w", err) + } + defer rows.Close() + + var entries []addrRow + for rows.Next() { + var r addrRow + if err := rows.Scan(&r.dev, &r.addr, &r.prefix); err != nil { + return fmt.Errorf("scan: %w", err) + } + entries = append(entries, r) + } + if err := rows.Err(); err != nil { + return err + } + + var buf bytes.Buffer + buf.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n") + buf.WriteString("# Read by edgeguard-apply-ipaddresses. Format: dev|address/prefix\n") + for _, e := range entries { + fmt.Fprintf(&buf, "%s|%s/%d\n", + sanitize(e.dev), sanitize(e.addr), e.prefix) + } + + if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o644); err != nil { + return fmt.Errorf("write %s: %w", ConfPath, err) + } + if err := applyIPAddresses(); err != nil { + return fmt.Errorf("apply: %w", err) + } + return nil +} + +func applyIPAddresses() error { + cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", + "restart", "edgeguard-ipaddresses.service") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("systemctl restart edgeguard-ipaddresses.service: %s: %w", + strings.TrimSpace(string(out)), err) + } + return nil +} + +func sanitize(s string) string { + s = strings.ReplaceAll(s, "|", "") + s = strings.ReplaceAll(s, "\n", "") + return strings.TrimSpace(s) +} diff --git a/internal/services/networkifs/apply.go b/internal/services/networkifs/apply.go new file mode 100644 index 0000000..761b83b --- /dev/null +++ b/internal/services/networkifs/apply.go @@ -0,0 +1,103 @@ +package networkifs + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + "git.netcell-it.de/projekte/edgeguard-native/internal/configgen" +) + +// ConfPath is read by edgeguard-apply-interfaces. +const ConfPath = "/etc/edgeguard/interfaces.conf" + +type Generator struct { + Repo *Repo +} + +func NewGenerator(repo *Repo) *Generator { return &Generator{Repo: repo} } + +// Render writes /etc/edgeguard/interfaces.conf (format: type|name|parent|vlan_id|mtu|members) +// for VLAN/bridge/bond interfaces and triggers edgeguard-interfaces.service. +// Ethernet and WireGuard interfaces are managed by the OS / wg-quick and are excluded. +func (g *Generator) Render(ctx context.Context) error { + rows, err := g.Repo.Pool.Query(ctx, ` + SELECT type, name, + COALESCE(parent, ''), + COALESCE(vlan_id::text, ''), + COALESCE(mtu::text, ''), + members + FROM network_interfaces + WHERE active = true + AND type IN ('vlan', 'bridge', 'bond') + AND (type = 'vlan' OR jsonb_array_length(members) > 0) + ORDER BY type, name`, + ) + if err != nil { + return fmt.Errorf("query: %w", err) + } + defer rows.Close() + + type ifRow struct { + typ string + name string + parent string + vlanID string + mtu string + members []string + } + + var entries []ifRow + for rows.Next() { + var r ifRow + var membersRaw []byte + if err := rows.Scan(&r.typ, &r.name, &r.parent, &r.vlanID, &r.mtu, &membersRaw); err != nil { + return fmt.Errorf("scan: %w", err) + } + if len(membersRaw) > 0 { + _ = json.Unmarshal(membersRaw, &r.members) + } + entries = append(entries, r) + } + if err := rows.Err(); err != nil { + return err + } + + var buf bytes.Buffer + buf.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n") + buf.WriteString("# Read by edgeguard-apply-interfaces. Format: type|name|parent|vlan_id|mtu|members\n") + for _, e := range entries { + fmt.Fprintf(&buf, "%s|%s|%s|%s|%s|%s\n", + sanitizeIf(e.typ), sanitizeIf(e.name), sanitizeIf(e.parent), + sanitizeIf(e.vlanID), sanitizeIf(e.mtu), + sanitizeIf(strings.Join(e.members, ","))) + } + + if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o644); err != nil { + return fmt.Errorf("write %s: %w", ConfPath, err) + } + if err := applyInterfaces(); err != nil { + return fmt.Errorf("apply: %w", err) + } + return nil +} + +func applyInterfaces() error { + cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", + "restart", "edgeguard-interfaces.service") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("systemctl restart edgeguard-interfaces.service: %s: %w", + strings.TrimSpace(string(out)), err) + } + return nil +} + +func sanitizeIf(s string) string { + s = strings.ReplaceAll(s, "|", "") + s = strings.ReplaceAll(s, "\n", "") + return strings.TrimSpace(s) +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index fef5da6..3610cf7 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -121,6 +121,12 @@ edgeguard ALL=(postgres) NOPASSWD: /usr/bin/pg_dump --clean --if-exists --no-own # Static-Routes: API ruft `sudo systemctl restart edgeguard-routes.service` # nach jedem Mutate, damit das apply-Skript die neue routes.conf anwendet. edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart edgeguard-routes.service +# Network-Interfaces: API ruft `sudo systemctl restart edgeguard-interfaces.service` +# nach jedem Mutate (Create/Update/Delete) von VLAN/bridge/bond-Interfaces. +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart edgeguard-interfaces.service +# IP-Addresses: API ruft `sudo systemctl restart edgeguard-ipaddresses.service` +# nach jedem Mutate (Create/Update/Delete). +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart edgeguard-ipaddresses.service # Self-Upgrade-Pfad (handlers/system.go → /system/upgrade). Whitelist # nur die exakte Unit-Form, damit edgeguard NICHT beliebige systemd- # Units anlegen darf. @@ -400,6 +406,167 @@ ROUTESUNIT chown "$EG_USER":"$EG_USER" /etc/edgeguard/routes.conf fi + # ── Network-Interfaces apply-script + systemd unit ─────────── + # Verwaltet aus /etc/edgeguard/interfaces.conf. VLAN/bridge/bond- + # Interfaces die der Operator anlegt werden sofort via + # `systemctl restart edgeguard-interfaces.service` erstellt. + # Ethernet + WireGuard werden vom OS bzw. wg-quick verwaltet, + # nicht von diesem Skript. Diff-Ansatz analog ip-addresses. + cat > /usr/sbin/edgeguard-apply-interfaces <<'APPLYIFACES' +#!/bin/bash +# Managed by edgeguard — DO NOT EDIT. +CONF=/etc/edgeguard/interfaces.conf +APPLIED=/var/lib/edgeguard/interfaces-applied.conf + +create_iface() { + local typ="$1" name="$2" parent="$3" vlan_id="$4" mtu="$5" members="$6" + case "$typ" in + vlan) + ip link add link "$parent" name "$name" type vlan id "$vlan_id" || return 1 + ;; + bridge) + ip link add "$name" type bridge || return 1 + if [ -n "$members" ]; then + echo "$members" | tr ',' '\n' | while read -r m; do + [ -n "$m" ] && ip link set "$m" master "$name" 2>/dev/null || true + done + fi + ;; + bond) + ip link add "$name" type bond || return 1 + if [ -n "$members" ]; then + echo "$members" | tr ',' '\n' | while read -r m; do + [ -n "$m" ] && { ip link set "$m" down 2>/dev/null; ip link set "$m" master "$name" 2>/dev/null || true; } + done + fi + ;; + *) return 0 ;; + esac + [ -n "$mtu" ] && ip link set "$name" mtu "$mtu" 2>/dev/null || true + ip link set "$name" up 2>/dev/null || true +} + +# Entferne Interfaces die im letzten Apply waren, aber jetzt nicht mehr +if [ -f "$APPLIED" ]; then + while IFS='|' read -r typ name rest; do + [ -z "$typ" ] && continue + case "$typ" in '#'*) continue;; esac + if ! awk -F'|' -v n="$name" 'NR>2 && $2==n {found=1} END{exit !found}' "$CONF" 2>/dev/null; then + ip link set "$name" down 2>/dev/null || true + ip link del "$name" 2>/dev/null || true + fi + done < "$APPLIED" +fi + +[ -f "$CONF" ] || { : > "$APPLIED"; exit 0; } + +# Erstelle/aktualisiere Interfaces aus Config +while IFS='|' read -r typ name parent vlan_id mtu members; do + [ -z "$typ" ] && continue + case "$typ" in '#'*) continue;; esac + if ! ip link show "$name" >/dev/null 2>&1; then + if ! create_iface "$typ" "$name" "$parent" "$vlan_id" "$mtu" "$members"; then + echo "edgeguard-interfaces: failed to create $typ $name" >&2 + fi + else + [ -n "$mtu" ] && ip link set "$name" mtu "$mtu" 2>/dev/null || true + ip link set "$name" up 2>/dev/null || true + fi +done < "$CONF" + +cp "$CONF" "$APPLIED" +APPLYIFACES + chmod 0755 /usr/sbin/edgeguard-apply-interfaces + chown root:root /usr/sbin/edgeguard-apply-interfaces + + cat > /etc/systemd/system/edgeguard-interfaces.service <<'IFACESUNIT' +[Unit] +Description=EdgeGuard managed network interfaces apply +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/sbin/edgeguard-apply-interfaces +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +IFACESUNIT + systemctl daemon-reload + systemctl enable edgeguard-interfaces.service >/dev/null 2>&1 || true + # Initialer Apply — leere /etc/edgeguard/interfaces.conf ist ok + systemctl start edgeguard-interfaces.service 2>/dev/null || true + + if [ ! -f /etc/edgeguard/interfaces.conf ]; then + : > /etc/edgeguard/interfaces.conf + chown "$EG_USER":"$EG_USER" /etc/edgeguard/interfaces.conf + fi + + # ── IP-Addresses apply-script + systemd unit ───────────────── + # Verwaltet aus /etc/edgeguard/ip-addresses.conf. Adressen die + # der Operator über die GUI hinzufügt/entfernt werden sofort via + # `systemctl restart edgeguard-ipaddresses.service` angewendet. + # Diff-Ansatz: /var/lib/edgeguard/ip-addresses-applied.conf hält + # den zuletzt angewendeten Stand; alte Adressen werden entfernt, + # neue hinzugefügt — manuell gebundene Adressen bleiben intakt. + cat > /usr/sbin/edgeguard-apply-ipaddresses <<'APPLYIP' +#!/bin/bash +# Managed by edgeguard — DO NOT EDIT. +set -e +CONF=/etc/edgeguard/ip-addresses.conf +APPLIED=/var/lib/edgeguard/ip-addresses-applied.conf + +# Entferne Adressen die im letzten Apply-Lauf waren, aber jetzt nicht mehr +if [ -f "$APPLIED" ]; then + while IFS='|' read -r dev addrpfx; do + [ -z "$dev" ] && continue + case "$dev" in '#'*) continue;; esac + # Nur entfernen wenn nicht mehr in der neuen Config + if ! grep -qxF "${dev}|${addrpfx}" "$CONF" 2>/dev/null; then + ip addr del "$addrpfx" dev "$dev" 2>/dev/null || true + fi + done < "$APPLIED" +fi + +[ -f "$CONF" ] || { : > "$APPLIED"; exit 0; } + +# Füge neue Adressen hinzu +while IFS='|' read -r dev addrpfx; do + [ -z "$dev" ] && continue + case "$dev" in '#'*) continue;; esac + ip addr add "$addrpfx" dev "$dev" 2>/dev/null || true +done < "$CONF" + +# Aktuelle Config als "applied" speichern +cp "$CONF" "$APPLIED" +APPLYIP + chmod 0755 /usr/sbin/edgeguard-apply-ipaddresses + chown root:root /usr/sbin/edgeguard-apply-ipaddresses + + cat > /etc/systemd/system/edgeguard-ipaddresses.service <<'IPADDRUNIT' +[Unit] +Description=EdgeGuard managed IP addresses apply +After=network-online.target edgeguard-interfaces.service +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/sbin/edgeguard-apply-ipaddresses +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +IPADDRUNIT + systemctl daemon-reload + systemctl enable edgeguard-ipaddresses.service >/dev/null 2>&1 || true + systemctl start edgeguard-ipaddresses.service 2>/dev/null || true + + if [ ! -f /etc/edgeguard/ip-addresses.conf ]; then + : > /etc/edgeguard/ip-addresses.conf + chown "$EG_USER":"$EG_USER" /etc/edgeguard/ip-addresses.conf + fi + # ── Keepalived notify-scripts installieren ─────────────────── # Die Skripte liegen im Package unter /usr/lib/edgeguard/ und # werden von Keepalived als notify_master / notify_backup / check