Pārlūkot izejas kodu

feat(sdwan): implement Software-Defined WAN (SD-WAN) multi-link load balancing & SLA health prober (v1.8.0)

Proxmox Router 2 dienas atpakaļ
vecāks
revīzija
786dba8a06

+ 1 - 1
VERSION

@@ -1 +1 @@
-1.7.0
+1.8.0

BIN
bin/debtik


BIN
bin/debtik-arm64


BIN
bin/debtik-cli


BIN
bin/debtik-cli-arm64


+ 5 - 0
cmd/server/main.go

@@ -30,6 +30,7 @@ import (
 	"debtik/internal/network"
 	"debtik/internal/reverseproxy"
 	"debtik/internal/routing"
+	"debtik/internal/sdwan"
 	"debtik/internal/services"
 	"debtik/internal/tools"
 	"debtik/internal/vpn"
@@ -83,6 +84,8 @@ func main() {
 	defer proxyMgr.Stop()
 
 	backupMgr := backup.NewManager("", "", "", nil)
+	sdwanMgr := sdwan.NewManager()
+	defer sdwanMgr.Stop()
 
 	webHandler := web.Handler()
 
@@ -104,6 +107,7 @@ func main() {
 		certMgr,
 		proxyMgr,
 		backupMgr,
+		sdwanMgr,
 	)
 
 	// Determine Telnet & SSH addresses from services config or flags
@@ -179,6 +183,7 @@ func main() {
 		certMgr,
 		proxyMgr,
 		backupMgr,
+		sdwanMgr,
 		webHandler,
 	)
 

+ 19 - 1
internal/api/api.go

@@ -21,6 +21,7 @@ import (
 	"debtik/internal/network"
 	"debtik/internal/reverseproxy"
 	"debtik/internal/routing"
+	"debtik/internal/sdwan"
 	"debtik/internal/services"
 	"debtik/internal/system"
 	"debtik/internal/tools"
@@ -50,14 +51,20 @@ type Server struct {
 	certMgr       *certs.CertManager
 	proxyMgr      *reverseproxy.Manager
 	backupMgr     *backup.Manager
+	sdwanMgr      *sdwan.Manager
 	mux           *http.ServeMux
 	fsHandler     http.Handler
 }
 
-func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, authMgr *auth.Manager, servicesMgr *services.Manager, certMgr *certs.CertManager, proxyMgr *reverseproxy.Manager, backupMgr *backup.Manager, fsHandler http.Handler) *Server {
+func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager, metaMgr *network.MetadataManager, vpnMgr *vpn.Manager, fwMgr *firewall.FirewallManager, routingMgr *routing.RoutingManager, dynRoutingMgr *dynamicrouting.Manager, bridgeMgr *bridge.BridgeManager, toolsMgr *tools.Manager, dhcpMgr *dhcp.Manager, dhcpServerMgr *dhcp.ServerManager, vrrpMgr *vrrp.Manager, bwtestMgr *bwtest.Manager, dnsMgr *dns.Manager, authMgr *auth.Manager, servicesMgr *services.Manager, certMgr *certs.CertManager, proxyMgr *reverseproxy.Manager, backupMgr *backup.Manager, sdwanMgr *sdwan.Manager, fsHandler http.Handler) *Server {
 	if backupMgr == nil {
 		backupMgr = backup.NewManager("", "", "", nil)
 	}
+	if sdwanMgr == nil {
+		sdwanMgr = sdwan.NewManager()
+	}
+	firewall.SDWANResolver = sdwanMgr.ResolveGroupInterfaces
+
 	s := &Server{
 		netMgr:        netMgr,
 		persistMgr:    persistMgr,
@@ -78,6 +85,7 @@ func NewServer(netMgr *network.Manager, persistMgr *network.PersistenceManager,
 		certMgr:       certMgr,
 		proxyMgr:      proxyMgr,
 		backupMgr:     backupMgr,
+		sdwanMgr:      sdwanMgr,
 		mux:           http.NewServeMux(),
 		fsHandler:     fsHandler,
 	}
@@ -284,6 +292,16 @@ func (s *Server) registerRoutes() {
 	s.mux.HandleFunc("POST /api/interface/vxlan/vteps", s.handleSaveVXLANVtep)
 	s.mux.HandleFunc("DELETE /api/interface/vxlan/{id}/vteps/{vtepid}", s.handleDeleteVXLANVtep)
 
+	// SD-WAN (Software-Defined WAN Multi-Link Load Balancing & SLA)
+	s.mux.HandleFunc("GET /api/sdwan/groups", s.handleListSDWANGroups)
+	s.mux.HandleFunc("POST /api/sdwan/groups", s.handleSaveSDWANGroup)
+	s.mux.HandleFunc("GET /api/sdwan/groups/{id}", s.handleGetSDWANGroup)
+	s.mux.HandleFunc("DELETE /api/sdwan/groups/{id}", s.handleDeleteSDWANGroup)
+	s.mux.HandleFunc("POST /api/sdwan/groups/{id}/state", s.handleSetSDWANGroupState)
+	s.mux.HandleFunc("POST /api/sdwan/groups/{id}/members", s.handleAddSDWANMember)
+	s.mux.HandleFunc("DELETE /api/sdwan/groups/{id}/members/{iface}", s.handleDeleteSDWANMember)
+	s.mux.HandleFunc("GET /api/sdwan/telemetry", s.handleGetSDWANTelemetry)
+
 	// AeroTUN (High-speed ChaCha20-Poly1305 + LZ4 Encrypted UDP Tunnel)
 	s.mux.HandleFunc("GET /api/vpn/aerotun/tunnels", s.handleListAeroTUNTunnels)
 	s.mux.HandleFunc("POST /api/vpn/aerotun/tunnels", s.handleSaveAeroTUNTunnel)

+ 144 - 0
internal/api/sdwan_handlers.go

@@ -0,0 +1,144 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"debtik/internal/sdwan"
+)
+
+// ======================== SD-WAN Handlers ========================
+
+func (s *Server) handleListSDWANGroups(w http.ResponseWriter, r *http.Request) {
+	if s.sdwanMgr == nil {
+		writeJSON(w, http.StatusOK, []*sdwan.SDWANGroup{})
+		return
+	}
+	list := s.sdwanMgr.List()
+	writeJSON(w, http.StatusOK, list)
+}
+
+func (s *Server) handleGetSDWANGroup(w http.ResponseWriter, r *http.Request) {
+	if s.sdwanMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "sd-wan subsystem unavailable")
+		return
+	}
+	id := r.PathValue("id")
+	g, ok := s.sdwanMgr.Get(id)
+	if !ok {
+		writeError(w, http.StatusNotFound, "sd-wan group not found")
+		return
+	}
+	writeJSON(w, http.StatusOK, g)
+}
+
+func (s *Server) handleSaveSDWANGroup(w http.ResponseWriter, r *http.Request) {
+	if s.sdwanMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "sd-wan subsystem unavailable")
+		return
+	}
+	var g sdwan.SDWANGroup
+	if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	if err := s.sdwanMgr.Save(&g); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success": true,
+		"id":      g.ID,
+		"name":    g.Name,
+		"mode":    g.Mode,
+	})
+}
+
+func (s *Server) handleDeleteSDWANGroup(w http.ResponseWriter, r *http.Request) {
+	if s.sdwanMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "sd-wan subsystem unavailable")
+		return
+	}
+	id := r.PathValue("id")
+	if id == "" {
+		writeError(w, http.StatusBadRequest, "group id required")
+		return
+	}
+	if err := s.sdwanMgr.Delete(id); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id})
+}
+
+func (s *Server) handleSetSDWANGroupState(w http.ResponseWriter, r *http.Request) {
+	if s.sdwanMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "sd-wan subsystem unavailable")
+		return
+	}
+	id := r.PathValue("id")
+	var req struct {
+		Disabled bool `json:"disabled"`
+	}
+	_ = json.NewDecoder(r.Body).Decode(&req)
+	if err := s.sdwanMgr.SetState(id, req.Disabled); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "disabled": req.Disabled})
+}
+
+func (s *Server) handleAddSDWANMember(w http.ResponseWriter, r *http.Request) {
+	if s.sdwanMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "sd-wan subsystem unavailable")
+		return
+	}
+	id := r.PathValue("id")
+	var mem sdwan.SDWANMember
+	if err := json.NewDecoder(r.Body).Decode(&mem); err != nil {
+		writeError(w, http.StatusBadRequest, "invalid request body")
+		return
+	}
+	if mem.Interface == "" {
+		writeError(w, http.StatusBadRequest, "interface is required")
+		return
+	}
+	if err := s.sdwanMgr.AddMember(id, &mem); err != nil {
+		writeError(w, http.StatusBadRequest, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{
+		"success":   true,
+		"id":        mem.ID,
+		"interface": mem.Interface,
+		"weight":    mem.Weight,
+		"priority":  mem.Priority,
+	})
+}
+
+func (s *Server) handleDeleteSDWANMember(w http.ResponseWriter, r *http.Request) {
+	if s.sdwanMgr == nil {
+		writeError(w, http.StatusServiceUnavailable, "sd-wan subsystem unavailable")
+		return
+	}
+	id := r.PathValue("id")
+	iface := r.PathValue("iface")
+	if id == "" || iface == "" {
+		writeError(w, http.StatusBadRequest, "group id and member interface required")
+		return
+	}
+	if err := s.sdwanMgr.DeleteMember(id, iface); err != nil {
+		writeError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	writeJSON(w, http.StatusOK, map[string]any{"success": true, "id": id, "interface": iface})
+}
+
+func (s *Server) handleGetSDWANTelemetry(w http.ResponseWriter, r *http.Request) {
+	if s.sdwanMgr == nil {
+		writeJSON(w, http.StatusOK, sdwan.TelemetrySummary{})
+		return
+	}
+	summary := s.sdwanMgr.GetSummary()
+	writeJSON(w, http.StatusOK, summary)
+}

+ 141 - 0
internal/api/sdwan_handlers_test.go

@@ -0,0 +1,141 @@
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+
+	"debtik/internal/auth"
+	"debtik/internal/sdwan"
+)
+
+func setupTestSDWANServer(t *testing.T) (*Server, *sdwan.Manager) {
+	tmpDir := t.TempDir()
+	cfgPath := filepath.Join(tmpDir, "sdwan.json")
+	sdwanMgr := sdwan.NewManager(cfgPath)
+
+	authPath := filepath.Join(tmpDir, "users.json")
+	authMgr := auth.NewManager(authPath)
+
+	srv := &Server{
+		sdwanMgr:  sdwanMgr,
+		authMgr:   authMgr,
+		mux:       http.NewServeMux(),
+		fsHandler: http.NotFoundHandler(),
+	}
+	srv.registerRoutes()
+	return srv, sdwanMgr
+}
+
+func TestSDWANAPIEndpoints(t *testing.T) {
+	srv, sdwanMgr := setupTestSDWANServer(t)
+	defer sdwanMgr.Stop()
+
+	// 1. GET /api/sdwan/groups (Initial empty list)
+	req := httptest.NewRequest(http.MethodGet, "/api/sdwan/groups", nil)
+	rec := httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("GET /api/sdwan/groups status = %d; want 200", rec.Code)
+	}
+
+	var groups []*sdwan.SDWANGroup
+	if err := json.NewDecoder(rec.Body).Decode(&groups); err != nil {
+		t.Fatalf("failed to decode groups: %v", err)
+	}
+	if len(groups) != 0 {
+		t.Fatalf("expected 0 groups, got %d", len(groups))
+	}
+
+	// 2. POST /api/sdwan/groups (Create group)
+	newGroup := sdwan.SDWANGroup{
+		Name:          "sdwan0",
+		Mode:          sdwan.ModeWeighted,
+		AutoNAT:       true,
+		ProbeInterval: 3,
+		Comment:       "Primary Multi-WAN",
+	}
+	body, _ := json.Marshal(newGroup)
+	req = httptest.NewRequest(http.MethodPost, "/api/sdwan/groups", bytes.NewReader(body))
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("POST /api/sdwan/groups status = %d; body = %s", rec.Code, rec.Body.String())
+	}
+
+	// 3. GET /api/sdwan/groups (Check group created)
+	req = httptest.NewRequest(http.MethodGet, "/api/sdwan/groups", nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("GET /api/sdwan/groups status = %d", rec.Code)
+	}
+	groups = nil
+	_ = json.NewDecoder(rec.Body).Decode(&groups)
+	if len(groups) != 1 || groups[0].Name != "sdwan0" {
+		t.Fatalf("expected 1 group named sdwan0, got %+v", groups)
+	}
+	groupID := groups[0].ID
+
+	// 4. POST /api/sdwan/groups/{id}/members (Add member)
+	memPayload := map[string]interface{}{
+		"interface":    "eth0",
+		"gateway":      "192.168.1.1",
+		"weight":       60,
+		"priority":     1,
+		"target_probe": "1.1.1.1",
+	}
+	body, _ = json.Marshal(memPayload)
+	req = httptest.NewRequest(http.MethodPost, "/api/sdwan/groups/"+groupID+"/members", bytes.NewReader(body))
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("POST /api/sdwan/groups/%s/members status = %d; body = %s", groupID, rec.Code, rec.Body.String())
+	}
+
+	// 5. GET /api/sdwan/telemetry
+	req = httptest.NewRequest(http.MethodGet, "/api/sdwan/telemetry", nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("GET /api/sdwan/telemetry status = %d", rec.Code)
+	}
+	var telemetry sdwan.TelemetrySummary
+	if err := json.NewDecoder(rec.Body).Decode(&telemetry); err != nil {
+		t.Fatalf("failed to decode telemetry: %v", err)
+	}
+	if telemetry.TotalGroups != 1 || telemetry.TotalMembers != 1 {
+		t.Fatalf("unexpected telemetry: %+v", telemetry)
+	}
+
+	// 6. POST /api/sdwan/groups/{id}/state (Disable group)
+	statePayload := map[string]interface{}{
+		"disabled": true,
+	}
+	body, _ = json.Marshal(statePayload)
+	req = httptest.NewRequest(http.MethodPost, "/api/sdwan/groups/"+groupID+"/state", bytes.NewReader(body))
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("POST /api/sdwan/groups/%s/state status = %d", groupID, rec.Code)
+	}
+
+	// 7. DELETE /api/sdwan/groups/{id}/members/{iface}
+	req = httptest.NewRequest(http.MethodDelete, "/api/sdwan/groups/"+groupID+"/members/eth0", nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("DELETE /api/sdwan/groups/%s/members/eth0 status = %d; body = %s", groupID, rec.Code, rec.Body.String())
+	}
+
+	// 8. DELETE /api/sdwan/groups/{id}
+	req = httptest.NewRequest(http.MethodDelete, "/api/sdwan/groups/"+groupID, nil)
+	rec = httptest.NewRecorder()
+	srv.mux.ServeHTTP(rec, req)
+	if rec.Code != http.StatusOK {
+		t.Fatalf("DELETE /api/sdwan/groups/%s status = %d; body = %s", groupID, rec.Code, rec.Body.String())
+	}
+}

+ 82 - 0
internal/cli/command_test.go

@@ -8,6 +8,7 @@ import (
 
 	"debtik/internal/aerotun"
 	"debtik/internal/auth"
+	"debtik/internal/sdwan"
 	"debtik/internal/vpn"
 )
 
@@ -136,3 +137,84 @@ func TestVXLANCLIConfigurationAndPermissions(t *testing.T) {
 		t.Fatalf("unexpected error removing vxlan: %s", output)
 	}
 }
+
+func TestSDWANCLIConfigurationAndPermissions(t *testing.T) {
+	sdwanMgr := sdwan.NewManager(filepath.Join(t.TempDir(), "sdwan.json"))
+	defer sdwanMgr.Stop()
+	e := &Executor{sdwanMgr: sdwanMgr}
+	admin := &auth.PublicUser{Role: auth.RoleFull}
+	viewer := &auth.PublicUser{Role: auth.RoleRead}
+
+	// 1. Add SD-WAN group
+	output, _ := e.Execute(`/ip sdwan add name=sdwan0 mode=weighted auto-nat=yes probe-interval=5 comment="Multi-WAN LB"`, admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("unexpected error adding sdwan group: %s", output)
+	}
+
+	// 2. Print SD-WAN groups
+	output, _ = e.Execute("/ip sdwan print", viewer)
+	if !strings.Contains(output, "sdwan0") || !strings.Contains(output, "weighted") {
+		t.Fatalf("sdwan0 not found in print output: %s", output)
+	}
+
+	// 3. Status
+	output, _ = e.Execute("/ip sdwan status", viewer)
+	if !strings.Contains(output, "SD-WAN Telemetry Status") {
+		t.Fatalf("status output unexpected: %s", output)
+	}
+
+	// 4. Add Members
+	output, _ = e.Execute("/ip sdwan member add group=sdwan0 interface=eth0 gateway=192.168.1.1 weight=70 target-probe=1.1.1.1", admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("error adding member eth0: %s", output)
+	}
+	output, _ = e.Execute("/ip sdwan member add group=sdwan0 interface=eth1 gateway=192.168.2.1 weight=30 target-probe=8.8.8.8", admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("error adding member eth1: %s", output)
+	}
+
+	// 5. Print Members
+	output, _ = e.Execute("/ip sdwan member print", viewer)
+	if !strings.Contains(output, "eth0") || !strings.Contains(output, "eth1") {
+		t.Fatalf("members not found in member print output: %s", output)
+	}
+
+	// 6. Permission check for viewer
+	output, _ = e.Execute("/ip sdwan remove sdwan0", viewer)
+	if !strings.Contains(output, "Permission denied") {
+		t.Fatalf("expected permission denied for viewer, got: %s", output)
+	}
+
+	// 7. Member set & disable
+	output, _ = e.Execute("/ip sdwan member set group=sdwan0 interface=eth0 weight=80", admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("error setting member weight: %s", output)
+	}
+
+	output, _ = e.Execute("/ip sdwan member disable group=sdwan0 interface=eth1", admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("error disabling member eth1: %s", output)
+	}
+
+	// 8. Group disable & enable
+	output, _ = e.Execute("/ip sdwan disable sdwan0", admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("error disabling sdwan0: %s", output)
+	}
+	output, _ = e.Execute("/ip sdwan enable sdwan0", admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("error enabling sdwan0: %s", output)
+	}
+
+	// 9. Remove member
+	output, _ = e.Execute("/ip sdwan member remove group=sdwan0 interface=eth1", admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("error removing member eth1: %s", output)
+	}
+
+	// 10. Remove group
+	output, _ = e.Execute("/ip sdwan remove sdwan0", admin)
+	if strings.Contains(output, "Error") {
+		t.Fatalf("error removing group sdwan0: %s", output)
+	}
+}

+ 68 - 2
internal/cli/completer.go

@@ -343,11 +343,11 @@ func colorizeCandidate(s string) string {
 		return ColorYellow + s + ColorReset
 	}
 	switch s {
-	case "print", "add", "remove", "enable", "disable", "set", "reboot", "flush", "renew", "release", "connect", "disconnect":
+	case "print", "add", "remove", "enable", "disable", "set", "reboot", "flush", "renew", "release", "connect", "disconnect", "status":
 		return ColorGreen + s + ColorReset
 	case "interface", "ip", "routing", "tool", "log", "user", "system", "certificate", "certificates",
 		"vlan", "bridge", "wireguard", "eoip", "vxlan", "vrrp", "pppoe-client",
-		"address", "route", "dhcp-client", "dhcp-server", "dns", "firewall", "service",
+		"address", "route", "dhcp-client", "dhcp-server", "dns", "firewall", "service", "reverse-proxy", "sdwan", "sd-wan", "member", "members",
 		"filter", "nat", "mangle", "address-list", "bgp", "ospf", "port", "peers", "lease", "network", "cache", "static", "rule", "table", "instance", "area", "neighbor", "resource", "identity":
 		return ColorCyan + s + ColorReset
 	case "www", "www-ssl", "ssh", "telnet", "web", "web-ssl":
@@ -441,6 +441,17 @@ func (c *Completer) buildCommandTree() *Node {
 		return nil
 	}
 
+	dynSDWANGroups := func() []string {
+		if c.executor != nil && c.executor.sdwanMgr != nil {
+			var names []string
+			for _, g := range c.executor.sdwanMgr.List() {
+				names = append(names, g.Name)
+			}
+			return names
+		}
+		return nil
+	}
+
 	// Common standard actions
 	addPrint := func(n *Node) {
 		n.AddChild(&Node{Name: "print", Aliases: []string{"pr", "show", "list"}})
@@ -747,6 +758,61 @@ func (c *Completer) buildCommandTree() *Node {
 	serviceSet.AddChild(&Node{Name: "disabled=", Dynamic: func() []string { return []string{"yes", "no"} }})
 	serviceSet.AddChild(&Node{Name: "certificate=", Dynamic: dynCertificates})
 
+	// /ip sdwan
+	sdwanNode := ipNode.AddChild(&Node{Name: "sdwan", Aliases: []string{"sd-wan"}})
+	addPrint(sdwanNode)
+	sdwanNode.AddChild(&Node{Name: "status"})
+	addRemove(sdwanNode)
+	addEnableDisable(sdwanNode)
+
+	sdwanAdd := sdwanNode.AddChild(&Node{Name: "add"})
+	sdwanAdd.AddChild(&Node{Name: "name="})
+	sdwanAdd.AddChild(&Node{Name: "mode=", Dynamic: func() []string {
+		return []string{"weighted", "pcc", "lowest-latency", "lowest-loss", "failover"}
+	}})
+	sdwanAdd.AddChild(&Node{Name: "auto-nat=", Dynamic: func() []string { return []string{"yes", "no"} }})
+	sdwanAdd.AddChild(&Node{Name: "probe-interval="})
+	sdwanAdd.AddChild(&Node{Name: "comment="})
+
+	sdwanSet := sdwanNode.AddChild(&Node{Name: "set", Dynamic: dynSDWANGroups})
+	sdwanSet.AddChild(&Node{Name: "mode=", Dynamic: func() []string {
+		return []string{"weighted", "pcc", "lowest-latency", "lowest-loss", "failover"}
+	}})
+	sdwanSet.AddChild(&Node{Name: "auto-nat=", Dynamic: func() []string { return []string{"yes", "no"} }})
+	sdwanSet.AddChild(&Node{Name: "probe-interval="})
+	sdwanSet.AddChild(&Node{Name: "comment="})
+
+	// /ip sdwan member
+	sdwanMemNode := sdwanNode.AddChild(&Node{Name: "member", Aliases: []string{"members"}})
+	addPrint(sdwanMemNode)
+	addRemove(sdwanMemNode)
+	addEnableDisable(sdwanMemNode)
+
+	sdwanMemAdd := sdwanMemNode.AddChild(&Node{Name: "add"})
+	sdwanMemAdd.AddChild(&Node{Name: "group=", Dynamic: dynSDWANGroups})
+	sdwanMemAdd.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	sdwanMemAdd.AddChild(&Node{Name: "gateway="})
+	sdwanMemAdd.AddChild(&Node{Name: "weight="})
+	sdwanMemAdd.AddChild(&Node{Name: "priority="})
+	sdwanMemAdd.AddChild(&Node{Name: "target-probe="})
+	sdwanMemAdd.AddChild(&Node{Name: "max-latency="})
+	sdwanMemAdd.AddChild(&Node{Name: "max-loss="})
+	sdwanMemAdd.AddChild(&Node{Name: "comment="})
+
+	sdwanMemSet := sdwanMemNode.AddChild(&Node{Name: "set"})
+	sdwanMemSet.AddChild(&Node{Name: "group=", Dynamic: dynSDWANGroups})
+	sdwanMemSet.AddChild(&Node{Name: "interface=", Dynamic: dynInterfaces})
+	sdwanMemSet.AddChild(&Node{Name: "gateway="})
+	sdwanMemSet.AddChild(&Node{Name: "weight="})
+	sdwanMemSet.AddChild(&Node{Name: "priority="})
+	sdwanMemSet.AddChild(&Node{Name: "target-probe="})
+	sdwanMemSet.AddChild(&Node{Name: "max-latency="})
+	sdwanMemSet.AddChild(&Node{Name: "max-loss="})
+	sdwanMemSet.AddChild(&Node{Name: "comment="})
+
+	// Also accessible directly at root /sdwan
+	root.AddChild(sdwanNode)
+
 	// 3. /routing
 	routingNode := root.AddChild(&Node{Name: "routing"})
 

+ 421 - 0
internal/cli/executor.go

@@ -25,6 +25,7 @@ import (
 	"debtik/internal/network"
 	"debtik/internal/reverseproxy"
 	"debtik/internal/routing"
+	"debtik/internal/sdwan"
 	"debtik/internal/services"
 	"debtik/internal/system"
 	"debtik/internal/vpn"
@@ -48,6 +49,7 @@ type Executor struct {
 	certMgr       *certs.CertManager
 	proxyMgr      *reverseproxy.Manager
 	backupMgr     *backup.Manager
+	sdwanMgr      *sdwan.Manager
 }
 
 func NewExecutor(
@@ -67,10 +69,14 @@ func NewExecutor(
 	certMgr *certs.CertManager,
 	proxyMgr *reverseproxy.Manager,
 	backupMgr *backup.Manager,
+	sdwanMgr *sdwan.Manager,
 ) *Executor {
 	if backupMgr == nil {
 		backupMgr = backup.NewManager("", "", "", nil)
 	}
+	if sdwanMgr == nil {
+		sdwanMgr = sdwan.NewManager()
+	}
 	return &Executor{
 		authMgr:       authMgr,
 		netMgr:        netMgr,
@@ -88,6 +94,7 @@ func NewExecutor(
 		certMgr:       certMgr,
 		proxyMgr:      proxyMgr,
 		backupMgr:     backupMgr,
+		sdwanMgr:      sdwanMgr,
 	}
 }
 
@@ -162,6 +169,9 @@ func (e *Executor) Execute(rawCmd string, user *auth.PublicUser) (output string,
 	case "reverse-proxy", "reverseproxy":
 		return e.handleIPReverseProxy(parts[1:], user), false
 
+	case "sdwan", "sd-wan":
+		return e.handleIPSDWAN(parts[1:], user), false
+
 	default:
 		return fmt.Sprintf(ColorRed+"Unknown command or syntax error: %q. Type 'help' or '?' for command list.\n"+ColorReset, cmd), false
 	}
@@ -1311,6 +1321,9 @@ func (e *Executor) handleIP(parts []string, user *auth.PublicUser) string {
 	case "reverse-proxy", "reverseproxy", "proxy":
 		return e.handleIPReverseProxy(parts[1:], user)
 
+	case "sdwan", "sd-wan":
+		return e.handleIPSDWAN(parts[1:], user)
+
 	default:
 		return ColorRed + "Unknown IP command: " + sub + "\n" + ColorReset
 	}
@@ -2723,6 +2736,8 @@ func (e *Executor) showHelp() string {
 	b.WriteString("      /ip firewall mangle        - Packet marking and QoS (print, add, remove)\n")
 	b.WriteString("      /ip firewall address-list  - Dynamic & static address groups (print, add, remove)\n")
 	b.WriteString("    /ip service                  - Management services: Telnet, SSH, Web (print, enable, disable, set)\n")
+	b.WriteString("    /ip reverse-proxy            - HTTP/HTTPS Reverse Proxy & Path Router (print, add, set, remove, enable, disable)\n")
+	b.WriteString("    /ip sdwan                    - Software-Defined WAN multi-link aggregation (print, status, add, set, remove, enable, disable, member)\n")
 	b.WriteString("  /routing                       - Dynamic routing engine\n")
 	b.WriteString("    /routing bgp                 - BGP instance, peers, routes (instance, peer, routes)\n")
 	b.WriteString("    /routing ospf                - OSPF areas, interfaces, neighbors (instance, area, neighbor)\n")
@@ -3069,6 +3084,412 @@ func (e *Executor) handleIPReverseProxy(parts []string, user *auth.PublicUser) s
 	}
 }
 
+// ==================== SD-WAN ====================
+
+func (e *Executor) handleIPSDWAN(parts []string, user *auth.PublicUser) string {
+	if e.sdwanMgr == nil {
+		return ColorRed + "SD-WAN service not available\n" + ColorReset
+	}
+
+	if len(parts) > 0 && (strings.ToLower(parts[0]) == "member" || strings.ToLower(parts[0]) == "members") {
+		return e.handleSDWANMember(parts[1:], user)
+	}
+
+	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" || parts[0] == "show" {
+		groups := e.sdwanMgr.List()
+		var out strings.Builder
+		telemetry := e.sdwanMgr.GetTelemetrySummary()
+		out.WriteString(fmt.Sprintf("SD-WAN Groups: %d active / %d total | Members: %d healthy / %d total\n",
+			telemetry.ActiveGroups, telemetry.TotalGroups, telemetry.HealthyMembers, telemetry.TotalMembers))
+
+		tbl := NewTable("Flags", "Name", "Mode", "Members", "Probe(s)", "Auto-NAT", "Comment")
+		for _, g := range groups {
+			flag := " "
+			if g.Disabled {
+				flag = "X"
+			} else if g.Running {
+				flag = "R"
+			}
+			autoNat := "no"
+			if g.AutoNAT {
+				autoNat = "yes"
+			}
+			tbl.AddRow(flag, g.Name, string(g.Mode), fmt.Sprintf("%d", len(g.Members)), fmt.Sprintf("%ds", g.ProbeInterval), autoNat, g.Comment)
+		}
+		out.WriteString(tbl.String())
+		return out.String()
+	}
+
+	if parts[0] == "status" {
+		t := e.sdwanMgr.GetTelemetrySummary()
+		var out strings.Builder
+		out.WriteString(ColorCyan + ColorBold + "DebTik SD-WAN Telemetry Status:\n" + ColorReset)
+		out.WriteString(fmt.Sprintf("  Total Groups:      %d\n", t.TotalGroups))
+		out.WriteString(fmt.Sprintf("  Active Groups:     %d\n", t.ActiveGroups))
+		out.WriteString(fmt.Sprintf("  Total Members:     %d\n", t.TotalMembers))
+		out.WriteString(fmt.Sprintf("  Healthy Links:     %d\n", t.HealthyMembers))
+		out.WriteString(fmt.Sprintf("  Degraded Links:    %d\n", t.DegradedMembers))
+		out.WriteString(fmt.Sprintf("  Down Links:        %d\n\n", t.DownMembers))
+
+		groups := e.sdwanMgr.List()
+		for _, g := range groups {
+			out.WriteString(fmt.Sprintf("Group: %s [%s] (Members: %d)\n", g.Name, g.Mode, len(g.Members)))
+			for _, m := range g.Members {
+				statusCol := ColorGreen + string(m.Status) + ColorReset
+				if m.Status == sdwan.StatusDegraded {
+					statusCol = ColorYellow + string(m.Status) + ColorReset
+				} else if m.Status == sdwan.StatusDown {
+					statusCol = ColorRed + string(m.Status) + ColorReset
+				} else if m.Status == sdwan.StatusDisabled {
+					statusCol = ColorDim + string(m.Status) + ColorReset
+				}
+				out.WriteString(fmt.Sprintf("  - %s (GW: %s) -> Status: %s | Latency: %.1fms | Loss: %.1f%% | Jitter: %.1fms | Table: %d\n",
+					m.Interface, m.Gateway, statusCol, m.LiveLatency, m.PacketLoss, m.Jitter, m.TableID))
+			}
+		}
+		return out.String()
+	}
+
+	if user != nil && user.Role != auth.RoleFull {
+		return ColorRed + "Permission denied: admin role required\n" + ColorReset
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "add":
+		_, params := parseArgs(parts[1:])
+		name := params["name"]
+		if name == "" {
+			return ColorRed + "Usage: /ip sdwan add name=<name> [mode=weighted|pcc|lowest-latency|lowest-loss|failover] [auto-nat=yes|no] [probe-interval=3] [comment=\"...\"]\n" + ColorReset
+		}
+		mode := sdwan.SDWANMode(params["mode"])
+		if mode == "" {
+			mode = sdwan.ModeWeighted
+		}
+		autoNat := (params["auto-nat"] == "yes" || params["auto-nat"] == "true")
+		probeInt := 3
+		if pStr, ok := params["probe-interval"]; ok {
+			if v, err := strconv.Atoi(pStr); err == nil && v > 0 {
+				probeInt = v
+			}
+		}
+		group := &sdwan.SDWANGroup{
+			Name:          name,
+			Mode:          mode,
+			HashFields:    params["hash-fields"],
+			AutoNAT:       autoNat,
+			ProbeInterval: probeInt,
+			Comment:       params["comment"],
+			Disabled:      (params["disabled"] == "yes" || params["disabled"] == "true"),
+		}
+		if err := e.sdwanMgr.Save(group); err != nil {
+			return ColorRed + "Error adding SD-WAN group: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("SD-WAN group %q created.\n", name) + ColorReset
+
+	case "set":
+		pos, params := parseArgs(parts[1:])
+		if len(pos) == 0 && params["name"] == "" && params["id"] == "" {
+			return ColorRed + "Usage: /ip sdwan set <id|name> [mode=...] [auto-nat=yes|no] [probe-interval=N] [comment=...]\n" + ColorReset
+		}
+		idOrName := ""
+		if len(pos) > 0 {
+			idOrName = pos[0]
+		} else if params["name"] != "" {
+			idOrName = params["name"]
+		} else {
+			idOrName = params["id"]
+		}
+		group, found := e.sdwanMgr.Get(idOrName)
+		if !found {
+			return ColorRed + fmt.Sprintf("SD-WAN group %q not found\n", idOrName) + ColorReset
+		}
+		if m, ok := params["mode"]; ok {
+			group.Mode = sdwan.SDWANMode(m)
+		}
+		if hf, ok := params["hash-fields"]; ok {
+			group.HashFields = hf
+		}
+		if an, ok := params["auto-nat"]; ok {
+			group.AutoNAT = (an == "yes" || an == "true")
+		}
+		if pi, ok := params["probe-interval"]; ok {
+			if v, err := strconv.Atoi(pi); err == nil && v > 0 {
+				group.ProbeInterval = v
+			}
+		}
+		if c, ok := params["comment"]; ok {
+			group.Comment = c
+		}
+		if err := e.sdwanMgr.Save(group); err != nil {
+			return ColorRed + "Error updating SD-WAN group: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("SD-WAN group %q updated.\n", group.Name) + ColorReset
+
+	case "remove", "delete", "del":
+		if len(parts) < 2 {
+			return ColorRed + "Usage: /ip sdwan remove <id|name>\n" + ColorReset
+		}
+		idOrName := parts[1]
+		if err := e.sdwanMgr.Delete(idOrName); err != nil {
+			return ColorRed + "Error deleting SD-WAN group: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("SD-WAN group %q removed.\n", idOrName) + ColorReset
+
+	case "enable":
+		if len(parts) < 2 {
+			return ColorRed + "Usage: /ip sdwan enable <id|name>\n" + ColorReset
+		}
+		idOrName := parts[1]
+		if err := e.sdwanMgr.SetState(idOrName, false); err != nil {
+			return ColorRed + "Error enabling SD-WAN group: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("SD-WAN group %q enabled.\n", idOrName) + ColorReset
+
+	case "disable":
+		if len(parts) < 2 {
+			return ColorRed + "Usage: /ip sdwan disable <id|name>\n" + ColorReset
+		}
+		idOrName := parts[1]
+		if err := e.sdwanMgr.SetState(idOrName, true); err != nil {
+			return ColorRed + "Error disabling SD-WAN group: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("SD-WAN group %q disabled.\n", idOrName) + ColorReset
+
+	default:
+		return ColorRed + "Usage: /ip sdwan [print|status|add|set|remove|enable|disable|member]\n" + ColorReset
+	}
+}
+
+func (e *Executor) handleSDWANMember(parts []string, user *auth.PublicUser) string {
+	if len(parts) == 0 || parts[0] == "print" || parts[0] == "pr" || parts[0] == "show" {
+		_, params := parseArgs(parts)
+		groupFilter := params["group"]
+
+		groups := e.sdwanMgr.List()
+		tbl := NewTable("Flags", "Group", "Interface", "Gateway", "Weight", "Priority", "Target", "Status", "Latency", "Loss", "Jitter")
+		for _, g := range groups {
+			if groupFilter != "" && !strings.EqualFold(g.Name, groupFilter) && !strings.EqualFold(g.ID, groupFilter) {
+				continue
+			}
+			for _, m := range g.Members {
+				flag := " "
+				if m.Disabled || g.Disabled {
+					flag = "X"
+				}
+				statusCol := string(m.Status)
+				tbl.AddRow(
+					flag,
+					g.Name,
+					m.Interface,
+					m.Gateway,
+					fmt.Sprintf("%d", m.Weight),
+					fmt.Sprintf("%d", m.Priority),
+					m.TargetProbe,
+					statusCol,
+					fmt.Sprintf("%.1fms", m.LiveLatency),
+					fmt.Sprintf("%.1f%%", m.PacketLoss),
+					fmt.Sprintf("%.1fms", m.Jitter),
+				)
+			}
+		}
+		return tbl.String()
+	}
+
+	if user != nil && user.Role != auth.RoleFull {
+		return ColorRed + "Permission denied: admin role required\n" + ColorReset
+	}
+
+	sub := strings.ToLower(parts[0])
+	switch sub {
+	case "add":
+		_, params := parseArgs(parts[1:])
+		groupName := params["group"]
+		iface := params["interface"]
+		if groupName == "" || iface == "" {
+			return ColorRed + "Usage: /ip sdwan member add group=<group> interface=<iface> [gateway=<gw>] [weight=50] [priority=1] [target-probe=1.1.1.1] [max-latency=250] [max-loss=20] [comment=\"...\"]\n" + ColorReset
+		}
+		weight := 50
+		if wStr, ok := params["weight"]; ok {
+			if v, err := strconv.Atoi(wStr); err == nil && v > 0 {
+				weight = v
+			}
+		}
+		priority := 1
+		if pStr, ok := params["priority"]; ok {
+			if v, err := strconv.Atoi(pStr); err == nil && v > 0 {
+				priority = v
+			}
+		}
+		maxLat := 250
+		if lStr, ok := params["max-latency"]; ok {
+			if v, err := strconv.Atoi(lStr); err == nil && v > 0 {
+				maxLat = v
+			}
+		}
+		maxLoss := 20
+		if mlStr, ok := params["max-loss"]; ok {
+			if v, err := strconv.Atoi(mlStr); err == nil && v >= 0 {
+				maxLoss = v
+			}
+		}
+		targetProbe := params["target-probe"]
+		if targetProbe == "" {
+			targetProbe = "1.1.1.1"
+		}
+
+		mem := &sdwan.SDWANMember{
+			Interface:   iface,
+			Gateway:     params["gateway"],
+			Weight:      weight,
+			Priority:    priority,
+			TargetProbe: targetProbe,
+			MaxLatency:  maxLat,
+			MaxLoss:     maxLoss,
+			Comment:     params["comment"],
+			Disabled:    (params["disabled"] == "yes" || params["disabled"] == "true"),
+		}
+
+		if err := e.sdwanMgr.AddMember(groupName, mem); err != nil {
+			return ColorRed + "Error adding member: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("Interface %s added to SD-WAN group %s.\n", iface, groupName) + ColorReset
+
+	case "set":
+		_, params := parseArgs(parts[1:])
+		groupName := params["group"]
+		iface := params["interface"]
+		if groupName == "" || iface == "" {
+			return ColorRed + "Usage: /ip sdwan member set group=<group> interface=<iface> [gateway=...] [weight=...] [priority=...] [target-probe=...]\n" + ColorReset
+		}
+		group, found := e.sdwanMgr.Get(groupName)
+		if !found {
+			return ColorRed + fmt.Sprintf("SD-WAN group %q not found\n", groupName) + ColorReset
+		}
+		var targetMember *sdwan.SDWANMember
+		for _, m := range group.Members {
+			if m.Interface == iface || m.ID == iface {
+				targetMember = m
+				break
+			}
+		}
+		if targetMember == nil {
+			return ColorRed + fmt.Sprintf("Member %q not found in group %s\n", iface, groupName) + ColorReset
+		}
+
+		if gw, ok := params["gateway"]; ok {
+			targetMember.Gateway = gw
+		}
+		if wStr, ok := params["weight"]; ok {
+			if v, err := strconv.Atoi(wStr); err == nil && v > 0 {
+				targetMember.Weight = v
+			}
+		}
+		if pStr, ok := params["priority"]; ok {
+			if v, err := strconv.Atoi(pStr); err == nil && v > 0 {
+				targetMember.Priority = v
+			}
+		}
+		if tp, ok := params["target-probe"]; ok {
+			targetMember.TargetProbe = tp
+		}
+		if lStr, ok := params["max-latency"]; ok {
+			if v, err := strconv.Atoi(lStr); err == nil && v > 0 {
+				targetMember.MaxLatency = v
+			}
+		}
+		if mlStr, ok := params["max-loss"]; ok {
+			if v, err := strconv.Atoi(mlStr); err == nil && v >= 0 {
+				targetMember.MaxLoss = v
+			}
+		}
+		if c, ok := params["comment"]; ok {
+			targetMember.Comment = c
+		}
+		if dStr, ok := params["disabled"]; ok {
+			targetMember.Disabled = (dStr == "yes" || dStr == "true")
+		}
+
+		if err := e.sdwanMgr.UpdateMember(groupName, targetMember); err != nil {
+			return ColorRed + "Error updating member: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("Member %s in SD-WAN group %s updated.\n", iface, groupName) + ColorReset
+
+	case "remove", "delete", "del":
+		_, params := parseArgs(parts[1:])
+		groupName := params["group"]
+		iface := params["interface"]
+		if groupName == "" && len(parts) >= 3 {
+			groupName = parts[1]
+			iface = parts[2]
+		}
+		if groupName == "" || iface == "" {
+			return ColorRed + "Usage: /ip sdwan member remove group=<group> interface=<iface>\n" + ColorReset
+		}
+		if err := e.sdwanMgr.DeleteMember(groupName, iface); err != nil {
+			return ColorRed + "Error removing member: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("Member %s removed from SD-WAN group %s.\n", iface, groupName) + ColorReset
+
+	case "enable":
+		_, params := parseArgs(parts[1:])
+		groupName := params["group"]
+		iface := params["interface"]
+		if groupName == "" || iface == "" {
+			return ColorRed + "Usage: /ip sdwan member enable group=<group> interface=<iface>\n" + ColorReset
+		}
+		group, found := e.sdwanMgr.Get(groupName)
+		if !found {
+			return ColorRed + fmt.Sprintf("SD-WAN group %q not found\n", groupName) + ColorReset
+		}
+		var targetMember *sdwan.SDWANMember
+		for _, m := range group.Members {
+			if m.Interface == iface || m.ID == iface {
+				targetMember = m
+				break
+			}
+		}
+		if targetMember == nil {
+			return ColorRed + fmt.Sprintf("Member %q not found in group %s\n", iface, groupName) + ColorReset
+		}
+		targetMember.Disabled = false
+		if err := e.sdwanMgr.UpdateMember(groupName, targetMember); err != nil {
+			return ColorRed + "Error enabling member: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("Member %s in SD-WAN group %s enabled.\n", iface, groupName) + ColorReset
+
+	case "disable":
+		_, params := parseArgs(parts[1:])
+		groupName := params["group"]
+		iface := params["interface"]
+		if groupName == "" || iface == "" {
+			return ColorRed + "Usage: /ip sdwan member disable group=<group> interface=<iface>\n" + ColorReset
+		}
+		group, found := e.sdwanMgr.Get(groupName)
+		if !found {
+			return ColorRed + fmt.Sprintf("SD-WAN group %q not found\n", groupName) + ColorReset
+		}
+		var targetMember *sdwan.SDWANMember
+		for _, m := range group.Members {
+			if m.Interface == iface || m.ID == iface {
+				targetMember = m
+				break
+			}
+		}
+		if targetMember == nil {
+			return ColorRed + fmt.Sprintf("Member %q not found in group %s\n", iface, groupName) + ColorReset
+		}
+		targetMember.Disabled = true
+		if err := e.sdwanMgr.UpdateMember(groupName, targetMember); err != nil {
+			return ColorRed + "Error disabling member: " + err.Error() + "\n" + ColorReset
+		}
+		return ColorGreen + fmt.Sprintf("Member %s in SD-WAN group %s disabled.\n", iface, groupName) + ColorReset
+
+	default:
+		return ColorRed + "Usage: /ip sdwan member [print|add|set|remove|enable|disable]\n" + ColorReset
+	}
+}
+
 // Helpers for sorting/filtering
 func sortStrings(s []string) {
 	sort.Strings(s)

+ 63 - 4
internal/firewall/iptables.go

@@ -111,21 +111,80 @@ func resolveRoles(data []byte, listName string) []string {
 	return matched
 }
 
+// SDWANResolver is an optional callback to resolve SD-WAN group names into member interfaces.
+var SDWANResolver func(groupName string) []string
+
+func resolveSDWANGroup(groupName string) []string {
+	groupName = strings.TrimSpace(groupName)
+	if groupName == "" {
+		return nil
+	}
+	if SDWANResolver != nil {
+		if res := SDWANResolver(groupName); len(res) > 0 {
+			return res
+		}
+	}
+	data, err := os.ReadFile("/etc/debtik/sdwan.json")
+	if err != nil {
+		return nil
+	}
+	var groups []struct {
+		Name     string `json:"name"`
+		Disabled bool   `json:"disabled"`
+		Members  []struct {
+			Interface string `json:"interface"`
+			Disabled  bool   `json:"disabled"`
+		} `json:"members"`
+	}
+	if err := json.Unmarshal(data, &groups); err != nil {
+		return nil
+	}
+	var res []string
+	for _, g := range groups {
+		if strings.EqualFold(g.Name, groupName) && !g.Disabled {
+			for _, m := range g.Members {
+				if !m.Disabled && m.Interface != "" {
+					res = append(res, m.Interface)
+				}
+			}
+			break
+		}
+	}
+	sort.Strings(res)
+	return res
+}
+
 func getInterfaceCombinations(inIface, inList, outIface, outList string) [][2]string {
 	var inListResolved []string
 	if inIface != "" {
-		inListResolved = []string{inIface}
+		if sdwanMembers := resolveSDWANGroup(inIface); len(sdwanMembers) > 0 {
+			inListResolved = sdwanMembers
+		} else {
+			inListResolved = []string{inIface}
+		}
 	} else if inList != "" {
-		inListResolved = resolveInterfaceList(inList)
+		if sdwanMembers := resolveSDWANGroup(inList); len(sdwanMembers) > 0 {
+			inListResolved = sdwanMembers
+		} else {
+			inListResolved = resolveInterfaceList(inList)
+		}
 	} else {
 		inListResolved = []string{""}
 	}
 
 	var outListResolved []string
 	if outIface != "" {
-		outListResolved = []string{outIface}
+		if sdwanMembers := resolveSDWANGroup(outIface); len(sdwanMembers) > 0 {
+			outListResolved = sdwanMembers
+		} else {
+			outListResolved = []string{outIface}
+		}
 	} else if outList != "" {
-		outListResolved = resolveInterfaceList(outList)
+		if sdwanMembers := resolveSDWANGroup(outList); len(sdwanMembers) > 0 {
+			outListResolved = sdwanMembers
+		} else {
+			outListResolved = resolveInterfaceList(outList)
+		}
 	} else {
 		outListResolved = []string{""}
 	}

+ 35 - 0
internal/firewall/iptables_test.go

@@ -34,3 +34,38 @@ func TestBuildNATRuleLines_InterfaceList(t *testing.T) {
 		}
 	}
 }
+
+func TestBuildRuleLines_SDWANGroup(t *testing.T) {
+	oldResolver := SDWANResolver
+	defer func() { SDWANResolver = oldResolver }()
+
+	SDWANResolver = func(groupName string) []string {
+		if groupName == "sdwan0" {
+			return []string{"eth0", "eth1"}
+		}
+		return nil
+	}
+
+	rule := FilterRule{
+		ID:           "filter-sdwan-1",
+		Order:        1,
+		Chain:        "forward",
+		Action:       "drop",
+		OutInterface: "sdwan0",
+		Protocol:     "tcp",
+		DstPort:      "25",
+		Disabled:     false,
+	}
+
+	lines := buildFilterRuleLines(rule)
+	if len(lines) != 2 {
+		t.Fatalf("expected 2 lines for 2 SD-WAN member interfaces, got %d", len(lines))
+	}
+
+	if !strings.Contains(lines[0], "-o eth0") && !strings.Contains(lines[1], "-o eth0") {
+		t.Errorf("expected eth0 in lines: %v", lines)
+	}
+	if !strings.Contains(lines[0], "-o eth1") && !strings.Contains(lines[1], "-o eth1") {
+		t.Errorf("expected eth1 in lines: %v", lines)
+	}
+}

+ 354 - 0
internal/sdwan/engine.go

@@ -0,0 +1,354 @@
+package sdwan
+
+import (
+	"fmt"
+	"log"
+	"math"
+	"os/exec"
+	"strconv"
+	"strings"
+	"sync"
+)
+
+// KernelEngine manages routing tables, ip rules, and netfilter mangle rules for SD-WAN.
+type KernelEngine struct {
+	mu sync.Mutex
+}
+
+func NewKernelEngine() *KernelEngine {
+	return &KernelEngine{}
+}
+
+// Sync applies the complete routing and iptables rules for all active SD-WAN groups.
+func (e *KernelEngine) Sync(groups []*SDWANGroup) error {
+	e.mu.Lock()
+	defer e.mu.Unlock()
+
+	// 1. Clean existing SD-WAN mangle chain & rules
+	e.cleanupMangle()
+
+	// 2. Clear existing SD-WAN ip rules
+	e.cleanupIPRules()
+
+	hasActiveGroups := false
+	for _, g := range groups {
+		if !g.Disabled && len(g.Members) > 0 {
+			hasActiveGroups = true
+			break
+		}
+	}
+
+	if !hasActiveGroups {
+		return nil
+	}
+
+	// 3. Create fresh DEBTIK_SDWAN mangle chain
+	_ = exec.Command("iptables", "-t", "mangle", "-N", "DEBTIK_SDWAN").Run()
+	_ = exec.Command("iptables", "-t", "mangle", "-F", "DEBTIK_SDWAN").Run()
+
+	// Ensure jumps from PREROUTING and OUTPUT
+	e.ensureMangleJump("PREROUTING")
+	e.ensureMangleJump("OUTPUT")
+
+	// Bypass private RFC1918, loopback, link-local, and multicast
+	privateNets := []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16", "224.0.0.0/4"}
+	for _, net := range privateNets {
+		_ = exec.Command("iptables", "-t", "mangle", "-A", "DEBTIK_SDWAN", "-d", net, "-j", "RETURN").Run()
+	}
+
+	// Restore connection mark: if a flow has already been classified, keep its mark (Session Stickiness!)
+	_ = exec.Command("iptables", "-t", "mangle", "-A", "DEBTIK_SDWAN", "-j", "CONNMARK", "--restore-mark").Run()
+	_ = exec.Command("iptables", "-t", "mangle", "-A", "DEBTIK_SDWAN", "-m", "mark", "!", "--mark", "0", "-j", "RETURN").Run()
+
+	markBase := 0x5d00
+	tableBase := 1100
+	memberCounter := 1
+
+	for _, g := range groups {
+		if g.Disabled || len(g.Members) == 0 {
+			g.Running = false
+			continue
+		}
+
+		// Filter active candidates: prefer healthy/degraded over down
+		candidates := make([]*SDWANMember, 0)
+		for _, m := range g.Members {
+			if !m.Disabled {
+				candidates = append(candidates, m)
+			}
+		}
+
+		if len(candidates) == 0 {
+			g.Running = false
+			continue
+		}
+
+		// Determine online candidates
+		onlineCandidates := make([]*SDWANMember, 0)
+		for _, m := range candidates {
+			if m.Status != StatusDown {
+				onlineCandidates = append(onlineCandidates, m)
+			}
+		}
+
+		// If all candidates are down, fallback to using all enabled candidates so traffic isn't completely blackholed
+		activePool := onlineCandidates
+		if len(activePool) == 0 {
+			activePool = candidates
+		}
+
+		// Assign table ID and marks
+		for _, m := range g.Members {
+			m.TableID = tableBase + memberCounter
+			m.MarkInt = uint32(markBase + memberCounter)
+			m.MarkHex = fmt.Sprintf("0x%x", m.MarkInt)
+			memberCounter++
+
+			// Apply kernel routing table
+			e.applyMemberRoute(m)
+
+			// Apply policy routing rule (ip rule)
+			e.applyMemberRule(m)
+
+			// Auto-NAT if configured
+			if g.AutoNAT && !m.Disabled {
+				e.ensureNAT(m.Interface)
+			}
+		}
+
+		// Apply Mangle classification rules based on group mode
+		switch g.Mode {
+		case ModeWeighted:
+			e.applyWeightedRules(activePool)
+		case ModePCC:
+			e.applyPCCRules(activePool)
+		case ModeLowestLatency:
+			e.applyBestQualityRules(activePool, true)
+		case ModeLowestLoss:
+			e.applyBestQualityRules(activePool, false)
+		case ModeFailover:
+			e.applyFailoverRules(activePool)
+		default:
+			e.applyWeightedRules(activePool)
+		}
+
+		g.Running = true
+	}
+
+	return nil
+}
+
+func (e *KernelEngine) applyMemberRoute(m *SDWANMember) {
+	if m.Interface == "" {
+		return
+	}
+	tableStr := strconv.Itoa(m.TableID)
+
+	// Flush table first
+	_ = exec.Command("ip", "route", "flush", "table", tableStr).Run()
+
+	args := []string{"route", "replace", "default"}
+	if m.Gateway != "" {
+		args = append(args, "via", m.Gateway)
+	}
+	args = append(args, "dev", m.Interface, "table", tableStr)
+
+	if out, err := exec.Command("ip", args...).CombinedOutput(); err != nil {
+		log.Printf("[SD-WAN] Warning: failed adding route for %s (table %s): %s (%v)", m.Interface, tableStr, strings.TrimSpace(string(out)), err)
+	}
+}
+
+func (e *KernelEngine) applyMemberRule(m *SDWANMember) {
+	tableStr := strconv.Itoa(m.TableID)
+	prefStr := strconv.Itoa(2000 + m.TableID%1000)
+
+	// Delete matching rule if already present
+	_ = exec.Command("ip", "rule", "del", "fwmark", m.MarkHex, "table", tableStr).Run()
+
+	// Add ip rule
+	_ = exec.Command("ip", "rule", "add", "fwmark", m.MarkHex, "table", tableStr, "pref", prefStr).Run()
+}
+
+func (e *KernelEngine) applyWeightedRules(pool []*SDWANMember) {
+	if len(pool) == 0 {
+		return
+	}
+	if len(pool) == 1 {
+		e.addMarkRule(pool[0].MarkHex, 1.0)
+		return
+	}
+
+	totalWeight := 0
+	for _, m := range pool {
+		w := m.Weight
+		if w <= 0 {
+			w = 1
+		}
+		totalWeight += w
+	}
+
+	remainingWeight := totalWeight
+	for i, m := range pool {
+		if i == len(pool)-1 {
+			// Last member gets 100% of the remainder
+			e.addMarkRule(m.MarkHex, 1.0)
+			break
+		}
+
+		w := m.Weight
+		if w <= 0 {
+			w = 1
+		}
+		prob := float64(w) / float64(remainingWeight)
+		// Clamp probability between 0.0001 and 0.9999
+		prob = math.Max(0.0001, math.Min(0.9999, prob))
+
+		e.addMarkRule(m.MarkHex, prob)
+		remainingWeight -= w
+	}
+}
+
+func (e *KernelEngine) applyPCCRules(pool []*SDWANMember) {
+	n := len(pool)
+	if n == 0 {
+		return
+	}
+	if n == 1 {
+		e.addMarkRule(pool[0].MarkHex, 1.0)
+		return
+	}
+
+	// Using nth packet mode for new connection distribution
+	for i, m := range pool {
+		args := []string{
+			"-t", "mangle", "-A", "DEBTIK_SDWAN",
+			"-m", "conntrack", "--ctstate", "NEW",
+			"-m", "statistic", "--mode", "nth",
+			"--every", strconv.Itoa(n - i),
+			"--packet", "0",
+			"-j", "MARK", "--set-mark", m.MarkHex,
+		}
+		_ = exec.Command("iptables", args...).Run()
+
+		// Save mark into conntrack
+		_ = exec.Command("iptables", "-t", "mangle", "-A", "DEBTIK_SDWAN",
+			"-m", "conntrack", "--ctstate", "NEW",
+			"-m", "mark", "--mark", m.MarkHex,
+			"-j", "CONNMARK", "--save-mark").Run()
+
+		_ = exec.Command("iptables", "-t", "mangle", "-A", "DEBTIK_SDWAN",
+			"-m", "conntrack", "--ctstate", "NEW",
+			"-m", "mark", "--mark", m.MarkHex,
+			"-j", "RETURN").Run()
+	}
+}
+
+func (e *KernelEngine) applyBestQualityRules(pool []*SDWANMember, latencyMode bool) {
+	if len(pool) == 0 {
+		return
+	}
+	best := pool[0]
+	for _, m := range pool[1:] {
+		if latencyMode {
+			if m.LiveLatency > 0 && (best.LiveLatency <= 0 || m.LiveLatency < best.LiveLatency) {
+				best = m
+			}
+		} else {
+			if m.PacketLoss < best.PacketLoss {
+				best = m
+			}
+		}
+	}
+	e.addMarkRule(best.MarkHex, 1.0)
+}
+
+func (e *KernelEngine) applyFailoverRules(pool []*SDWANMember) {
+	if len(pool) == 0 {
+		return
+	}
+	// Sort by priority (1 is highest)
+	primary := pool[0]
+	for _, m := range pool[1:] {
+		prio := m.Priority
+		if prio <= 0 {
+			prio = 100
+		}
+		bestPrio := primary.Priority
+		if bestPrio <= 0 {
+			bestPrio = 100
+		}
+		if prio < bestPrio {
+			primary = m
+		}
+	}
+	e.addMarkRule(primary.MarkHex, 1.0)
+}
+
+func (e *KernelEngine) addMarkRule(markHex string, prob float64) {
+	if prob >= 1.0 {
+		_ = exec.Command("iptables", "-t", "mangle", "-A", "DEBTIK_SDWAN",
+			"-m", "conntrack", "--ctstate", "NEW",
+			"-j", "MARK", "--set-mark", markHex).Run()
+	} else {
+		probStr := fmt.Sprintf("%.4f", prob)
+		_ = exec.Command("iptables", "-t", "mangle", "-A", "DEBTIK_SDWAN",
+			"-m", "conntrack", "--ctstate", "NEW",
+			"-m", "statistic", "--mode", "random", "--probability", probStr,
+			"-j", "MARK", "--set-mark", markHex).Run()
+	}
+
+	_ = exec.Command("iptables", "-t", "mangle", "-A", "DEBTIK_SDWAN",
+		"-m", "conntrack", "--ctstate", "NEW",
+		"-m", "mark", "--mark", markHex,
+		"-j", "CONNMARK", "--save-mark").Run()
+
+	_ = exec.Command("iptables", "-t", "mangle", "-A", "DEBTIK_SDWAN",
+		"-m", "conntrack", "--ctstate", "NEW",
+		"-m", "mark", "--mark", markHex,
+		"-j", "RETURN").Run()
+}
+
+func (e *KernelEngine) ensureMangleJump(chain string) {
+	// Check if jump exists
+	check := exec.Command("iptables", "-t", "mangle", "-C", chain, "-j", "DEBTIK_SDWAN")
+	if check.Run() != nil {
+		_ = exec.Command("iptables", "-t", "mangle", "-I", chain, "1", "-j", "DEBTIK_SDWAN").Run()
+	}
+}
+
+func (e *KernelEngine) ensureNAT(iface string) {
+	if iface == "" {
+		return
+	}
+	check := exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-o", iface, "-m", "comment", "--comment", "sdwan-auto-nat", "-j", "MASQUERADE")
+	if check.Run() != nil {
+		_ = exec.Command("iptables", "-t", "nat", "-A", "POSTROUTING", "-o", iface, "-m", "comment", "--comment", "sdwan-auto-nat", "-j", "MASQUERADE").Run()
+	}
+}
+
+func (e *KernelEngine) cleanupMangle() {
+	_ = exec.Command("iptables", "-t", "mangle", "-D", "PREROUTING", "-j", "DEBTIK_SDWAN").Run()
+	_ = exec.Command("iptables", "-t", "mangle", "-D", "OUTPUT", "-j", "DEBTIK_SDWAN").Run()
+	_ = exec.Command("iptables", "-t", "mangle", "-F", "DEBTIK_SDWAN").Run()
+	_ = exec.Command("iptables", "-t", "mangle", "-X", "DEBTIK_SDWAN").Run()
+}
+
+func (e *KernelEngine) cleanupIPRules() {
+	out, err := exec.Command("ip", "rule", "show").CombinedOutput()
+	if err != nil {
+		return
+	}
+	lines := strings.Split(string(out), "\n")
+	for _, l := range lines {
+		if strings.Contains(l, "0x5d") {
+			// Extract fwmark and table
+			fields := strings.Fields(l)
+			for i, f := range fields {
+				if f == "fwmark" && i+1 < len(fields) {
+					mark := fields[i+1]
+					_ = exec.Command("ip", "rule", "del", "fwmark", mark).Run()
+				}
+			}
+		}
+	}
+}

+ 213 - 0
internal/sdwan/health.go

@@ -0,0 +1,213 @@
+package sdwan
+
+import (
+	"context"
+	"log"
+	"math"
+	"os/exec"
+	"regexp"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+)
+
+var (
+	rttRegex  = regexp.MustCompile(`time=([0-9.]+)\s*ms`)
+	lossRegex = regexp.MustCompile(`([0-9.]+)%\s*packet\s*loss`)
+)
+
+type probeSample struct {
+	latency float64
+	success bool
+	t       time.Time
+}
+
+// HealthMonitor periodically probes SD-WAN member interfaces and updates SLA telemetry.
+type HealthMonitor struct {
+	mu            sync.RWMutex
+	samples       map[string][]probeSample // memberID -> rolling probe samples
+	cancel        context.CancelFunc
+	onStateChange func()
+}
+
+func NewHealthMonitor(onStateChange func()) *HealthMonitor {
+	return &HealthMonitor{
+		samples:       make(map[string][]probeSample),
+		onStateChange: onStateChange,
+	}
+}
+
+// Start begins periodic background health checking for active SD-WAN members.
+func (h *HealthMonitor) Start(getGroups func() []*SDWANGroup) {
+	ctx, cancel := context.WithCancel(context.Background())
+	h.cancel = cancel
+
+	go func() {
+		ticker := time.NewTicker(3 * time.Second)
+		defer ticker.Stop()
+
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-ticker.C:
+				groups := getGroups()
+				h.runProbeIteration(groups)
+			}
+		}
+	}()
+}
+
+// Stop terminates the probing goroutine.
+func (h *HealthMonitor) Stop() {
+	if h.cancel != nil {
+		h.cancel()
+	}
+}
+
+func (h *HealthMonitor) runProbeIteration(groups []*SDWANGroup) {
+	stateChanged := false
+
+	for _, g := range groups {
+		if g.Disabled {
+			continue
+		}
+
+		for _, m := range g.Members {
+			if m.Disabled || m.Interface == "" {
+				m.Status = StatusDisabled
+				continue
+			}
+
+			probeTarget := m.TargetProbe
+			if probeTarget == "" {
+				probeTarget = "1.1.1.1"
+			}
+
+			// Run ICMP ping probe
+			latency, success := h.executeProbe(m.Interface, probeTarget)
+
+			h.mu.Lock()
+			history := h.samples[m.ID]
+			history = append(history, probeSample{
+				latency: latency,
+				success: success,
+				t:       time.Now(),
+			})
+			if len(history) > 10 {
+				history = history[len(history)-10:]
+			}
+			h.samples[m.ID] = history
+			h.mu.Unlock()
+
+			prevStatus := m.Status
+
+			// Calculate telemetry
+			m.LastChecked = time.Now()
+			if success {
+				m.ConsecutiveFails = 0
+			} else {
+				m.ConsecutiveFails++
+			}
+
+			h.calculateMetrics(m, history)
+
+			// Determine status based on SLA thresholds
+			if m.ConsecutiveFails >= 3 {
+				m.Status = StatusDown
+			} else {
+				maxLat := float64(m.MaxLatency)
+				if maxLat <= 0 {
+					maxLat = 250.0
+				}
+				maxLoss := float64(m.MaxLoss)
+				if maxLoss <= 0 {
+					maxLoss = 20.0
+				}
+
+				if m.PacketLoss > maxLoss || m.LiveLatency > maxLat {
+					m.Status = StatusDegraded
+				} else {
+					m.Status = StatusHealthy
+				}
+			}
+
+			// Check if link transitioned between UP and DOWN
+			if (prevStatus == StatusDown && m.Status != StatusDown) ||
+				(prevStatus != StatusDown && m.Status == StatusDown) {
+				stateChanged = true
+				log.Printf("[SD-WAN] Health alert: %s status changed from %s to %s (latency: %.1fms, loss: %.1f%%)",
+					m.Interface, prevStatus, m.Status, m.LiveLatency, m.PacketLoss)
+			}
+		}
+	}
+
+	if stateChanged && h.onStateChange != nil {
+		h.onStateChange()
+	}
+}
+
+func (h *HealthMonitor) executeProbe(iface, target string) (float64, bool) {
+	// ping -c 1 -W 2 -I <iface> <target>
+	cmd := exec.Command("ping", "-c", "1", "-W", "2", "-I", iface, target)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return 0, false
+	}
+
+	str := string(out)
+	if !strings.Contains(str, "1 received") && !strings.Contains(str, "1 packets received") {
+		return 0, false
+	}
+
+	matches := rttRegex.FindStringSubmatch(str)
+	if len(matches) > 1 {
+		if val, err := strconv.ParseFloat(matches[1], 64); err == nil {
+			return val, true
+		}
+	}
+
+	return 1.0, true
+}
+
+func (h *HealthMonitor) calculateMetrics(m *SDWANMember, history []probeSample) {
+	if len(history) == 0 {
+		return
+	}
+
+	total := len(history)
+	failed := 0
+	var sumLat float64
+	var countSuccess int
+	var latencies []float64
+
+	for _, s := range history {
+		if !s.success {
+			failed++
+		} else {
+			sumLat += s.latency
+			countSuccess++
+			latencies = append(latencies, s.latency)
+		}
+	}
+
+	m.PacketLoss = (float64(failed) / float64(total)) * 100.0
+
+	if countSuccess > 0 {
+		avg := sumLat / float64(countSuccess)
+		m.LiveLatency = math.Round(avg*10) / 10
+
+		// Jitter calculation (average difference between consecutive probes)
+		if len(latencies) >= 2 {
+			var diffSum float64
+			for i := 1; i < len(latencies); i++ {
+				diffSum += math.Abs(latencies[i] - latencies[i-1])
+			}
+			m.Jitter = math.Round((diffSum/float64(len(latencies)-1))*10) / 10
+		}
+	} else {
+		m.LiveLatency = 0
+		m.Jitter = 0
+	}
+}

+ 415 - 0
internal/sdwan/manager.go

@@ -0,0 +1,415 @@
+package sdwan
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+)
+
+const DefaultStorePath = "/etc/debtik/sdwan.json"
+
+// Global resolver registered for firewall integration
+var GlobalManager *Manager
+
+// Manager coordinates SD-WAN group configurations, kernel routing, and SLA health monitoring.
+type Manager struct {
+	mu        sync.RWMutex
+	storePath string
+	groups    map[string]*SDWANGroup
+	engine    *KernelEngine
+	monitor   *HealthMonitor
+}
+
+// NewManager creates an SD-WAN manager with persistent storage.
+func NewManager(customStore ...string) *Manager {
+	store := DefaultStorePath
+	if len(customStore) > 0 && customStore[0] != "" {
+		store = customStore[0]
+	}
+
+	mgr := &Manager{
+		storePath: store,
+		groups:    make(map[string]*SDWANGroup),
+		engine:    NewKernelEngine(),
+	}
+
+	mgr.monitor = NewHealthMonitor(func() {
+		mgr.mu.RLock()
+		groups := mgr.listLocked()
+		mgr.mu.RUnlock()
+		_ = mgr.engine.Sync(groups)
+	})
+
+	mgr.load()
+	mgr.SyncAll()
+	mgr.monitor.Start(func() []*SDWANGroup {
+		return mgr.List()
+	})
+
+	GlobalManager = mgr
+	return mgr
+}
+
+func (m *Manager) Stop() {
+	if m.monitor != nil {
+		m.monitor.Stop()
+	}
+}
+
+func (m *Manager) load() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	data, err := os.ReadFile(m.storePath)
+	if err != nil {
+		return
+	}
+	var list []*SDWANGroup
+	if err := json.Unmarshal(data, &list); err == nil {
+		for _, g := range list {
+			m.groups[g.ID] = g
+		}
+	}
+}
+
+func (m *Manager) save() error {
+	_ = os.MkdirAll(filepath.Dir(m.storePath), 0755)
+
+	list := make([]*SDWANGroup, 0, len(m.groups))
+	for _, g := range m.groups {
+		list = append(list, g)
+	}
+
+	data, err := json.MarshalIndent(list, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(m.storePath, data, 0644)
+}
+
+func (m *Manager) listLocked() []*SDWANGroup {
+	list := make([]*SDWANGroup, 0, len(m.groups))
+	for _, g := range m.groups {
+		list = append(list, g)
+	}
+	sort.Slice(list, func(i, j int) bool {
+		return list[i].Name < list[j].Name
+	})
+	return list
+}
+
+// List returns a sorted copy of all configured SD-WAN groups.
+func (m *Manager) List() []*SDWANGroup {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	return m.listLocked()
+}
+
+// Get returns an SD-WAN group by ID or group name.
+func (m *Manager) Get(idOrName string) (*SDWANGroup, bool) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	if g, ok := m.groups[idOrName]; ok {
+		return g, true
+	}
+	for _, g := range m.groups {
+		if strings.EqualFold(g.Name, idOrName) {
+			return g, true
+		}
+	}
+	return nil, false
+}
+
+// Save validates, stores, and synchronizes an SD-WAN group.
+func (m *Manager) Save(g *SDWANGroup) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if g.Name == "" {
+		g.Name = fmt.Sprintf("sdwan%d", len(m.groups))
+	}
+	if g.ID == "" {
+		g.ID = fmt.Sprintf("sdwan-%d", time.Now().UnixNano())
+	}
+	if g.Mode == "" {
+		g.Mode = ModeWeighted
+	}
+	if g.ProbeInterval <= 0 {
+		g.ProbeInterval = 3
+	}
+
+	// Validate name collisions
+	for _, existing := range m.groups {
+		if existing.ID != g.ID && strings.EqualFold(existing.Name, g.Name) {
+			return fmt.Errorf("sd-wan group with name %q already exists", g.Name)
+		}
+	}
+
+	// Ensure member IDs and initial statuses
+	for _, mem := range g.Members {
+		if mem.ID == "" {
+			mem.ID = fmt.Sprintf("mem-%d-%s", time.Now().UnixNano(), mem.Interface)
+		}
+		if mem.Weight <= 0 {
+			mem.Weight = 50
+		}
+		if mem.Priority <= 0 {
+			mem.Priority = 1
+		}
+		if mem.Status == "" {
+			mem.Status = StatusHealthy
+		}
+	}
+
+	m.groups[g.ID] = g
+	if err := m.save(); err != nil {
+		return err
+	}
+
+	return m.engine.Sync(m.listLocked())
+}
+
+// SetState enables or disables an SD-WAN group.
+func (m *Manager) SetState(idOrName string, disabled bool) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	g := m.findLocked(idOrName)
+	if g == nil {
+		return fmt.Errorf("sd-wan group %q not found", idOrName)
+	}
+
+	g.Disabled = disabled
+	if err := m.save(); err != nil {
+		return err
+	}
+
+	return m.engine.Sync(m.listLocked())
+}
+
+// Delete removes an SD-WAN group.
+func (m *Manager) Delete(idOrName string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	g := m.findLocked(idOrName)
+	if g == nil {
+		return fmt.Errorf("sd-wan group %q not found", idOrName)
+	}
+
+	delete(m.groups, g.ID)
+	if err := m.save(); err != nil {
+		return err
+	}
+
+	return m.engine.Sync(m.listLocked())
+}
+
+// AddMember adds an interface member to a group.
+func (m *Manager) AddMember(groupIDOrName string, mem *SDWANMember) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	g := m.findLocked(groupIDOrName)
+	if g == nil {
+		return fmt.Errorf("sd-wan group %q not found", groupIDOrName)
+	}
+
+	if mem.Interface == "" {
+		return fmt.Errorf("member interface is required")
+	}
+
+	// Check duplicates
+	for _, existing := range g.Members {
+		if existing.Interface == mem.Interface {
+			return fmt.Errorf("interface %s already a member of %s", mem.Interface, g.Name)
+		}
+	}
+
+	if mem.ID == "" {
+		mem.ID = fmt.Sprintf("mem-%d-%s", time.Now().UnixNano(), mem.Interface)
+	}
+	if mem.Weight <= 0 {
+		mem.Weight = 50
+	}
+	if mem.Priority <= 0 {
+		mem.Priority = 1
+	}
+	if mem.TargetProbe == "" {
+		mem.TargetProbe = "1.1.1.1"
+	}
+	mem.Status = StatusHealthy
+
+	g.Members = append(g.Members, mem)
+	if err := m.save(); err != nil {
+		return err
+	}
+
+	return m.engine.Sync(m.listLocked())
+}
+
+// UpdateMember modifies a member's configuration.
+func (m *Manager) UpdateMember(groupIDOrName string, mem *SDWANMember) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	g := m.findLocked(groupIDOrName)
+	if g == nil {
+		return fmt.Errorf("sd-wan group %q not found", groupIDOrName)
+	}
+
+	idx := -1
+	for i, existing := range g.Members {
+		if existing.ID == mem.ID || existing.Interface == mem.Interface {
+			idx = i
+			break
+		}
+	}
+
+	if idx == -1 {
+		return fmt.Errorf("member %s not found in group %s", mem.Interface, g.Name)
+	}
+
+	// Preserve live telemetry
+	mem.Status = g.Members[idx].Status
+	mem.LiveLatency = g.Members[idx].LiveLatency
+	mem.PacketLoss = g.Members[idx].PacketLoss
+	mem.Jitter = g.Members[idx].Jitter
+	mem.ConsecutiveFails = g.Members[idx].ConsecutiveFails
+
+	g.Members[idx] = mem
+	if err := m.save(); err != nil {
+		return err
+	}
+
+	return m.engine.Sync(m.listLocked())
+}
+
+// DeleteMember removes a member from a group.
+func (m *Manager) DeleteMember(groupIDOrName, memberIDOrIface string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	g := m.findLocked(groupIDOrName)
+	if g == nil {
+		return fmt.Errorf("sd-wan group %q not found", groupIDOrName)
+	}
+
+	idx := -1
+	for i, mem := range g.Members {
+		if mem.ID == memberIDOrIface || mem.Interface == memberIDOrIface {
+			idx = i
+			break
+		}
+	}
+
+	if idx == -1 {
+		return fmt.Errorf("member %s not found in group %s", memberIDOrIface, g.Name)
+	}
+
+	g.Members = append(g.Members[:idx], g.Members[idx+1:]...)
+	if err := m.save(); err != nil {
+		return err
+	}
+
+	return m.engine.Sync(m.listLocked())
+}
+
+// ResolveGroupInterfaces resolves an SD-WAN group to its member interfaces (used by Firewall).
+func (m *Manager) ResolveGroupInterfaces(groupName string) []string {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	var res []string
+	for _, g := range m.groups {
+		if strings.EqualFold(g.Name, groupName) && !g.Disabled {
+			for _, mem := range g.Members {
+				if !mem.Disabled && mem.Interface != "" {
+					res = append(res, mem.Interface)
+				}
+			}
+			break
+		}
+	}
+	sort.Strings(res)
+	return res
+}
+
+// ResolveAllSDWANGroups returns a mapping of all group names to their member interfaces.
+func (m *Manager) ResolveAllSDWANGroups() map[string][]string {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	res := make(map[string][]string)
+	for _, g := range m.groups {
+		if !g.Disabled {
+			var members []string
+			for _, mem := range g.Members {
+				if !mem.Disabled && mem.Interface != "" {
+					members = append(members, mem.Interface)
+				}
+			}
+			sort.Strings(members)
+			res[g.Name] = members
+		}
+	}
+	return res
+}
+
+// GetSummary returns high-level telemetry across all SD-WAN groups.
+func (m *Manager) GetSummary() TelemetrySummary {
+	return m.GetTelemetrySummary()
+}
+
+// GetTelemetrySummary returns high-level telemetry across all SD-WAN groups.
+func (m *Manager) GetTelemetrySummary() TelemetrySummary {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	sum := TelemetrySummary{
+		TotalGroups: len(m.groups),
+	}
+
+	for _, g := range m.groups {
+		if !g.Disabled {
+			sum.ActiveGroups++
+		}
+		for _, mem := range g.Members {
+			sum.TotalMembers++
+			switch mem.Status {
+			case StatusHealthy:
+				sum.HealthyMembers++
+			case StatusDegraded:
+				sum.DegradedMembers++
+			case StatusDown:
+				sum.DownMembers++
+			}
+		}
+	}
+	return sum
+}
+
+// SyncAll reconciles kernel routing and iptables for all groups.
+func (m *Manager) SyncAll() {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	_ = m.engine.Sync(m.listLocked())
+}
+
+func (m *Manager) findLocked(idOrName string) *SDWANGroup {
+	if g, ok := m.groups[idOrName]; ok {
+		return g
+	}
+	for _, g := range m.groups {
+		if strings.EqualFold(g.Name, idOrName) {
+			return g
+		}
+	}
+	return nil
+}

+ 144 - 0
internal/sdwan/sdwan_test.go

@@ -0,0 +1,144 @@
+package sdwan
+
+import (
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func TestSDWANManagerBasics(t *testing.T) {
+	tmpDir := t.TempDir()
+	storePath := filepath.Join(tmpDir, "sdwan.json")
+
+	mgr := NewManager(storePath)
+	defer mgr.Stop()
+
+	if mgr == nil {
+		t.Fatal("expected non-nil manager")
+	}
+
+	// 1. Create SD-WAN group
+	group := &SDWANGroup{
+		Name:          "sdwan0",
+		Mode:          ModeWeighted,
+		ProbeInterval: 3,
+		AutoNAT:       true,
+		Comment:       "Primary Internet SD-WAN",
+	}
+
+	if err := mgr.Save(group); err != nil {
+		t.Fatalf("failed to save group: %v", err)
+	}
+
+	// Verify group is retrievable by Name and ID
+	got, ok := mgr.Get("sdwan0")
+	if !ok || got == nil {
+		t.Fatal("expected to find sdwan0 by name")
+	}
+	if got.ID == "" {
+		t.Fatal("expected auto-generated group ID")
+	}
+
+	// 2. Add members
+	mem1 := &SDWANMember{
+		Interface:   "eth0",
+		Gateway:     "192.168.1.1",
+		Weight:      70,
+		TargetProbe: "1.1.1.1",
+	}
+	if err := mgr.AddMember("sdwan0", mem1); err != nil {
+		t.Fatalf("failed adding member 1: %v", err)
+	}
+
+	mem2 := &SDWANMember{
+		Interface:   "eth1",
+		Gateway:     "192.168.2.1",
+		Weight:      30,
+		TargetProbe: "8.8.8.8",
+	}
+	if err := mgr.AddMember("sdwan0", mem2); err != nil {
+		t.Fatalf("failed adding member 2: %v", err)
+	}
+
+	// Test duplicate member rejection
+	if err := mgr.AddMember("sdwan0", &SDWANMember{Interface: "eth0"}); err == nil {
+		t.Fatal("expected error on duplicate member interface")
+	}
+
+	// 3. Test Interface Resolution for Firewall
+	resolved := mgr.ResolveGroupInterfaces("sdwan0")
+	if len(resolved) != 2 || resolved[0] != "eth0" || resolved[1] != "eth1" {
+		t.Fatalf("expected [eth0, eth1], got %v", resolved)
+	}
+
+	allGroups := mgr.ResolveAllSDWANGroups()
+	if len(allGroups["sdwan0"]) != 2 {
+		t.Fatalf("expected sdwan0 in ResolveAllSDWANGroups, got %v", allGroups)
+	}
+
+	// 4. Update Member
+	mem1.Weight = 80
+	if err := mgr.UpdateMember("sdwan0", mem1); err != nil {
+		t.Fatalf("failed updating member: %v", err)
+	}
+	updated, _ := mgr.Get("sdwan0")
+	if updated.Members[0].Weight != 80 {
+		t.Fatalf("expected weight 80, got %d", updated.Members[0].Weight)
+	}
+
+	// 5. Telemetry Summary
+	summary := mgr.GetSummary()
+	if summary.TotalGroups != 1 || summary.ActiveGroups != 1 || summary.TotalMembers != 2 {
+		t.Fatalf("unexpected summary: %+v", summary)
+	}
+
+	// 6. Delete Member
+	if err := mgr.DeleteMember("sdwan0", "eth1"); err != nil {
+		t.Fatalf("failed deleting member: %v", err)
+	}
+	resolvedAfterDelete := mgr.ResolveGroupInterfaces("sdwan0")
+	if len(resolvedAfterDelete) != 1 || resolvedAfterDelete[0] != "eth0" {
+		t.Fatalf("expected [eth0], got %v", resolvedAfterDelete)
+	}
+
+	// 7. Delete Group
+	if err := mgr.Delete("sdwan0"); err != nil {
+		t.Fatalf("failed deleting group: %v", err)
+	}
+	if len(mgr.List()) != 0 {
+		t.Fatal("expected 0 groups after delete")
+	}
+}
+
+func TestHealthMetricsCalculation(t *testing.T) {
+	monitor := NewHealthMonitor(nil)
+
+	mem := &SDWANMember{
+		Interface:  "eth0",
+		MaxLatency: 100,
+		MaxLoss:    10,
+	}
+
+	samples := []probeSample{
+		{latency: 20.0, success: true, t: time.Now()},
+		{latency: 25.0, success: true, t: time.Now()},
+		{latency: 30.0, success: true, t: time.Now()},
+		{latency: 0.0, success: false, t: time.Now()},
+	}
+
+	monitor.calculateMetrics(mem, samples)
+
+	// 1 failure out of 4 = 25% loss
+	if mem.PacketLoss != 25.0 {
+		t.Errorf("expected 25.0%% loss, got %.1f", mem.PacketLoss)
+	}
+
+	// Average of 20, 25, 30 is 25.0 ms
+	if mem.LiveLatency != 25.0 {
+		t.Errorf("expected 25.0ms latency, got %.1f", mem.LiveLatency)
+	}
+
+	if mem.Jitter <= 0 {
+		t.Errorf("expected positive jitter, got %.1f", mem.Jitter)
+	}
+}

+ 74 - 0
internal/sdwan/types.go

@@ -0,0 +1,74 @@
+package sdwan
+
+import "time"
+
+// SDWANMode represents the traffic distribution strategy.
+type SDWANMode string
+
+const (
+	ModeWeighted      SDWANMode = "weighted"       // Weighted percentage distribution across healthy links
+	ModePCC           SDWANMode = "pcc"            // Per-Connection Classifier (flow hash stickiness)
+	ModeLowestLatency SDWANMode = "lowest-latency" // Quality SLA: select link with minimum RTT
+	ModeLowestLoss    SDWANMode = "lowest-loss"    // Quality SLA: select link with minimum packet loss
+	ModeFailover      SDWANMode = "failover"       // Active-backup priority failover
+)
+
+// MemberStatus represents the live health state of an SD-WAN member.
+type MemberStatus string
+
+const (
+	StatusHealthy  MemberStatus = "HEALTHY"
+	StatusDegraded MemberStatus = "DEGRADED"
+	StatusDown     MemberStatus = "DOWN"
+	StatusDisabled MemberStatus = "DISABLED"
+)
+
+// SDWANMember represents a single WAN link participating in an SD-WAN group.
+type SDWANMember struct {
+	ID          string       `json:"id"`
+	Interface   string       `json:"interface"`    // Underlying device name, e.g. "eth0", "eth1", "aerotun0"
+	Gateway     string       `json:"gateway"`      // Next-hop IPv4 gateway for this link
+	Weight      int          `json:"weight"`       // Relative weight or percentage ratio (default: 50)
+	Priority    int          `json:"priority"`     // Priority for failover mode (1 = highest primary)
+	TargetProbe string       `json:"target_probe"` // Health check ping target IP (e.g. "1.1.1.1", "8.8.8.8")
+	MaxLatency  int          `json:"max_latency"`  // SLA maximum latency threshold in ms (default: 250)
+	MaxLoss     int          `json:"max_loss"`     // SLA maximum packet loss threshold in % (default: 20)
+	Comment     string       `json:"comment,omitempty"`
+	Disabled    bool         `json:"disabled"`
+
+	// Live Telemetry from SLA Prober
+	Status           MemberStatus `json:"status"`            // HEALTHY, DEGRADED, DOWN, DISABLED
+	LiveLatency      float64      `json:"live_latency"`      // Latency in ms (RTT)
+	PacketLoss       float64      `json:"packet_loss"`       // Packet loss percentage (0.0 - 100.0)
+	Jitter           float64      `json:"jitter"`            // Latency jitter in ms
+	LastChecked      time.Time    `json:"last_checked"`
+	ConsecutiveFails int          `json:"consecutive_fails"` // Counter for sequential probe failures
+	TableID          int          `json:"table_id"`          // Kernel routing table ID (e.g. 1101)
+	MarkHex          string       `json:"mark_hex"`          // Fwmark hex string (e.g. "0x5d01")
+	MarkInt          uint32       `json:"mark_int"`          // Fwmark integer
+}
+
+// SDWANGroup represents a unified SD-WAN virtual interface / zone.
+type SDWANGroup struct {
+	ID            string         `json:"id"`
+	Name          string         `json:"name"`           // Group name, e.g. "sdwan0", "sdwan-internet"
+	Mode          SDWANMode      `json:"mode"`           // "weighted", "pcc", "lowest-latency", "lowest-loss", "failover"
+	HashFields    string         `json:"hash_fields"`    // For PCC: "both-addresses-and-ports", "src-address", "both-addresses"
+	AutoNAT       bool           `json:"auto_nat"`       // Automatically masquerade egress traffic from member interfaces
+	ProbeInterval int            `json:"probe_interval"` // Health check frequency in seconds (default: 3)
+	Comment       string         `json:"comment,omitempty"`
+	Disabled      bool           `json:"disabled"`
+	Running       bool           `json:"running"`
+	Members       []*SDWANMember `json:"members"`
+	LastError     string         `json:"last_error,omitempty"`
+}
+
+// TelemetrySummary represents summary stats for all SD-WAN groups.
+type TelemetrySummary struct {
+	TotalGroups    int `json:"total_groups"`
+	ActiveGroups   int `json:"active_groups"`
+	TotalMembers   int `json:"total_members"`
+	HealthyMembers int `json:"healthy_members"`
+	DegradedMembers int `json:"degraded_members"`
+	DownMembers    int `json:"down_members"`
+}

+ 561 - 1
internal/web/static/app.js

@@ -146,7 +146,9 @@ let state = {
   dhcpNetworks: [],
   dhcpLeases: [],
   dhcpOptions: [],
-  dhcpServerSummary: null
+  dhcpServerSummary: null,
+  sdwanGroups: [],
+  sdwanTelemetry: null
 };
 
 const API_BASE = '/api';
@@ -361,6 +363,7 @@ function navigate(tabName) {
     vlans: { title: 'VLAN Virtual Networks', sub: 'IEEE 802.1Q sub-interface segmentation and routing' },
     firewall: { title: 'Network Firewall', sub: 'Stateful packet inspection, NAT, traffic mangle, address lists & connection tracking' },
     routes: { title: 'Routing & Policy', sub: 'Routing tables, static routes (DAS/DAC), and policy routing rules' },
+    sdwan: { title: 'Software-Defined WAN (SD-WAN)', sub: 'Multi-link aggregation, traffic load balancing & quality SLA health monitoring' },
     bgp: { title: 'BGP Dynamic Routing', sub: 'BGP multi-instance peering sessions, advertised networks, and routing table (RIB)' },
     ospf: { title: 'OSPF Dynamic Routing', sub: 'OSPFv2 link-state routing, dynamic neighbor adjacencies, and multi-area topology' },
     'routing-filters': { title: 'Routing Policy Filters', sub: 'Route filter chains, prefix matching, BGP community maps & policy-based path control' },
@@ -409,6 +412,7 @@ function navigate(tabName) {
   if (tabName === 'bridge') loadBridge();
   if (tabName === 'firewall') loadFirewall();
   if (tabName === 'routes') loadRoutes();
+  if (tabName === 'sdwan') loadSDWAN();
   if (tabName === 'bgp') {
     loadDynamicRouting();
     switchBgpSubTab(state.activeBgpSubTab || 'bgp-peers');
@@ -552,6 +556,7 @@ async function refreshCoreData(isManual = false) {
     if (state.activeTab === 'users') await loadUsers(false);
     if (state.activeTab === 'certificates') await loadCertificates(false);
     if (state.activeTab === 'reverse_proxy') await loadReverseProxy(false);
+    if (state.activeTab === 'sdwan') await loadSDWAN(false);
     if (state.activeTab === 'services') await loadServices(false);
     if (state.activeTab === 'updates') await loadUpdateStatus(false);
     if (state.activeTab === 'bwtest') await fetchBwTestServerStatus();
@@ -3988,6 +3993,18 @@ function populateInterfaceSelect(selectId, currentVal = '') {
     if (i.name === currentVal) opt.selected = true;
     sel.appendChild(opt);
   });
+  if (state.sdwanGroups && state.sdwanGroups.length > 0) {
+    const optgroup = document.createElement('optgroup');
+    optgroup.label = 'SD-WAN Groups';
+    state.sdwanGroups.forEach(g => {
+      const opt = document.createElement('option');
+      opt.value = g.name;
+      opt.innerText = `${g.name} [SD-WAN: ${g.mode}]`;
+      if (g.name === currentVal) opt.selected = true;
+      optgroup.appendChild(opt);
+    });
+    sel.appendChild(optgroup);
+  }
 }
 
 function populateAddressListSelect(selectId, currentVal = '') {
@@ -18884,3 +18901,546 @@ async function saveProxyConfig(event) {
   }
 }
 
+// ==================== SD-WAN Controller ====================
+
+async function loadSDWAN(manual = false) {
+  try {
+    const [groupsRes, telemetryRes] = await Promise.all([
+      fetch(`${API_BASE}/sdwan/groups`),
+      fetch(`${API_BASE}/sdwan/telemetry`)
+    ]);
+
+    if (!groupsRes.ok) throw new Error('Failed to load SD-WAN groups');
+    state.sdwanGroups = await groupsRes.json() || [];
+
+    if (telemetryRes.ok) {
+      state.sdwanTelemetry = await telemetryRes.json() || {};
+    }
+
+    renderSDWANGroups(state.sdwanGroups);
+    renderSDWANMembers(state.sdwanGroups);
+    updateSDWANMetrics(state.sdwanTelemetry);
+
+    if (manual) showToast('SD-WAN', 'SD-WAN groups and live telemetry reloaded', 'success');
+  } catch (err) {
+    console.error('loadSDWAN error:', err);
+    if (manual) showToast('Error', err.message, 'error');
+  }
+}
+
+function updateSDWANMetrics(telemetry) {
+  const groups = state.sdwanGroups || [];
+  const t = telemetry || {};
+
+  const badge = document.getElementById('badge-sdwan-count');
+  if (badge) badge.textContent = groups.length;
+
+  const metricGroups = document.getElementById('sdwan-metric-groups');
+  if (metricGroups) metricGroups.textContent = `${t.active_groups || 0} / ${t.total_groups || groups.length}`;
+
+  const metricHealthy = document.getElementById('sdwan-metric-healthy');
+  if (metricHealthy) metricHealthy.textContent = t.healthy_members || 0;
+
+  const metricDegraded = document.getElementById('sdwan-metric-degraded');
+  if (metricDegraded) metricDegraded.textContent = t.degraded_members || 0;
+
+  const metricDown = document.getElementById('sdwan-metric-down');
+  if (metricDown) metricDown.textContent = t.down_members || 0;
+}
+
+function renderSDWANGroups(groups) {
+  const tbody = document.getElementById('sdwan-groups-table-body');
+  if (!tbody) return;
+
+  if (!groups || groups.length === 0) {
+    tbody.innerHTML = '<tr><td colspan="8" class="py-8 text-center text-slate-500 text-xs font-sans">No SD-WAN groups configured yet. Click "Create SD-WAN Group" to begin.</td></tr>';
+    return;
+  }
+
+  tbody.innerHTML = groups.map(g => {
+    const isEnabled = g.disabled !== true;
+    const members = g.members || [];
+    const totalWeight = members.filter(m => !m.disabled).reduce((sum, m) => sum + (m.weight || 50), 0) || 1;
+
+    // Build distribution progress bar
+    let distHtml = '';
+    if (members.length === 0) {
+      distHtml = '<span class="text-slate-500 text-[11px] font-sans">No links attached</span>';
+    } else if (g.mode === 'weighted' || g.mode === 'pcc') {
+      const colors = ['bg-amber-500', 'bg-cyan-500', 'bg-emerald-500', 'bg-purple-500', 'bg-rose-500'];
+      const segments = members.map((m, idx) => {
+        const pct = m.disabled ? 0 : Math.round(((m.weight || 50) / totalWeight) * 100);
+        const col = colors[idx % colors.length];
+        return `<div class="${col} h-2 transition-all relative group" style="width: ${pct}%" title="${escapeHtml(m.interface)}: ${pct}%"></div>`;
+      }).join('');
+
+      const labels = members.map((m, idx) => {
+        const pct = m.disabled ? '0%' : `${Math.round(((m.weight || 50) / totalWeight) * 100)}%`;
+        const textCol = ['text-amber-400', 'text-cyan-400', 'text-emerald-400', 'text-purple-400', 'text-rose-400'][idx % 5];
+        return `<span class="${textCol} text-[10px] font-mono mr-2">${escapeHtml(m.interface)}: ${pct}</span>`;
+      }).join('');
+
+      distHtml = `
+        <div class="w-48 bg-slate-800 rounded-full h-2 overflow-hidden flex mb-1 border border-slate-700/50">${segments}</div>
+        <div class="flex flex-wrap">${labels}</div>
+      `;
+    } else if (g.mode === 'lowest-latency') {
+      distHtml = '<span class="text-emerald-400 text-[11px] font-sans">⚡ Auto: lowest RTT probe</span>';
+    } else if (g.mode === 'lowest-loss') {
+      distHtml = '<span class="text-cyan-400 text-[11px] font-sans">🛡 Auto: minimum packet loss</span>';
+    } else if (g.mode === 'failover') {
+      distHtml = '<span class="text-amber-400 text-[11px] font-sans">🔄 Priority Failover (Active-Backup)</span>';
+    }
+
+    let modeBadge = '';
+    switch (g.mode) {
+      case 'weighted':
+        modeBadge = '<span class="px-2 py-0.5 rounded bg-amber-500/20 text-amber-300 border border-amber-500/30 text-[10px] font-bold">Weighted LB</span>';
+        break;
+      case 'pcc':
+        modeBadge = '<span class="px-2 py-0.5 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30 text-[10px] font-bold">PCC Hashing</span>';
+        break;
+      case 'lowest-latency':
+        modeBadge = '<span class="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 text-[10px] font-bold">Lowest Latency SLA</span>';
+        break;
+      case 'lowest-loss':
+        modeBadge = '<span class="px-2 py-0.5 rounded bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-[10px] font-bold">Lowest Loss SLA</span>';
+        break;
+      case 'failover':
+        modeBadge = '<span class="px-2 py-0.5 rounded bg-rose-500/20 text-rose-300 border border-rose-500/30 text-[10px] font-bold">Failover</span>';
+        break;
+      default:
+        modeBadge = `<span class="px-2 py-0.5 rounded bg-slate-800 text-slate-300 text-[10px] font-bold">${escapeHtml(g.mode)}</span>`;
+    }
+
+    const autoNatBadge = g.auto_nat
+      ? '<span class="text-emerald-400 text-[11px]">Enabled</span>'
+      : '<span class="text-slate-500 text-[11px]">Disabled</span>';
+
+    return `
+      <tr class="hover:bg-slate-800/40 transition">
+        <td class="py-3 px-4">
+          <button onclick="toggleSDWANGroupState('${escapeHtml(g.id)}', ${isEnabled})" class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${isEnabled ? 'bg-amber-500' : 'bg-slate-700'}" title="${isEnabled ? 'Click to disable' : 'Click to enable'}">
+            <span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out ${isEnabled ? 'translate-x-4' : 'translate-x-0'}"></span>
+          </button>
+        </td>
+        <td class="py-3 px-4">
+          <div class="font-bold text-white text-xs flex items-center gap-1.5">
+            <span class="px-2 py-0.5 rounded bg-amber-950/60 text-amber-300 border border-amber-800/40 font-mono text-[11px] font-bold">${escapeHtml(g.name)}</span>
+            ${g.comment ? `<span class="text-slate-400 text-[10px] font-sans truncate max-w-[150px]" title="${escapeHtml(g.comment)}">(${escapeHtml(g.comment)})</span>` : ''}
+          </div>
+        </td>
+        <td class="py-3 px-4">${modeBadge}</td>
+        <td class="py-3 px-4">${distHtml}</td>
+        <td class="py-3 px-4">
+          <span class="px-2 py-0.5 rounded bg-slate-800 text-slate-200 border border-slate-700 font-mono text-[11px]">${members.length} links</span>
+        </td>
+        <td class="py-3 px-4 text-slate-300 font-mono text-xs">${g.probe_interval || 3}s</td>
+        <td class="py-3 px-4">${autoNatBadge}</td>
+        <td class="py-3 px-4 text-right">
+          <div class="flex items-center justify-end gap-1.5">
+            <button onclick="openAddSDWANMemberModal('${escapeHtml(g.id)}')" class="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-amber-300 transition" title="Add Member Link">
+              <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
+            </button>
+            <button onclick="openEditSDWANGroupModal('${escapeHtml(g.id)}')" class="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 transition" title="Edit Group">
+              <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
+            </button>
+            <button onclick="deleteSDWANGroup('${escapeHtml(g.id)}')" class="p-1.5 rounded-lg bg-rose-950/40 hover:bg-rose-900/60 text-rose-400 border border-rose-800/40 transition" title="Delete Group">
+              <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
+            </button>
+          </div>
+        </td>
+      </tr>
+    `;
+  }).join('');
+}
+
+function renderSDWANMembers(groups) {
+  const tbody = document.getElementById('sdwan-members-table-body');
+  if (!tbody) return;
+
+  const allMembers = [];
+  (groups || []).forEach(g => {
+    (g.members || []).forEach(m => {
+      allMembers.push({ ...m, group_id: g.id, group_name: g.name });
+    });
+  });
+
+  if (allMembers.length === 0) {
+    tbody.innerHTML = '<tr><td colspan="11" class="py-8 text-center text-slate-500 text-xs font-sans">No member WAN links configured. Click "Add Member Link" above.</td></tr>';
+    return;
+  }
+
+  tbody.innerHTML = allMembers.map(m => {
+    const isEnabled = m.disabled !== true;
+
+    let statusBadge = '<span class="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 text-[10px] font-bold">HEALTHY</span>';
+    if (!isEnabled) {
+      statusBadge = '<span class="px-2 py-0.5 rounded bg-slate-800 text-slate-400 border border-slate-700 text-[10px]">DISABLED</span>';
+    } else if (m.status === 'DEGRADED') {
+      statusBadge = '<span class="px-2 py-0.5 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30 text-[10px] font-bold">DEGRADED</span>';
+    } else if (m.status === 'DOWN') {
+      statusBadge = '<span class="px-2 py-0.5 rounded bg-rose-500/20 text-rose-400 border border-rose-500/30 text-[10px] font-bold animate-pulse">DOWN</span>';
+    }
+
+    const latColor = m.live_latency > (m.max_latency || 250) ? 'text-amber-400' : 'text-emerald-400';
+    const lossColor = m.packet_loss > (m.max_loss || 20) ? 'text-rose-400' : 'text-slate-300';
+
+    return `
+      <tr class="hover:bg-slate-800/40 transition">
+        <td class="py-3 px-4">
+          <button onclick="toggleSDWANMemberState('${escapeHtml(m.group_id)}', '${escapeHtml(m.id)}', ${isEnabled})" class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${isEnabled ? 'bg-amber-500' : 'bg-slate-700'}" title="${isEnabled ? 'Click to disable link' : 'Click to enable link'}">
+            <span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out ${isEnabled ? 'translate-x-4' : 'translate-x-0'}"></span>
+          </button>
+        </td>
+        <td class="py-3 px-4">
+          <span class="px-2 py-0.5 rounded bg-amber-950/60 text-amber-300 border border-amber-800/40 font-mono text-[11px] font-bold">${escapeHtml(m.group_name)}</span>
+        </td>
+        <td class="py-3 px-4">
+          <div class="font-bold text-white text-xs font-mono">${escapeHtml(m.interface)}</div>
+          ${m.comment ? `<div class="text-[10px] text-slate-500 font-sans">${escapeHtml(m.comment)}</div>` : ''}
+        </td>
+        <td class="py-3 px-4 text-slate-300 font-mono text-xs">${escapeHtml(m.gateway || '(auto)')}</td>
+        <td class="py-3 px-4">
+          <div class="text-xs font-mono text-white">W: ${m.weight || 50}</div>
+          <div class="text-[10px] text-slate-500">Prio: ${m.priority || 1}</div>
+        </td>
+        <td class="py-3 px-4">${statusBadge}</td>
+        <td class="py-3 px-4 text-cyan-400 font-mono text-xs">${escapeHtml(m.target_probe || '1.1.1.1')}</td>
+        <td class="py-3 px-4 font-mono text-xs ${latColor}">
+          ${m.live_latency ? `${m.live_latency.toFixed(1)} ms` : '<span class="text-slate-500">--</span>'}
+        </td>
+        <td class="py-3 px-4 font-mono text-xs ${lossColor}">
+          ${m.packet_loss !== undefined ? `${m.packet_loss.toFixed(1)}%` : '<span class="text-slate-500">--</span>'}
+        </td>
+        <td class="py-3 px-4 font-mono text-xs text-slate-400">
+          ${m.jitter ? `${m.jitter.toFixed(1)} ms` : '<span class="text-slate-500">--</span>'}
+        </td>
+        <td class="py-3 px-4 text-right">
+          <div class="flex items-center justify-end gap-1.5">
+            <button onclick="openEditSDWANMemberModal('${escapeHtml(m.group_id)}', '${escapeHtml(m.id)}')" class="p-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 transition" title="Edit Member">
+              <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
+            </button>
+            <button onclick="deleteSDWANMember('${escapeHtml(m.group_id)}', '${escapeHtml(m.interface)}')" class="p-1.5 rounded-lg bg-rose-950/40 hover:bg-rose-900/60 text-rose-400 border border-rose-800/40 transition" title="Remove Link">
+              <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
+            </button>
+          </div>
+        </td>
+      </tr>
+    `;
+  }).join('');
+}
+
+// Modal handling for SD-WAN Groups
+function openAddSDWANGroupModal() {
+  const modal = document.getElementById('modal-sdwan-group');
+  if (!modal) return;
+
+  document.getElementById('sdwan-group-modal-title').querySelector('span').textContent = 'Create SD-WAN Group';
+  document.getElementById('sdwan-group-id').value = '';
+  document.getElementById('sdwan-group-name').value = `sdwan${(state.sdwanGroups || []).length}`;
+  document.getElementById('sdwan-group-name').disabled = false;
+  document.getElementById('sdwan-group-mode').value = 'weighted';
+  document.getElementById('sdwan-group-pcc-container').classList.add('hidden');
+  document.getElementById('sdwan-group-hash-fields').value = 'both-addresses-and-ports';
+  document.getElementById('sdwan-group-probe-interval').value = 3;
+  document.getElementById('sdwan-group-auto-nat').checked = true;
+  document.getElementById('sdwan-group-comment').value = '';
+
+  modal.classList.remove('hidden');
+}
+
+function openEditSDWANGroupModal(id) {
+  const group = (state.sdwanGroups || []).find(g => g.id === id);
+  if (!group) return;
+
+  const modal = document.getElementById('modal-sdwan-group');
+  if (!modal) return;
+
+  document.getElementById('sdwan-group-modal-title').querySelector('span').textContent = `Edit SD-WAN Group (${group.name})`;
+  document.getElementById('sdwan-group-id').value = group.id;
+  document.getElementById('sdwan-group-name').value = group.name;
+  document.getElementById('sdwan-group-name').disabled = true;
+  document.getElementById('sdwan-group-mode').value = group.mode || 'weighted';
+  if (group.mode === 'pcc') {
+    document.getElementById('sdwan-group-pcc-container').classList.remove('hidden');
+  } else {
+    document.getElementById('sdwan-group-pcc-container').classList.add('hidden');
+  }
+  document.getElementById('sdwan-group-hash-fields').value = group.hash_fields || 'both-addresses-and-ports';
+  document.getElementById('sdwan-group-probe-interval').value = group.probe_interval || 3;
+  document.getElementById('sdwan-group-auto-nat').checked = group.auto_nat !== false;
+  document.getElementById('sdwan-group-comment').value = group.comment || '';
+
+  modal.classList.remove('hidden');
+}
+
+function closeSDWANGroupModal() {
+  const modal = document.getElementById('modal-sdwan-group');
+  if (modal) modal.classList.add('hidden');
+}
+
+function onSDWANModeChange() {
+  const mode = document.getElementById('sdwan-group-mode').value;
+  const pccContainer = document.getElementById('sdwan-group-pcc-container');
+  if (pccContainer) {
+    if (mode === 'pcc') pccContainer.classList.remove('hidden');
+    else pccContainer.classList.add('hidden');
+  }
+}
+
+async function saveSDWANGroup(event) {
+  event.preventDefault();
+
+  const id = document.getElementById('sdwan-group-id').value;
+  const name = document.getElementById('sdwan-group-name').value.trim();
+  const mode = document.getElementById('sdwan-group-mode').value;
+  const hashFields = document.getElementById('sdwan-group-hash-fields').value;
+  const probeInterval = parseInt(document.getElementById('sdwan-group-probe-interval').value, 10) || 3;
+  const autoNAT = document.getElementById('sdwan-group-auto-nat').checked;
+  const comment = document.getElementById('sdwan-group-comment').value.trim();
+
+  const payload = {
+    id: id || undefined,
+    name,
+    mode,
+    hash_fields: mode === 'pcc' ? hashFields : undefined,
+    probe_interval: probeInterval,
+    auto_nat: autoNAT,
+    comment
+  };
+
+  try {
+    const res = await fetch(`${API_BASE}/sdwan/groups`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+
+    if (!res.ok) {
+      const err = await res.json().catch(() => ({}));
+      throw new Error(err.message || 'Failed to save SD-WAN group');
+    }
+
+    closeSDWANGroupModal();
+    showToast('Success', `SD-WAN group ${name} saved successfully`, 'success');
+    await loadSDWAN(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleSDWANGroupState(id, currentState) {
+  try {
+    const res = await fetch(`${API_BASE}/sdwan/groups/${id}/state`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ disabled: currentState })
+    });
+
+    if (!res.ok) throw new Error('Failed to update group state');
+    await loadSDWAN(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteSDWANGroup(id) {
+  const group = (state.sdwanGroups || []).find(g => g.id === id);
+  const name = group ? group.name : id;
+  if (!confirm(`Are you sure you want to delete SD-WAN group "${name}"? Member routing tables will be cleared.`)) return;
+
+  try {
+    const res = await fetch(`${API_BASE}/sdwan/groups/${id}`, {
+      method: 'DELETE'
+    });
+
+    if (!res.ok) {
+      const err = await res.json().catch(() => ({}));
+      throw new Error(err.message || 'Failed to delete SD-WAN group');
+    }
+
+    showToast('Deleted', `SD-WAN group ${name} removed`, 'success');
+    await loadSDWAN(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+// Modal handling for SD-WAN Members
+function populateSDWANGroupsSelect(selectedId = '') {
+  const sel = document.getElementById('sdwan-member-group-id');
+  if (!sel) return;
+  sel.innerHTML = '';
+  (state.sdwanGroups || []).forEach(g => {
+    const opt = document.createElement('option');
+    opt.value = g.id;
+    opt.textContent = `${g.name} (${g.mode})`;
+    if (g.id === selectedId) opt.selected = true;
+    sel.appendChild(opt);
+  });
+}
+
+function populateSDWANMemberInterfaceSelect(selectedVal = '') {
+  const sel = document.getElementById('sdwan-member-interface');
+  if (!sel) return;
+  sel.innerHTML = '';
+  (state.interfaces || []).forEach(i => {
+    const opt = document.createElement('option');
+    opt.value = i.name;
+    opt.textContent = `${i.name} (${i.role || i.type})`;
+    if (i.name === selectedVal) opt.selected = true;
+    sel.appendChild(opt);
+  });
+}
+
+function openAddSDWANMemberModal(defaultGroupId = null) {
+  const modal = document.getElementById('modal-sdwan-member');
+  if (!modal) return;
+
+  if (!state.sdwanGroups || state.sdwanGroups.length === 0) {
+    showToast('Notice', 'Please create an SD-WAN group first before adding member links', 'warning');
+    return;
+  }
+
+  document.getElementById('sdwan-member-modal-title').querySelector('span').textContent = 'Add Member Link';
+  document.getElementById('sdwan-member-id').value = '';
+  populateSDWANGroupsSelect(defaultGroupId || (state.sdwanGroups[0] && state.sdwanGroups[0].id));
+  populateSDWANMemberInterfaceSelect();
+  document.getElementById('sdwan-member-gateway').value = '';
+  document.getElementById('sdwan-member-weight').value = 50;
+  document.getElementById('sdwan-member-priority').value = 1;
+  document.getElementById('sdwan-member-probe').value = '1.1.1.1';
+  document.getElementById('sdwan-member-max-lat').value = 250;
+  document.getElementById('sdwan-member-max-loss').value = 20;
+  document.getElementById('sdwan-member-comment').value = '';
+
+  modal.classList.remove('hidden');
+}
+
+function openEditSDWANMemberModal(groupId, memberId) {
+  const group = (state.sdwanGroups || []).find(g => g.id === groupId);
+  if (!group) return;
+  const member = (group.members || []).find(m => m.id === memberId);
+  if (!member) return;
+
+  const modal = document.getElementById('modal-sdwan-member');
+  if (!modal) return;
+
+  document.getElementById('sdwan-member-modal-title').querySelector('span').textContent = `Edit Member Link (${member.interface})`;
+  document.getElementById('sdwan-member-id').value = member.id;
+  populateSDWANGroupsSelect(group.id);
+  document.getElementById('sdwan-member-group-id').disabled = true;
+  populateSDWANMemberInterfaceSelect(member.interface);
+  document.getElementById('sdwan-member-interface').disabled = true;
+  document.getElementById('sdwan-member-gateway').value = member.gateway || '';
+  document.getElementById('sdwan-member-weight').value = member.weight || 50;
+  document.getElementById('sdwan-member-priority').value = member.priority || 1;
+  document.getElementById('sdwan-member-probe').value = member.target_probe || '1.1.1.1';
+  document.getElementById('sdwan-member-max-lat').value = member.max_latency || 250;
+  document.getElementById('sdwan-member-max-loss').value = member.max_loss || 20;
+  document.getElementById('sdwan-member-comment').value = member.comment || '';
+
+  modal.classList.remove('hidden');
+}
+
+function closeSDWANMemberModal() {
+  const modal = document.getElementById('modal-sdwan-member');
+  if (modal) {
+    document.getElementById('sdwan-member-group-id').disabled = false;
+    document.getElementById('sdwan-member-interface').disabled = false;
+    modal.classList.add('hidden');
+  }
+}
+
+async function saveSDWANMember(event) {
+  event.preventDefault();
+
+  const id = document.getElementById('sdwan-member-id').value;
+  const groupId = document.getElementById('sdwan-member-group-id').value;
+  const iface = document.getElementById('sdwan-member-interface').value;
+  const gateway = document.getElementById('sdwan-member-gateway').value.trim();
+  const weight = parseInt(document.getElementById('sdwan-member-weight').value, 10) || 50;
+  const priority = parseInt(document.getElementById('sdwan-member-priority').value, 10) || 1;
+  const targetProbe = document.getElementById('sdwan-member-probe').value.trim() || '1.1.1.1';
+  const maxLatency = parseInt(document.getElementById('sdwan-member-max-lat').value, 10) || 250;
+  const maxLoss = parseInt(document.getElementById('sdwan-member-max-loss').value, 10) || 20;
+  const comment = document.getElementById('sdwan-member-comment').value.trim();
+
+  const payload = {
+    id: id || undefined,
+    interface: iface,
+    gateway,
+    weight,
+    priority,
+    target_probe: targetProbe,
+    max_latency: maxLatency,
+    max_loss: maxLoss,
+    comment
+  };
+
+  try {
+    const res = await fetch(`${API_BASE}/sdwan/groups/${groupId}/members`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+
+    if (!res.ok) {
+      const err = await res.json().catch(() => ({}));
+      throw new Error(err.message || 'Failed to save member link');
+    }
+
+    closeSDWANMemberModal();
+    showToast('Success', `Link ${iface} saved to SD-WAN group`, 'success');
+    await loadSDWAN(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function toggleSDWANMemberState(groupId, memberId, currentState) {
+  const group = (state.sdwanGroups || []).find(g => g.id === groupId);
+  if (!group) return;
+  const member = (group.members || []).find(m => m.id === memberId);
+  if (!member) return;
+
+  const payload = {
+    ...member,
+    disabled: currentState
+  };
+
+  try {
+    const res = await fetch(`${API_BASE}/sdwan/groups/${groupId}/members`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+
+    if (!res.ok) throw new Error('Failed to update member state');
+    await loadSDWAN(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+
+async function deleteSDWANMember(groupId, iface) {
+  if (!confirm(`Are you sure you want to remove link "${iface}" from this SD-WAN group?`)) return;
+
+  try {
+    const res = await fetch(`${API_BASE}/sdwan/groups/${groupId}/members/${iface}`, {
+      method: 'DELETE'
+    });
+
+    if (!res.ok) {
+      const err = await res.json().catch(() => ({}));
+      throw new Error(err.message || 'Failed to remove member link');
+    }
+
+    showToast('Removed', `Link ${iface} removed from SD-WAN group`, 'success');
+    await loadSDWAN(false);
+  } catch (err) {
+    showToast('Error', err.message, 'error');
+  }
+}
+

+ 271 - 0
internal/web/static/index.html

@@ -227,6 +227,17 @@
           <span id="badge-routes-count" class="text-[10px] bg-slate-800 text-slate-300 px-1.5 py-0.2 rounded-full font-mono">0</span>
         </a>
 
+        <a href="#sdwan" onclick="navigate('sdwan')" id="nav-sdwan"
+           class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
+          <div class="flex items-center space-x-2.5">
+            <svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
+            </svg>
+            <span>SD-WAN</span>
+          </div>
+          <span id="badge-sdwan-count" class="text-[10px] bg-amber-950 text-amber-400 border border-amber-800/50 px-1.5 py-0.2 rounded-full font-mono">0</span>
+        </a>
+
         <a href="#bgp" onclick="navigate('bgp')" id="nav-bgp"
            class="nav-link flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all group text-slate-300 hover:bg-slate-800/80 hover:text-white">
           <div class="flex items-center space-x-2.5">
@@ -2744,6 +2755,123 @@
           </div>
         </section>
 
+        <!-- ================= SECTION: SD-WAN ================= -->
+        <section id="section-sdwan" class="hidden space-y-6">
+          <!-- Header and Stats Banner -->
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg flex flex-wrap items-center justify-between gap-4">
+            <div>
+              <h3 class="text-base font-bold text-white flex items-center gap-2">
+                <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
+                </svg>
+                <span>Software-Defined WAN (SD-WAN)</span>
+              </h3>
+              <p class="text-xs text-slate-400 mt-1">Multi-link aggregation, percentage balancing, SLA quality probes, and unified firewall zone interfaces.</p>
+            </div>
+            <div class="flex items-center gap-2 flex-wrap">
+              <button onclick="loadSDWAN(true)" class="px-3.5 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 text-xs font-semibold flex items-center gap-1.5 transition">
+                <svg class="w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
+                <span>Refresh</span>
+              </button>
+              <button onclick="openAddSDWANMemberModal()" class="px-3.5 py-1.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-amber-300 border border-amber-500/30 text-xs font-semibold flex items-center gap-1.5 transition">
+                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
+                <span>Add Member Link</span>
+              </button>
+              <button onclick="openAddSDWANGroupModal()" class="px-3.5 py-1.5 rounded-xl bg-amber-600 hover:bg-amber-500 text-white text-xs font-semibold flex items-center gap-1.5 shadow-lg shadow-amber-600/20 transition">
+                <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
+                <span>Create SD-WAN Group</span>
+              </button>
+            </div>
+          </div>
+
+          <!-- Quick Metrics Cards -->
+          <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
+            <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 shadow-sm">
+              <div class="text-[11px] font-mono text-slate-400 uppercase tracking-wider">SD-WAN Groups</div>
+              <div class="text-2xl font-bold text-amber-400 mt-1" id="sdwan-metric-groups">0</div>
+              <div class="text-[10px] text-slate-500 mt-1">Configured multi-link zones</div>
+            </div>
+
+            <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 shadow-sm">
+              <div class="text-[11px] font-mono text-slate-400 uppercase tracking-wider">Healthy Links</div>
+              <div class="text-2xl font-bold text-emerald-400 mt-1 flex items-center gap-2">
+                <span class="w-2.5 h-2.5 rounded-full bg-emerald-500 animate-pulse"></span>
+                <span id="sdwan-metric-healthy">0</span>
+              </div>
+              <div class="text-[10px] text-slate-500 mt-1">Passing SLA latency & loss checks</div>
+            </div>
+
+            <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 shadow-sm">
+              <div class="text-[11px] font-mono text-slate-400 uppercase tracking-wider">Degraded Links</div>
+              <div class="text-2xl font-bold text-amber-400 mt-1" id="sdwan-metric-degraded">0</div>
+              <div class="text-[10px] text-slate-500 mt-1">Exceeding SLA latency/loss thresholds</div>
+            </div>
+
+            <div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-4 shadow-sm">
+              <div class="text-[11px] font-mono text-slate-400 uppercase tracking-wider">Down Links</div>
+              <div class="text-2xl font-bold text-rose-400 font-mono mt-1" id="sdwan-metric-down">0</div>
+              <div class="text-[10px] text-slate-500 mt-1">Offline or probe unreachable</div>
+            </div>
+          </div>
+
+          <!-- SD-WAN Groups Table -->
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <h4 class="text-xs font-bold text-slate-300 uppercase tracking-wider font-mono">SD-WAN Groups & Zones</h4>
+              <span class="text-[11px] text-slate-500">Virtual aggregation interfaces usable directly in Firewall rules</span>
+            </div>
+            <div class="overflow-x-auto">
+              <table class="w-full text-left border-collapse text-xs">
+                <thead>
+                  <tr class="border-b border-slate-800/80 bg-slate-950/40 text-[11px] font-mono uppercase text-slate-400">
+                    <th class="py-2.5 px-4">State</th>
+                    <th class="py-2.5 px-4">Group Name</th>
+                    <th class="py-2.5 px-4">Balancing Mode</th>
+                    <th class="py-2.5 px-4">Load Distribution</th>
+                    <th class="py-2.5 px-4">Members</th>
+                    <th class="py-2.5 px-4">Probe Interval</th>
+                    <th class="py-2.5 px-4">Auto-NAT</th>
+                    <th class="py-2.5 px-4 text-right">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="sdwan-groups-table-body" class="divide-y divide-slate-800/60 font-mono">
+                  <tr><td colspan="8" class="py-8 text-center text-slate-500 font-sans">Loading SD-WAN groups...</td></tr>
+                </tbody>
+              </table>
+            </div>
+          </div>
+
+          <!-- SD-WAN Member Interfaces Table -->
+          <div class="bg-slate-900/80 border border-slate-800 rounded-2xl overflow-hidden shadow-xl">
+            <div class="p-4 border-b border-slate-800 flex items-center justify-between">
+              <h4 class="text-xs font-bold text-slate-300 uppercase tracking-wider font-mono">Member WAN Links & Live SLA Telemetry</h4>
+              <span class="text-[11px] text-slate-500">Active latency, packet loss, jitter and routing tables</span>
+            </div>
+            <div class="overflow-x-auto">
+              <table class="w-full text-left border-collapse text-xs">
+                <thead>
+                  <tr class="border-b border-slate-800/80 bg-slate-950/40 text-[11px] font-mono uppercase text-slate-400">
+                    <th class="py-2.5 px-4">State</th>
+                    <th class="py-2.5 px-4">Group</th>
+                    <th class="py-2.5 px-4">Interface</th>
+                    <th class="py-2.5 px-4">Gateway</th>
+                    <th class="py-2.5 px-4">Weight / Prio</th>
+                    <th class="py-2.5 px-4">Health Status</th>
+                    <th class="py-2.5 px-4">Target Probe</th>
+                    <th class="py-2.5 px-4">Live Latency</th>
+                    <th class="py-2.5 px-4">Packet Loss</th>
+                    <th class="py-2.5 px-4">Jitter</th>
+                    <th class="py-2.5 px-4 text-right">Actions</th>
+                  </tr>
+                </thead>
+                <tbody id="sdwan-members-table-body" class="divide-y divide-slate-800/60 font-mono">
+                  <tr><td colspan="11" class="py-8 text-center text-slate-500 font-sans">No member links configured yet.</td></tr>
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </section>
+
         <!-- ================= SECTION: BGP ROUTING ================= -->
         <section id="section-bgp" class="hidden space-y-6">
           <!-- Top Routing Switcher Bar -->
@@ -11941,6 +12069,149 @@
     </div>
   </div>
 
+  <!-- ================= MODAL: SD-WAN GROUP ================= -->
+  <div id="modal-sdwan-group" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden max-h-[90vh] flex flex-col">
+      <div class="px-5 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
+        <h3 id="sdwan-group-modal-title" class="text-sm font-bold text-white flex items-center gap-2">
+          <svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
+          <span>Create SD-WAN Group</span>
+        </h3>
+        <button type="button" onclick="closeSDWANGroupModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="sdwan-group-form" onsubmit="saveSDWANGroup(event)" class="p-5 space-y-4 overflow-y-auto flex-1">
+        <input type="hidden" id="sdwan-group-id">
+
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Group Interface Name *</label>
+            <input type="text" id="sdwan-group-name" placeholder="e.g. sdwan0 or sdwan-internet" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500">
+            <p class="text-[10px] text-slate-500 mt-1">Acts as unified interface in Firewall.</p>
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Distribution Strategy *</label>
+            <select id="sdwan-group-mode" onchange="onSDWANModeChange()" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500">
+              <option value="weighted">Weighted Percentage (Load Balance)</option>
+              <option value="pcc">PCC (Per-Connection Classifier Hashing)</option>
+              <option value="lowest-latency">Lowest Latency SLA (Minimum RTT)</option>
+              <option value="lowest-loss">Lowest Loss SLA (Minimum Drop)</option>
+              <option value="failover">Active-Backup (Priority Failover)</option>
+            </select>
+          </div>
+        </div>
+
+        <div id="sdwan-group-pcc-container" class="hidden">
+          <label class="block text-xs font-semibold text-slate-300 mb-1">PCC Flow Hash Fields</label>
+          <select id="sdwan-group-hash-fields" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500">
+            <option value="both-addresses-and-ports">Both Addresses & Ports (Recommended)</option>
+            <option value="src-address">Source Address Only</option>
+            <option value="both-addresses">Both Addresses (Source & Dest)</option>
+            <option value="dst-address">Destination Address Only</option>
+          </select>
+        </div>
+
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Probe Interval (seconds)</label>
+            <input type="number" id="sdwan-group-probe-interval" value="3" min="1" max="60" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500">
+          </div>
+          <div class="flex items-center pt-6">
+            <label class="flex items-center gap-2 cursor-pointer bg-slate-950/60 border border-slate-800/80 p-2.5 rounded-xl w-full">
+              <input type="checkbox" id="sdwan-group-auto-nat" checked class="rounded bg-slate-800 border-slate-700 text-amber-600 focus:ring-0">
+              <div>
+                <span class="text-xs font-semibold text-white block">Auto-NAT Masquerade</span>
+                <span class="text-[10px] text-slate-400 block">Masquerade egress WAN links automatically</span>
+              </div>
+            </label>
+          </div>
+        </div>
+
+        <div>
+          <label class="block text-xs font-semibold text-slate-300 mb-1">Comment</label>
+          <input type="text" id="sdwan-group-comment" placeholder="Optional description" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500">
+        </div>
+
+        <div class="px-5 py-3 -mx-5 -mb-5 bg-slate-950/50 border-t border-slate-800 flex items-center justify-end gap-2">
+          <button type="button" onclick="closeSDWANGroupModal()" class="px-4 py-2 rounded-xl text-xs font-semibold bg-slate-800 hover:bg-slate-700 text-slate-300 transition">Cancel</button>
+          <button type="submit" class="px-4 py-2 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/20 transition">Save Group</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ================= MODAL: SD-WAN MEMBER LINK ================= -->
+  <div id="modal-sdwan-member" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
+    <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden max-h-[90vh] flex flex-col">
+      <div class="px-5 py-4 border-b border-slate-800 flex items-center justify-between bg-slate-950/50">
+        <h3 id="sdwan-member-modal-title" class="text-sm font-bold text-white flex items-center gap-2">
+          <svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
+          <span>Add Member Link</span>
+        </h3>
+        <button type="button" onclick="closeSDWANMemberModal()" class="text-slate-400 hover:text-white p-1 rounded-lg">✕</button>
+      </div>
+      <form id="sdwan-member-form" onsubmit="saveSDWANMember(event)" class="p-5 space-y-4 overflow-y-auto flex-1">
+        <input type="hidden" id="sdwan-member-id">
+
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Target SD-WAN Group *</label>
+            <select id="sdwan-member-group-id" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500"></select>
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Member Interface *</label>
+            <select id="sdwan-member-interface" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500"></select>
+          </div>
+        </div>
+
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Gateway IPv4</label>
+            <input type="text" id="sdwan-member-gateway" placeholder="e.g. 192.168.1.1 (leave blank for auto)" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500">
+            <p class="text-[10px] text-slate-500 mt-1">Next-hop router for this link.</p>
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Weight Ratio (1 - 100)</label>
+            <input type="number" id="sdwan-member-weight" value="50" min="1" max="100" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500">
+            <p class="text-[10px] text-slate-500 mt-1">Relative percentage ratio (e.g. 70 vs 30).</p>
+          </div>
+        </div>
+
+        <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Failover Priority</label>
+            <input type="number" id="sdwan-member-priority" value="1" min="1" max="10" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500">
+            <p class="text-[10px] text-slate-500 mt-1">1 = Highest Primary.</p>
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Health Ping Target</label>
+            <input type="text" id="sdwan-member-probe" value="1.1.1.1" required class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500">
+            <p class="text-[10px] text-slate-500 mt-1">e.g. 1.1.1.1 or 8.8.8.8.</p>
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Max SLA Latency (ms)</label>
+            <input type="number" id="sdwan-member-max-lat" value="250" min="5" max="2000" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500">
+          </div>
+        </div>
+
+        <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Max Packet Loss SLA (%)</label>
+            <input type="number" id="sdwan-member-max-loss" value="20" min="0" max="100" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white font-mono focus:outline-none focus:border-amber-500">
+          </div>
+          <div>
+            <label class="block text-xs font-semibold text-slate-300 mb-1">Comment</label>
+            <input type="text" id="sdwan-member-comment" placeholder="e.g. Primary Fiber ISP" class="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-amber-500">
+          </div>
+        </div>
+
+        <div class="px-5 py-3 -mx-5 -mb-5 bg-slate-950/50 border-t border-slate-800 flex items-center justify-end gap-2">
+          <button type="button" onclick="closeSDWANMemberModal()" class="px-4 py-2 rounded-xl text-xs font-semibold bg-slate-800 hover:bg-slate-700 text-slate-300 transition">Cancel</button>
+          <button type="submit" class="px-4 py-2 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white shadow-lg shadow-amber-600/20 transition">Save Member</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
   <!-- ================= MODAL: RESTORE BACKUP CONFIRMATION ================= -->
   <div id="modal-restore-confirm" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4">
     <div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden">