|
|
@@ -15,6 +15,7 @@ import (
|
|
|
"sync"
|
|
|
"time"
|
|
|
|
|
|
+ "debtik/internal/sdwan"
|
|
|
"github.com/vishvananda/netlink"
|
|
|
)
|
|
|
|
|
|
@@ -46,6 +47,11 @@ func NewRoutingManager() *RoutingManager {
|
|
|
m.applyAll()
|
|
|
m.startHealthCheckWorker()
|
|
|
m.startRouteSyncWorker()
|
|
|
+
|
|
|
+ sdwan.RegisterStateChangeCallback(func() {
|
|
|
+ m.SyncSDWANRoutes()
|
|
|
+ })
|
|
|
+
|
|
|
return m
|
|
|
}
|
|
|
|
|
|
@@ -516,6 +522,20 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
|
|
|
continue
|
|
|
}
|
|
|
gwMatches := sr.Gateway == "" || lr.Gateway == sr.Gateway || lr.Interface == sr.Gateway || (sr.Interface != "" && lr.Interface == sr.Interface)
|
|
|
+ if !gwMatches && (sdwan.IsSDWANGroup(sr.Interface) || sdwan.IsSDWANGroup(sr.Gateway)) {
|
|
|
+ targetSDWAN := sr.Interface
|
|
|
+ if !sdwan.IsSDWANGroup(targetSDWAN) {
|
|
|
+ targetSDWAN = sr.Gateway
|
|
|
+ }
|
|
|
+ if resolved, ok := sdwan.ResolveGroupForRouting(targetSDWAN); ok {
|
|
|
+ for _, nh := range resolved {
|
|
|
+ if nh.Interface == lr.Interface || (nh.Gateway != "" && nh.Gateway == lr.Gateway) {
|
|
|
+ gwMatches = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
if gwMatches {
|
|
|
matchedLive[i] = true
|
|
|
liveRoutes[i].Comment = sr.Comment
|
|
|
@@ -523,6 +543,9 @@ func (m *RoutingManager) ListRoutes() []RouteItem {
|
|
|
liveRoutes[i].Dynamic = false
|
|
|
liveRoutes[i].Protocol = "static"
|
|
|
liveRoutes[i].ID = sr.ID
|
|
|
+ if sr.Interface != "" {
|
|
|
+ liveRoutes[i].Interface = sr.Interface
|
|
|
+ }
|
|
|
if liveRoutes[i].Gateway == "" && sr.Gateway != "" {
|
|
|
liveRoutes[i].Gateway = sr.Gateway
|
|
|
}
|
|
|
@@ -1064,42 +1087,94 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
- if len(activeRoutes) == 1 {
|
|
|
- r := activeRoutes[0]
|
|
|
+ type expandedNexthop struct {
|
|
|
+ Gateway string
|
|
|
+ Interface string
|
|
|
+ Weight int
|
|
|
+ }
|
|
|
+
|
|
|
+ var allNexthops []expandedNexthop
|
|
|
+ isBlackhole := false
|
|
|
+
|
|
|
+ for _, r := range activeRoutes {
|
|
|
gw := strings.TrimSpace(r.Gateway)
|
|
|
- isBlackhole := strings.EqualFold(gw, "blackhole") || strings.EqualFold(r.Action, "blackhole")
|
|
|
+ iface := strings.TrimSpace(r.Interface)
|
|
|
|
|
|
- var args []string
|
|
|
- if isBlackhole {
|
|
|
- args = []string{"route", "replace", "blackhole", normDst}
|
|
|
- } else {
|
|
|
- args = []string{"route", "replace", normDst}
|
|
|
- iface := strings.TrimSpace(r.Interface)
|
|
|
- hasVia := false
|
|
|
- if gw != "" {
|
|
|
- if net.ParseIP(gw) != nil {
|
|
|
- args = append(args, "via", gw)
|
|
|
- hasVia = true
|
|
|
- } else {
|
|
|
- // Gateway field contains an interface name
|
|
|
- targetDev := gw
|
|
|
- if iface == "" {
|
|
|
- iface = targetDev
|
|
|
- }
|
|
|
- if detectedGw := detectGatewayForInterface(targetDev); detectedGw != "" {
|
|
|
- args = append(args, "via", detectedGw)
|
|
|
- hasVia = true
|
|
|
- } else {
|
|
|
- args = append(args, "dev", targetDev)
|
|
|
- }
|
|
|
+ if strings.EqualFold(gw, "blackhole") || strings.EqualFold(r.Action, "blackhole") {
|
|
|
+ isBlackhole = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if interface or gateway specifies an SD-WAN virtual group
|
|
|
+ targetSDWAN := ""
|
|
|
+ if sdwan.IsSDWANGroup(iface) {
|
|
|
+ targetSDWAN = iface
|
|
|
+ } else if sdwan.IsSDWANGroup(gw) {
|
|
|
+ targetSDWAN = gw
|
|
|
+ }
|
|
|
+
|
|
|
+ if targetSDWAN != "" {
|
|
|
+ resolved, ok := sdwan.ResolveGroupForRouting(targetSDWAN)
|
|
|
+ if ok && len(resolved) > 0 {
|
|
|
+ for _, nh := range resolved {
|
|
|
+ allNexthops = append(allNexthops, expandedNexthop{
|
|
|
+ Gateway: nh.Gateway,
|
|
|
+ Interface: nh.Interface,
|
|
|
+ Weight: nh.Weight,
|
|
|
+ })
|
|
|
}
|
|
|
+ continue
|
|
|
}
|
|
|
- if iface != "" && (gw == "" || net.ParseIP(gw) != nil || (gw != "" && detectGatewayForInterface(gw) != "")) {
|
|
|
- args = append(args, "dev", iface)
|
|
|
- }
|
|
|
- if hasVia && iface != "" {
|
|
|
- args = append(args, "onlink")
|
|
|
- }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Standard static route
|
|
|
+ if gw != "" && net.ParseIP(gw) == nil && iface == "" {
|
|
|
+ iface = gw
|
|
|
+ gw = ""
|
|
|
+ }
|
|
|
+ if gw == "" && iface != "" {
|
|
|
+ gw = detectGatewayForInterface(iface)
|
|
|
+ }
|
|
|
+
|
|
|
+ allNexthops = append(allNexthops, expandedNexthop{
|
|
|
+ Gateway: gw,
|
|
|
+ Interface: iface,
|
|
|
+ Weight: 1,
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ if isBlackhole {
|
|
|
+ args := []string{"route", "replace", "blackhole", normDst}
|
|
|
+ if distance > 0 {
|
|
|
+ args = append(args, "metric", strconv.Itoa(distance))
|
|
|
+ }
|
|
|
+ if tbl != "main" {
|
|
|
+ args = append(args, "table", tbl)
|
|
|
+ }
|
|
|
+ args = append(args, "proto", "static")
|
|
|
+ if out, err := exec.Command("ip", args...).CombinedOutput(); err != nil {
|
|
|
+ return fmt.Errorf("failed to apply blackhole route: %s: %w", string(out), err)
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ if len(allNexthops) == 0 {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ if len(allNexthops) == 1 {
|
|
|
+ nh := allNexthops[0]
|
|
|
+ args := []string{"route", "replace", normDst}
|
|
|
+ hasVia := false
|
|
|
+ if nh.Gateway != "" && net.ParseIP(nh.Gateway) != nil {
|
|
|
+ args = append(args, "via", nh.Gateway)
|
|
|
+ hasVia = true
|
|
|
+ }
|
|
|
+ if nh.Interface != "" {
|
|
|
+ args = append(args, "dev", nh.Interface)
|
|
|
+ }
|
|
|
+ if hasVia && nh.Interface != "" {
|
|
|
+ args = append(args, "onlink")
|
|
|
}
|
|
|
if distance > 0 {
|
|
|
args = append(args, "metric", strconv.Itoa(distance))
|
|
|
@@ -1127,7 +1202,7 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
- // Multiple routes: ECMP multipath route
|
|
|
+ // Multiple nexthops: ECMP multipath route
|
|
|
args := []string{"route", "replace", normDst, "scope", "global"}
|
|
|
if distance > 0 {
|
|
|
args = append(args, "metric", strconv.Itoa(distance))
|
|
|
@@ -1137,30 +1212,24 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
|
|
|
}
|
|
|
args = append(args, "proto", "static")
|
|
|
|
|
|
- for _, r := range activeRoutes {
|
|
|
+ for _, nh := range allNexthops {
|
|
|
args = append(args, "nexthop")
|
|
|
- gw := strings.TrimSpace(r.Gateway)
|
|
|
- iface := strings.TrimSpace(r.Interface)
|
|
|
- if iface == "" && gw != "" && net.ParseIP(gw) == nil {
|
|
|
- iface = gw
|
|
|
- }
|
|
|
hasVia := false
|
|
|
- if gw != "" && net.ParseIP(gw) != nil {
|
|
|
- args = append(args, "via", gw)
|
|
|
+ if nh.Gateway != "" && net.ParseIP(nh.Gateway) != nil {
|
|
|
+ args = append(args, "via", nh.Gateway)
|
|
|
hasVia = true
|
|
|
- } else if iface != "" {
|
|
|
- if detectedGw := detectGatewayForInterface(iface); detectedGw != "" {
|
|
|
- args = append(args, "via", detectedGw)
|
|
|
- hasVia = true
|
|
|
- }
|
|
|
}
|
|
|
- if iface != "" {
|
|
|
- args = append(args, "dev", iface)
|
|
|
+ if nh.Interface != "" {
|
|
|
+ args = append(args, "dev", nh.Interface)
|
|
|
}
|
|
|
- if hasVia && iface != "" {
|
|
|
+ if hasVia && nh.Interface != "" {
|
|
|
args = append(args, "onlink")
|
|
|
}
|
|
|
- args = append(args, "weight", "1")
|
|
|
+ w := nh.Weight
|
|
|
+ if w <= 0 {
|
|
|
+ w = 1
|
|
|
+ }
|
|
|
+ args = append(args, "weight", strconv.Itoa(w))
|
|
|
}
|
|
|
|
|
|
cmd := exec.Command("ip", args...)
|
|
|
@@ -1181,6 +1250,46 @@ func (m *RoutingManager) syncRouteGroupToKernel(dstAddress, routingTable string,
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
+// SyncSDWANRoutes reapplies kernel routes for static routes referencing SD-WAN groups when group state changes.
|
|
|
+func (m *RoutingManager) SyncSDWANRoutes() {
|
|
|
+ m.mu.Lock()
|
|
|
+ defer m.mu.Unlock()
|
|
|
+
|
|
|
+ type groupKey struct {
|
|
|
+ dst string
|
|
|
+ table string
|
|
|
+ dist int
|
|
|
+ }
|
|
|
+ seen := make(map[groupKey]bool)
|
|
|
+ var groupsToSync []groupKey
|
|
|
+
|
|
|
+ for _, r := range m.staticRoutes {
|
|
|
+ if r.Disabled {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if sdwan.IsSDWANGroup(r.Interface) || sdwan.IsSDWANGroup(r.Gateway) {
|
|
|
+ tbl := strings.TrimSpace(r.RoutingTable)
|
|
|
+ if tbl == "" {
|
|
|
+ tbl = "main"
|
|
|
+ }
|
|
|
+ normDst := normalizeDst(r.DstAddress)
|
|
|
+ k := groupKey{dst: normDst, table: tbl, dist: r.Distance}
|
|
|
+ if !seen[k] {
|
|
|
+ seen[k] = true
|
|
|
+ groupsToSync = append(groupsToSync, groupKey{dst: r.DstAddress, table: tbl, dist: r.Distance})
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for _, g := range groupsToSync {
|
|
|
+ if err := m.syncRouteGroupToKernel(g.dst, g.table, g.dist); err != nil {
|
|
|
+ log.Printf("[RouteManager] SD-WAN failover route sync failed for %s (%s metric %d): %v", g.dst, g.table, g.dist, err)
|
|
|
+ } else {
|
|
|
+ log.Printf("[RouteManager] SD-WAN failover synced route %s (%s metric %d) successfully", g.dst, g.table, g.dist)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
func (m *RoutingManager) applyRouteToKernel(r RouteItem) error {
|
|
|
return m.syncRouteGroupToKernel(r.DstAddress, r.RoutingTable, r.Distance)
|
|
|
}
|