feat(network): VLAN/bridge/bond interface provisioner + IP-address apply

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 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-30 13:25:26 +02:00
parent eaa6a04234
commit 0ee754e231
6 changed files with 408 additions and 11 deletions

View File

@@ -1 +1 @@
1.2.15 1.2.17

View File

@@ -1,7 +1,9 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"log/slog"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -14,12 +16,26 @@ import (
type IPAddressesHandler struct { type IPAddressesHandler struct {
Repo *ipaddresses.Repo Repo *ipaddresses.Repo
Generator *ipaddresses.Generator
Audit *audit.Repo Audit *audit.Repo
NodeID string NodeID string
} }
func NewIPAddressesHandler(repo *ipaddresses.Repo, a *audit.Repo, nodeID string) *IPAddressesHandler { 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) { 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", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.create",
req.Address, out, h.NodeID) req.Address, out, h.NodeID)
h.applyAsync(c.Request.Context())
response.Created(c, out) 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", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.update",
out.Address, out, h.NodeID) out.Address, out, h.NodeID)
h.applyAsync(c.Request.Context())
response.OK(c, out) 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", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
h.applyAsync(c.Request.Context())
response.NoContent(c) response.NoContent(c)
} }

View File

@@ -1,7 +1,9 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"log/slog"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -16,6 +18,7 @@ import (
type NetworksHandler struct { type NetworksHandler struct {
Repo *networkifs.Repo Repo *networkifs.Repo
Generator *networkifs.Generator
IPs *ipaddresses.Repo IPs *ipaddresses.Repo
Zones *firewall.ZonesRepo Zones *firewall.ZonesRepo
Audit *audit.Repo Audit *audit.Repo
@@ -26,7 +29,22 @@ func NewNetworksHandler(
repo *networkifs.Repo, ips *ipaddresses.Repo, repo *networkifs.Repo, ips *ipaddresses.Repo,
zones *firewall.ZonesRepo, a *audit.Repo, nodeID string, zones *firewall.ZonesRepo, a *audit.Repo, nodeID string,
) *NetworksHandler { ) *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) { func (h *NetworksHandler) Register(rg *gin.RouterGroup) {
@@ -88,6 +106,7 @@ func (h *NetworksHandler) Create(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.create", req.Name, out, h.NodeID) _ = 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) response.Created(c, out)
} }
@@ -122,6 +141,7 @@ func (h *NetworksHandler) Update(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.update", out.Name, out, h.NodeID) _ = 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) 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", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
h.applyAsync(c.Request.Context())
response.NoContent(c) response.NoContent(c)
} }

View File

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

View File

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

View File

@@ -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` # Static-Routes: API ruft `sudo systemctl restart edgeguard-routes.service`
# nach jedem Mutate, damit das apply-Skript die neue routes.conf anwendet. # nach jedem Mutate, damit das apply-Skript die neue routes.conf anwendet.
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart edgeguard-routes.service 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 # Self-Upgrade-Pfad (handlers/system.go → /system/upgrade). Whitelist
# nur die exakte Unit-Form, damit edgeguard NICHT beliebige systemd- # nur die exakte Unit-Form, damit edgeguard NICHT beliebige systemd-
# Units anlegen darf. # Units anlegen darf.
@@ -400,6 +406,167 @@ ROUTESUNIT
chown "$EG_USER":"$EG_USER" /etc/edgeguard/routes.conf chown "$EG_USER":"$EG_USER" /etc/edgeguard/routes.conf
fi 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 ─────────────────── # ── Keepalived notify-scripts installieren ───────────────────
# Die Skripte liegen im Package unter /usr/lib/edgeguard/ und # Die Skripte liegen im Package unter /usr/lib/edgeguard/ und
# werden von Keepalived als notify_master / notify_backup / check # werden von Keepalived als notify_master / notify_backup / check