| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701 |
- 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
- }
|