health.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. package routing
  2. import (
  3. "bytes"
  4. "fmt"
  5. "log"
  6. "net"
  7. "os/exec"
  8. "regexp"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "debtik/internal/sdwan"
  13. "github.com/vishvananda/netlink"
  14. )
  15. var (
  16. pingLossRegex = regexp.MustCompile(`(\d+)% packet loss`)
  17. pingRttRegex = regexp.MustCompile(`rtt min/avg/max/mdev = [\d.]+/([\d.]+)/`)
  18. )
  19. // resolveGatewayInterface determines which network device routes to the gateway IP
  20. func resolveGatewayInterface(gwIP string) string {
  21. ip := net.ParseIP(strings.TrimSpace(gwIP))
  22. if ip == nil {
  23. return ""
  24. }
  25. routes, err := netlink.RouteGet(ip)
  26. if err == nil && len(routes) > 0 {
  27. link, err := netlink.LinkByIndex(routes[0].LinkIndex)
  28. if err == nil && link != nil {
  29. return link.Attrs().Name
  30. }
  31. }
  32. // Fallback using `ip route get`
  33. out, err := exec.Command("ip", "route", "get", gwIP).Output()
  34. if err == nil {
  35. fields := strings.Fields(string(out))
  36. for i, f := range fields {
  37. if f == "dev" && i+1 < len(fields) {
  38. return fields[i+1]
  39. }
  40. }
  41. }
  42. return ""
  43. }
  44. func probeRouteHealthOnce(target string, iface string) (bool, float64, float64) {
  45. var args []string
  46. if iface != "" {
  47. args = []string{"-c", "3", "-W", "2", "-i", "0.3", "-I", iface, target}
  48. } else {
  49. args = []string{"-c", "3", "-W", "2", "-i", "0.3", target}
  50. }
  51. cmd := exec.Command("ping", args...)
  52. var outBuf bytes.Buffer
  53. cmd.Stdout = &outBuf
  54. cmd.Stderr = &outBuf
  55. _ = cmd.Run()
  56. output := outBuf.String()
  57. lossPct := 100.0
  58. if match := pingLossRegex.FindStringSubmatch(output); len(match) > 1 {
  59. if val, err := strconv.ParseFloat(match[1], 64); err == nil {
  60. lossPct = val
  61. }
  62. }
  63. var avgRtt float64
  64. if match := pingRttRegex.FindStringSubmatch(output); len(match) > 1 {
  65. if val, err := strconv.ParseFloat(match[1], 64); err == nil {
  66. avgRtt = val
  67. }
  68. }
  69. isUp := (lossPct < 100.0)
  70. return isUp, avgRtt, lossPct
  71. }
  72. // probeRouteHealth tests reachability of a gateway or public internet host bound to a specific interface
  73. func probeRouteHealth(target string, iface string) (bool, float64, float64, error) {
  74. target = strings.TrimSpace(target)
  75. if target == "" {
  76. return false, 0, 100, fmt.Errorf("empty probe target")
  77. }
  78. isUp, avgRtt, lossPct := probeRouteHealthOnce(target, iface)
  79. if isUp {
  80. return true, avgRtt, lossPct, nil
  81. }
  82. // Secondary fallback target to avoid false failovers from CDN ICMP drops
  83. fallbackTarget := "8.8.8.8"
  84. if target == "8.8.8.8" || target == "8.8.4.4" {
  85. fallbackTarget = "1.1.1.1"
  86. }
  87. fallbackUp, fallbackRtt, fallbackLoss := probeRouteHealthOnce(fallbackTarget, iface)
  88. if fallbackUp {
  89. return true, fallbackRtt, fallbackLoss, nil
  90. }
  91. return false, avgRtt, lossPct, nil
  92. }
  93. // startHealthCheckWorker initiates background internet connectivity probing for static routes
  94. func (m *RoutingManager) startHealthCheckWorker() {
  95. ticker := time.NewTicker(2 * time.Second)
  96. go func() {
  97. for {
  98. select {
  99. case <-m.stopChan:
  100. ticker.Stop()
  101. return
  102. case now := <-ticker.C:
  103. m.checkAllRoutesHealth(now)
  104. }
  105. }
  106. }()
  107. }
  108. func (m *RoutingManager) checkAllRoutesHealth(now time.Time) {
  109. m.mu.Lock()
  110. defer m.mu.Unlock()
  111. for i := range m.staticRoutes {
  112. r := &m.staticRoutes[i]
  113. if r.Disabled {
  114. continue
  115. }
  116. mode := strings.ToLower(strings.TrimSpace(r.CheckGateway))
  117. if mode != "ping" && mode != "internet" {
  118. continue
  119. }
  120. interval := r.CheckInterval
  121. if interval <= 0 {
  122. interval = 5
  123. }
  124. // Check if it's time to probe
  125. if r.HealthLastCheck != "" {
  126. if lastT, err := time.Parse(time.RFC3339, r.HealthLastCheck); err == nil {
  127. if now.Sub(lastT) < time.Duration(interval)*time.Second {
  128. continue
  129. }
  130. }
  131. }
  132. // Determine probe target
  133. target := r.CheckTarget
  134. if mode == "ping" {
  135. target = r.Gateway
  136. } else if mode == "internet" {
  137. if target == "" {
  138. target = "1.1.1.1"
  139. }
  140. }
  141. // Determine egress interface
  142. iface := r.Interface
  143. if iface == "" && r.Gateway != "" {
  144. iface = resolveGatewayInterface(r.Gateway)
  145. }
  146. // If this route is bound to an SD-WAN group, resolve egress interface to the group's active member
  147. if sdwan.IsSDWANGroup(iface) || sdwan.IsSDWANGroup(r.Gateway) {
  148. targetSDWAN := iface
  149. if !sdwan.IsSDWANGroup(targetSDWAN) {
  150. targetSDWAN = r.Gateway
  151. }
  152. resolved, ok := sdwan.ResolveGroupForRouting(targetSDWAN)
  153. if ok && len(resolved) > 0 {
  154. iface = resolved[0].Interface
  155. }
  156. }
  157. // Threshold
  158. threshold := r.CheckThreshold
  159. if threshold < 5 {
  160. threshold = 5
  161. }
  162. // Execute probe asynchronously with timeout to prevent blocking routing manager lock
  163. go func(routeID string, probeTarget string, probeIface string, checkThreshold int) {
  164. isUp, rtt, loss, _ := probeRouteHealth(probeTarget, probeIface)
  165. m.mu.Lock()
  166. defer m.mu.Unlock()
  167. // Find route by ID again
  168. var targetRoute *RouteItem
  169. for idx := range m.staticRoutes {
  170. if m.staticRoutes[idx].ID == routeID {
  171. targetRoute = &m.staticRoutes[idx]
  172. break
  173. }
  174. }
  175. if targetRoute == nil || targetRoute.Disabled {
  176. return
  177. }
  178. targetRoute.HealthRTT = rtt
  179. targetRoute.HealthLossPct = loss
  180. targetRoute.HealthLastCheck = time.Now().Format(time.RFC3339)
  181. if isUp {
  182. wasOffline := targetRoute.HealthStatus == "offline"
  183. targetRoute.HealthStatus = "online"
  184. targetRoute.HealthFailCount = 0
  185. // If it was offline, restore route in kernel
  186. if wasOffline || !targetRoute.Active {
  187. targetRoute.Active = true
  188. _ = m.applyRouteToKernel(*targetRoute)
  189. log.Printf("[RouteHealth] Route %s via %s (target %s) Internet RESTORED (RTT: %.1fms) -> Re-installed in kernel",
  190. targetRoute.DstAddress, targetRoute.Gateway, probeTarget, rtt)
  191. _ = m.save()
  192. }
  193. } else {
  194. targetRoute.HealthFailCount++
  195. if targetRoute.HealthFailCount >= checkThreshold {
  196. wasOnline := targetRoute.HealthStatus != "offline"
  197. targetRoute.HealthStatus = "offline"
  198. // Safety guard: if this is a default route and the ONLY active default route remaining,
  199. // do NOT withdraw it from the kernel to prevent a total internet blackout!
  200. hasOtherActiveDefault := false
  201. for _, other := range m.staticRoutes {
  202. if other.ID != targetRoute.ID && normalizeDst(other.DstAddress) == "default" && !other.Disabled && other.HealthStatus != "offline" {
  203. hasOtherActiveDefault = true
  204. break
  205. }
  206. }
  207. if !hasOtherActiveDefault && normalizeDst(targetRoute.DstAddress) == "default" {
  208. log.Printf("[RouteHealth] Route %s via %s (target %s) failed health check, but keeping active as LAST RESORT default route to prevent total internet blackout",
  209. targetRoute.DstAddress, targetRoute.Gateway, probeTarget)
  210. if !targetRoute.Active {
  211. targetRoute.Active = true
  212. _ = m.applyRouteToKernel(*targetRoute)
  213. _ = m.save()
  214. }
  215. } else {
  216. // If it was online, withdraw route from kernel to allow failover
  217. if wasOnline || targetRoute.Active {
  218. targetRoute.Active = false
  219. _ = m.removeRouteFromKernel(*targetRoute)
  220. log.Printf("[RouteHealth] Route %s via %s (target %s) Internet DOWN (threshold %d reached) -> Removed from kernel for failover",
  221. targetRoute.DstAddress, targetRoute.Gateway, probeTarget, checkThreshold)
  222. _ = m.save()
  223. }
  224. }
  225. }
  226. }
  227. }(r.ID, target, iface, threshold)
  228. }
  229. }