health.go 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  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. "github.com/vishvananda/netlink"
  13. )
  14. var (
  15. pingLossRegex = regexp.MustCompile(`(\d+)% packet loss`)
  16. pingRttRegex = regexp.MustCompile(`rtt min/avg/max/mdev = [\d.]+/([\d.]+)/`)
  17. )
  18. // resolveGatewayInterface determines which network device routes to the gateway IP
  19. func resolveGatewayInterface(gwIP string) string {
  20. ip := net.ParseIP(strings.TrimSpace(gwIP))
  21. if ip == nil {
  22. return ""
  23. }
  24. routes, err := netlink.RouteGet(ip)
  25. if err == nil && len(routes) > 0 {
  26. link, err := netlink.LinkByIndex(routes[0].LinkIndex)
  27. if err == nil && link != nil {
  28. return link.Attrs().Name
  29. }
  30. }
  31. // Fallback using `ip route get`
  32. out, err := exec.Command("ip", "route", "get", gwIP).Output()
  33. if err == nil {
  34. fields := strings.Fields(string(out))
  35. for i, f := range fields {
  36. if f == "dev" && i+1 < len(fields) {
  37. return fields[i+1]
  38. }
  39. }
  40. }
  41. return ""
  42. }
  43. // probeRouteHealth tests reachability of a gateway or public internet host bound to a specific interface
  44. func probeRouteHealth(target string, iface string) (bool, float64, float64, error) {
  45. target = strings.TrimSpace(target)
  46. if target == "" {
  47. return false, 0, 100, fmt.Errorf("empty probe target")
  48. }
  49. var args []string
  50. if iface != "" {
  51. args = []string{"-c", "2", "-W", "1", "-I", iface, target}
  52. } else {
  53. args = []string{"-c", "2", "-W", "1", target}
  54. }
  55. cmd := exec.Command("ping", args...)
  56. var outBuf bytes.Buffer
  57. cmd.Stdout = &outBuf
  58. cmd.Stderr = &outBuf
  59. err := cmd.Run()
  60. output := outBuf.String()
  61. // Parse packet loss
  62. lossPct := 100.0
  63. if match := pingLossRegex.FindStringSubmatch(output); len(match) > 1 {
  64. if val, err := strconv.ParseFloat(match[1], 64); err == nil {
  65. lossPct = val
  66. }
  67. }
  68. // Parse average RTT
  69. var avgRtt float64
  70. if match := pingRttRegex.FindStringSubmatch(output); len(match) > 1 {
  71. if val, err := strconv.ParseFloat(match[1], 64); err == nil {
  72. avgRtt = val
  73. }
  74. }
  75. isUp := (lossPct < 100.0) && (err == nil || lossPct < 50.0)
  76. return isUp, avgRtt, lossPct, nil
  77. }
  78. // startHealthCheckWorker initiates background internet connectivity probing for static routes
  79. func (m *RoutingManager) startHealthCheckWorker() {
  80. ticker := time.NewTicker(2 * time.Second)
  81. go func() {
  82. for {
  83. select {
  84. case <-m.stopChan:
  85. ticker.Stop()
  86. return
  87. case now := <-ticker.C:
  88. m.checkAllRoutesHealth(now)
  89. }
  90. }
  91. }()
  92. }
  93. func (m *RoutingManager) checkAllRoutesHealth(now time.Time) {
  94. m.mu.Lock()
  95. defer m.mu.Unlock()
  96. for i := range m.staticRoutes {
  97. r := &m.staticRoutes[i]
  98. if r.Disabled {
  99. continue
  100. }
  101. mode := strings.ToLower(strings.TrimSpace(r.CheckGateway))
  102. if mode != "ping" && mode != "internet" {
  103. continue
  104. }
  105. interval := r.CheckInterval
  106. if interval <= 0 {
  107. interval = 5
  108. }
  109. // Check if it's time to probe
  110. if r.HealthLastCheck != "" {
  111. if lastT, err := time.Parse(time.RFC3339, r.HealthLastCheck); err == nil {
  112. if now.Sub(lastT) < time.Duration(interval)*time.Second {
  113. continue
  114. }
  115. }
  116. }
  117. // Determine probe target
  118. target := r.CheckTarget
  119. if mode == "ping" {
  120. target = r.Gateway
  121. } else if mode == "internet" {
  122. if target == "" {
  123. target = "1.1.1.1"
  124. }
  125. }
  126. // Determine egress interface
  127. iface := r.Interface
  128. if iface == "" && r.Gateway != "" {
  129. iface = resolveGatewayInterface(r.Gateway)
  130. }
  131. // Threshold
  132. threshold := r.CheckThreshold
  133. if threshold <= 0 {
  134. threshold = 3
  135. }
  136. // Execute probe asynchronously with timeout to prevent blocking routing manager lock
  137. go func(routeID string, probeTarget string, probeIface string, checkThreshold int) {
  138. isUp, rtt, loss, _ := probeRouteHealth(probeTarget, probeIface)
  139. m.mu.Lock()
  140. defer m.mu.Unlock()
  141. // Find route by ID again
  142. var targetRoute *RouteItem
  143. for idx := range m.staticRoutes {
  144. if m.staticRoutes[idx].ID == routeID {
  145. targetRoute = &m.staticRoutes[idx]
  146. break
  147. }
  148. }
  149. if targetRoute == nil || targetRoute.Disabled {
  150. return
  151. }
  152. targetRoute.HealthRTT = rtt
  153. targetRoute.HealthLossPct = loss
  154. targetRoute.HealthLastCheck = time.Now().Format(time.RFC3339)
  155. if isUp {
  156. wasOffline := targetRoute.HealthStatus == "offline"
  157. targetRoute.HealthStatus = "online"
  158. targetRoute.HealthFailCount = 0
  159. // If it was offline, restore route in kernel
  160. if wasOffline || !targetRoute.Active {
  161. _ = m.applyRouteToKernel(*targetRoute)
  162. targetRoute.Active = true
  163. log.Printf("[RouteHealth] Route %s via %s (target %s) Internet RESTORED (RTT: %.1fms) -> Re-installed in kernel",
  164. targetRoute.DstAddress, targetRoute.Gateway, probeTarget, rtt)
  165. _ = m.save()
  166. }
  167. } else {
  168. targetRoute.HealthFailCount++
  169. if targetRoute.HealthFailCount >= checkThreshold {
  170. wasOnline := targetRoute.HealthStatus != "offline"
  171. targetRoute.HealthStatus = "offline"
  172. // If it was online, withdraw route from kernel to allow failover
  173. if wasOnline || targetRoute.Active {
  174. _ = m.removeRouteFromKernel(*targetRoute)
  175. targetRoute.Active = false
  176. log.Printf("[RouteHealth] Route %s via %s (target %s) Internet DOWN (threshold %d reached) -> Removed from kernel for failover",
  177. targetRoute.DstAddress, targetRoute.Gateway, probeTarget, checkThreshold)
  178. _ = m.save()
  179. }
  180. }
  181. }
  182. }(r.ID, target, iface, threshold)
  183. }
  184. }