Kaynağa Gözat

feat(sdwan): introduce virtual netdevice & manual static route integration (v1.8.2)

Proxmox Router 2 gün önce
ebeveyn
işleme
0e7fd556a8

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.8.1
+1.8.2

BIN
bin/debtik


BIN
bin/debtik-arm64


BIN
bin/debtik-cli


BIN
bin/debtik-cli-arm64


+ 2 - 1
internal/cli/completer.go

@@ -617,7 +617,8 @@ func (c *Completer) buildCommandTree() *Node {
 	addEnableDisable(ipRouteNode)
 	ipRouteAdd := ipRouteNode.AddChild(&Node{Name: "add"})
 	ipRouteAdd.AddChild(&Node{Name: "dst-address="})
-	ipRouteAdd.AddChild(&Node{Name: "gateway="})
+	ipRouteAdd.AddChild(&Node{Name: "gateway=", Dynamic: dynInterfaces})
+	ipRouteAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
 	ipRouteAdd.AddChild(&Node{Name: "distance="})
 
 	// /ip route rule

+ 13 - 2
internal/cli/executor.go

@@ -1391,6 +1391,9 @@ func (e *Executor) handleIPAddress(parts []string, user *auth.PublicUser) string
 
 // --- IP Route ---
 func (e *Executor) handleIPRoute(parts []string, user *auth.PublicUser) string {
+	if e.routingMgr == nil {
+		return ColorRed + "Routing manager not available\n" + ColorReset
+	}
 	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" {
 		routes := e.routingMgr.ListRoutes()
 		tbl := NewTable("FLAGS", "DST-ADDRESS", "GATEWAY", "DISTANCE", "TABLE", "STATUS")
@@ -1446,6 +1449,13 @@ func (e *Executor) handleIPRoute(parts []string, user *auth.PublicUser) string {
 			dst = params["dst"]
 		}
 		gw := params["gateway"]
+		iface := params["interface"]
+		if gw == "" && iface != "" {
+			gw = iface
+		}
+		if gw != "" && iface == "" && net.ParseIP(gw) == nil {
+			iface = gw
+		}
 		table := params["routing-table"]
 		if table == "" {
 			table = "main"
@@ -1457,13 +1467,14 @@ func (e *Executor) handleIPRoute(parts []string, user *auth.PublicUser) string {
 			}
 		}
 
-		if dst == "" || gw == "" {
-			return ColorRed + "Usage: /ip route add dst-address=<cidr> gateway=<ip> [distance=1] [routing-table=main]\n" + ColorReset
+		if dst == "" || (gw == "" && iface == "") {
+			return ColorRed + "Usage: /ip route add dst-address=<cidr> gateway=<ip|interface> [distance=1] [routing-table=main]\n" + ColorReset
 		}
 
 		r := routing.RouteItem{
 			DstAddress:   dst,
 			Gateway:      gw,
+			Interface:    iface,
 			Distance:     dist,
 			RoutingTable: table,
 			Static:       true,

+ 10 - 2
internal/network/manager.go

@@ -145,13 +145,21 @@ func (m *Manager) ListInterfaces() ([]InterfaceInfo, error) {
 		if IsKernelDummyInterface(attrs.Name) {
 			continue
 		}
+		linkType := link.Type()
+		if linkType == "dummy" && strings.HasPrefix(attrs.Name, "sdwan") {
+			linkType = "sdwan"
+		}
+		operState := strings.ToUpper(attrs.OperState.String())
+		if (operState == "UNKNOWN" || operState == "DOWN") && (attrs.Flags&net.FlagUp != 0) {
+			operState = "UP"
+		}
 		info := InterfaceInfo{
 			Index:     attrs.Index,
 			Name:      attrs.Name,
-			Type:      link.Type(),
+			Type:      linkType,
 			MTU:       attrs.MTU,
 			MAC:       attrs.HardwareAddr.String(),
-			OperState: strings.ToUpper(attrs.OperState.String()),
+			OperState: operState,
 			IPv4:      make([]string, 0),
 			IPv6:      make([]string, 0),
 			Flags:     make([]string, 0),

+ 5 - 1
internal/routing/manager.go

@@ -461,12 +461,16 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
 	for _, sr := range m.staticRoutes {
 		found := false
 		for i, lr := range liveRoutes {
-			if lr.DstAddress == sr.DstAddress && lr.RoutingTable == sr.RoutingTable && (sr.Gateway == "" || lr.Gateway == sr.Gateway) {
+			gwMatches := sr.Gateway == "" || lr.Gateway == sr.Gateway || lr.Interface == sr.Gateway || (sr.Interface != "" && lr.Interface == sr.Interface)
+			if lr.DstAddress == sr.DstAddress && lr.RoutingTable == sr.RoutingTable && gwMatches {
 				liveRoutes[i].Comment = sr.Comment
 				liveRoutes[i].Static = true
 				liveRoutes[i].Dynamic = false
 				liveRoutes[i].Protocol = "static"
 				liveRoutes[i].ID = sr.ID
+				if liveRoutes[i].Gateway == "" && sr.Gateway != "" {
+					liveRoutes[i].Gateway = sr.Gateway
+				}
 				if sr.Distance > 0 && liveRoutes[i].Distance == 0 {
 					liveRoutes[i].Distance = sr.Distance
 				}

+ 64 - 5
internal/sdwan/engine.go

@@ -4,22 +4,28 @@ import (
 	"fmt"
 	"log"
 	"math"
+	"net"
 	"os/exec"
 	"strconv"
 	"strings"
 	"sync"
+
+	"github.com/vishvananda/netlink"
 )
 
-// KernelEngine manages routing tables, ip rules, and netfilter mangle rules for SD-WAN.
+// KernelEngine manages routing tables, ip rules, virtual devices, and netfilter mangle rules for SD-WAN.
 type KernelEngine struct {
-	mu sync.Mutex
+	mu             sync.Mutex
+	virtualDevices map[string]bool
 }
 
 func NewKernelEngine() *KernelEngine {
-	return &KernelEngine{}
+	return &KernelEngine{
+		virtualDevices: make(map[string]bool),
+	}
 }
 
-// Sync applies the complete routing and iptables rules for all active SD-WAN groups.
+// Sync applies the complete routing, virtual devices, and iptables rules for all active SD-WAN groups.
 func (e *KernelEngine) Sync(groups []*SDWANGroup) error {
 	e.mu.Lock()
 	defer e.mu.Unlock()
@@ -38,11 +44,26 @@ func (e *KernelEngine) Sync(groups []*SDWANGroup) error {
 		}
 	}
 
+	// 3. Synchronize virtual dummy interfaces for active groups (e.g. "sdwan0")
+	activeDevices := make(map[string]bool)
+	for _, g := range groups {
+		if !g.Disabled && len(g.Members) > 0 && g.Name != "" {
+			activeDevices[g.Name] = true
+			e.ensureVirtualDevice(g.Name)
+		}
+	}
+	for dev := range e.virtualDevices {
+		if !activeDevices[dev] {
+			e.removeVirtualDevice(dev)
+		}
+	}
+	e.virtualDevices = activeDevices
+
 	if !hasActiveGroups {
 		return nil
 	}
 
-	// 3. Create fresh DEBTIK_SDWAN mangle chain
+	// 4. Create fresh DEBTIK_SDWAN mangle chain
 	_ = exec.Command("iptables", "-t", "mangle", "-N", "DEBTIK_SDWAN").Run()
 	_ = exec.Command("iptables", "-t", "mangle", "-F", "DEBTIK_SDWAN").Run()
 
@@ -352,3 +373,41 @@ func (e *KernelEngine) cleanupIPRules() {
 		}
 	}
 }
+
+func (e *KernelEngine) ensureVirtualDevice(name string) {
+	if name == "" {
+		return
+	}
+	link, err := netlink.LinkByName(name)
+	if err == nil {
+		if link.Attrs().Flags&net.FlagUp == 0 {
+			_ = netlink.LinkSetUp(link)
+		}
+		return
+	}
+
+	dummy := &netlink.Dummy{
+		LinkAttrs: netlink.LinkAttrs{
+			Name: name,
+			MTU:  1500,
+		},
+	}
+	if err := netlink.LinkAdd(dummy); err != nil {
+		_ = exec.Command("ip", "link", "add", name, "type", "dummy").Run()
+		_ = exec.Command("ip", "link", "set", name, "up", "mtu", "1500").Run()
+		return
+	}
+	_ = netlink.LinkSetUp(dummy)
+}
+
+func (e *KernelEngine) removeVirtualDevice(name string) {
+	if name == "" {
+		return
+	}
+	link, err := netlink.LinkByName(name)
+	if err == nil {
+		_ = netlink.LinkDel(link)
+	} else {
+		_ = exec.Command("ip", "link", "del", name).Run()
+	}
+}

+ 16 - 0
internal/sdwan/manager.go

@@ -59,6 +59,22 @@ func (m *Manager) Stop() {
 	if m.monitor != nil {
 		m.monitor.Stop()
 	}
+	if m.engine != nil {
+		m.engine.mu.Lock()
+		for dev := range m.engine.virtualDevices {
+			m.engine.removeVirtualDevice(dev)
+		}
+		m.engine.virtualDevices = make(map[string]bool)
+		m.engine.cleanupMangle()
+		m.engine.cleanupIPRules()
+		m.engine.mu.Unlock()
+	}
+}
+
+// Has checks if an SD-WAN group exists by ID or name.
+func (m *Manager) Has(idOrName string) bool {
+	_, found := m.Get(idOrName)
+	return found
 }
 
 func (m *Manager) load() {

+ 40 - 0
internal/sdwan/sdwan_test.go

@@ -1,9 +1,12 @@
 package sdwan
 
 import (
+	"net"
 	"path/filepath"
 	"testing"
 	"time"
+
+	"github.com/vishvananda/netlink"
 )
 
 func TestSDWANManagerBasics(t *testing.T) {
@@ -142,3 +145,40 @@ func TestHealthMetricsCalculation(t *testing.T) {
 		t.Errorf("expected positive jitter, got %.1f", mem.Jitter)
 	}
 }
+
+func TestSDWANVirtualDeviceLifecycle(t *testing.T) {
+	tmpDir := t.TempDir()
+	storePath := filepath.Join(tmpDir, "sdwan.json")
+
+	mgr := NewManager(storePath)
+	defer mgr.Stop()
+
+	group := &SDWANGroup{
+		Name:    "sdwantest0",
+		Mode:    ModeFailover,
+		AutoNAT: false,
+		Members: []*SDWANMember{
+			{Interface: "lo", Gateway: "127.0.0.1", Weight: 100},
+		},
+	}
+	if err := mgr.Save(group); err != nil {
+		t.Fatalf("failed saving group: %v", err)
+	}
+
+	// Verify virtual device exists in kernel
+	link, err := netlink.LinkByName("sdwantest0")
+	if err != nil {
+		t.Fatalf("expected sdwantest0 virtual device to be created in kernel: %v", err)
+	}
+	if link.Attrs().Flags&net.FlagUp == 0 {
+		t.Fatalf("expected sdwantest0 to be UP")
+	}
+
+	// Delete group and verify virtual device is removed
+	if err := mgr.Delete("sdwantest0"); err != nil {
+		t.Fatalf("failed to delete group: %v", err)
+	}
+	if _, err := netlink.LinkByName("sdwantest0"); err == nil {
+		t.Fatalf("expected sdwantest0 virtual device to be removed from kernel after group delete")
+	}
+}