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 // ResolvedRouteNexthop represents a target egress interface/gateway for routing. type ResolvedRouteNexthop struct { Gateway string `json:"gateway"` Interface string `json:"interface"` Weight int `json:"weight"` } var ( stateChangeCallbacks []func() stateChangeMu sync.Mutex ) // RegisterStateChangeCallback registers a function to be invoked when SD-WAN link health or topology changes. func RegisterStateChangeCallback(cb func()) { if cb == nil { return } stateChangeMu.Lock() defer stateChangeMu.Unlock() stateChangeCallbacks = append(stateChangeCallbacks, cb) } // NotifyStateChange invokes all registered state change callbacks. func NotifyStateChange() { stateChangeMu.Lock() cbs := make([]func(), len(stateChangeCallbacks)) copy(cbs, stateChangeCallbacks) stateChangeMu.Unlock() for _, cb := range cbs { go cb() } } // 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) NotifyStateChange() }) 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() } if m.engine != nil { m.engine.mu.Lock() for dev := range m.engine.virtualDevices { m.engine.removeVirtualDevice(dev) } m.engine.virtualDevices = make(map[string]bool) m.engine.cleanupMangle() m.engine.cleanupIPRules() m.engine.mu.Unlock() } } // Has checks if an SD-WAN group exists by ID or name. func (m *Manager) Has(idOrName string) bool { _, found := m.Get(idOrName) return found } func (m *Manager) load() { 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) } } // Preserve existing members if not specified in update payload if existing, ok := m.groups[g.ID]; ok { if g.Members == nil && existing.Members != nil { g.Members = existing.Members } } // 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.syncLocked() } func (m *Manager) syncLocked() error { err := m.engine.Sync(m.listLocked()) _ = m.save() NotifyStateChange() return err } // 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.syncLocked() } // 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.syncLocked() } // 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.syncLocked() } // 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 (mem.ID != "" && existing.ID == mem.ID) || (mem.Interface != "" && 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 existing ID & Interface if not specified if mem.ID == "" { mem.ID = g.Members[idx].ID } if mem.Interface == "" { mem.Interface = g.Members[idx].Interface } if mem.Weight <= 0 { mem.Weight = g.Members[idx].Weight } if mem.Priority <= 0 { mem.Priority = g.Members[idx].Priority } if mem.TargetProbe == "" { mem.TargetProbe = g.Members[idx].TargetProbe } if mem.MaxLatency <= 0 { mem.MaxLatency = g.Members[idx].MaxLatency } if mem.MaxLoss <= 0 { mem.MaxLoss = g.Members[idx].MaxLoss } // Preserve live telemetry mem.Status = g.Members[idx].Status if mem.Status == "" { mem.Status = StatusHealthy } 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.syncLocked() } // 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.syncLocked() } // 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 } // RenameInterface updates SD-WAN member references when an interface is renamed. func (m *Manager) RenameInterface(oldName, newName string) { m.mu.Lock() defer m.mu.Unlock() modified := false for _, g := range m.groups { for _, mem := range g.Members { if mem.Interface == oldName { mem.Interface = newName modified = true } } } if modified { _ = m.save() _ = m.engine.Sync(m.listLocked()) NotifyStateChange() } } // ResolveGroupForRouting resolves an SD-WAN group name or ID to active egress nexthops for kernel routing. func (m *Manager) ResolveGroupForRouting(groupNameOrID string) ([]ResolvedRouteNexthop, bool) { m.mu.RLock() defer m.mu.RUnlock() g := m.findLocked(groupNameOrID) if g == nil || g.Disabled || len(g.Members) == 0 { return nil, false } var candidates []*SDWANMember for _, mem := range g.Members { if !mem.Disabled && mem.Interface != "" { candidates = append(candidates, mem) } } if len(candidates) == 0 { return nil, false } var online []*SDWANMember for _, mem := range candidates { if mem.Status != StatusDown { online = append(online, mem) } } // Fallback to all enabled candidates if all are down so traffic isn't completely blackholed if len(online) == 0 { online = candidates } switch g.Mode { case ModeFailover: // Sort by priority (1 is highest priority) sort.Slice(online, func(i, j int) bool { p1, p2 := online[i].Priority, online[j].Priority if p1 <= 0 { p1 = 100 } if p2 <= 0 { p2 = 100 } return p1 < p2 }) primary := online[0] gw := primary.Gateway if gw == "" { gw = m.engine.detectInterfaceGateway(primary.Interface) } return []ResolvedRouteNexthop{{ Gateway: gw, Interface: primary.Interface, Weight: 1, }}, true case ModeLowestLatency: best := online[0] for _, mem := range online[1:] { if mem.LiveLatency > 0 && (best.LiveLatency <= 0 || mem.LiveLatency < best.LiveLatency) { best = mem } } gw := best.Gateway if gw == "" { gw = m.engine.detectInterfaceGateway(best.Interface) } return []ResolvedRouteNexthop{{ Gateway: gw, Interface: best.Interface, Weight: 1, }}, true case ModeLowestLoss: best := online[0] for _, mem := range online[1:] { if mem.PacketLoss < best.PacketLoss { best = mem } } gw := best.Gateway if gw == "" { gw = m.engine.detectInterfaceGateway(best.Interface) } return []ResolvedRouteNexthop{{ Gateway: gw, Interface: best.Interface, Weight: 1, }}, true case ModeWeighted, ModePCC: fallthrough default: var res []ResolvedRouteNexthop for _, mem := range online { gw := mem.Gateway if gw == "" { gw = m.engine.detectInterfaceGateway(mem.Interface) } w := mem.Weight if w <= 0 { w = 1 } res = append(res, ResolvedRouteNexthop{ Gateway: gw, Interface: mem.Interface, Weight: w, }) } return res, true } } // ResolveGroupForRouting resolves an SD-WAN group name or ID via GlobalManager or file fallback. func ResolveGroupForRouting(groupNameOrID string) ([]ResolvedRouteNexthop, bool) { if GlobalManager != nil { return GlobalManager.ResolveGroupForRouting(groupNameOrID) } // Fallback to reading file if GlobalManager is not yet initialized data, err := os.ReadFile(DefaultStorePath) if err != nil { return nil, false } var groups []*SDWANGroup if err := json.Unmarshal(data, &groups); err != nil { return nil, false } for _, g := range groups { if strings.EqualFold(g.Name, groupNameOrID) || g.ID == groupNameOrID { if g.Disabled || len(g.Members) == 0 { return nil, false } var res []ResolvedRouteNexthop for _, m := range g.Members { if !m.Disabled && m.Interface != "" { res = append(res, ResolvedRouteNexthop{ Gateway: m.Gateway, Interface: m.Interface, Weight: m.Weight, }) } } return res, len(res) > 0 } } return nil, false } // IsSDWANGroup reports whether the given name corresponds to an active or configured SD-WAN group. func IsSDWANGroup(name string) bool { clean := strings.TrimSpace(name) if clean == "" { return false } if GlobalManager != nil { return GlobalManager.Has(clean) } data, err := os.ReadFile(DefaultStorePath) if err != nil { return false } var groups []*SDWANGroup if err := json.Unmarshal(data, &groups); err != nil { return false } for _, g := range groups { if strings.EqualFold(g.Name, clean) || g.ID == clean { return true } } return false }