health.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  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. func probeRouteHealthOnce(target string, iface string) (bool, float64, float64) {
  44. var args []string
  45. if iface != "" {
  46. args = []string{"-c", "3", "-W", "2", "-i", "0.3", "-I", iface, target}
  47. } else {
  48. args = []string{"-c", "3", "-W", "2", "-i", "0.3", target}
  49. }
  50. cmd := exec.Command("ping", args...)
  51. var outBuf bytes.Buffer
  52. cmd.Stdout = &outBuf
  53. cmd.Stderr = &outBuf
  54. _ = cmd.Run()
  55. output := outBuf.String()
  56. lossPct := 100.0
  57. if match := pingLossRegex.FindStringSubmatch(output); len(match) > 1 {
  58. if val, err := strconv.ParseFloat(match[1], 64); err == nil {
  59. lossPct = val
  60. }
  61. }
  62. var avgRtt float64
  63. if match := pingRttRegex.FindStringSubmatch(output); len(match) > 1 {
  64. if val, err := strconv.ParseFloat(match[1], 64); err == nil {
  65. avgRtt = val
  66. }
  67. }
  68. isUp := (lossPct < 100.0)
  69. return isUp, avgRtt, lossPct
  70. }
  71. // probeRouteHealth tests reachability of a gateway or public internet host bound to a specific interface
  72. func probeRouteHealth(target string, iface string) (bool, float64, float64, error) {
  73. target = strings.TrimSpace(target)
  74. if target == "" {
  75. return false, 0, 100, fmt.Errorf("empty probe target")
  76. }
  77. isUp, avgRtt, lossPct := probeRouteHealthOnce(target, iface)
  78. if isUp {
  79. return true, avgRtt, lossPct, nil
  80. }
  81. // Secondary fallback target to avoid false failovers from CDN ICMP drops
  82. fallbackTarget := "8.8.8.8"
  83. if target == "8.8.8.8" || target == "8.8.4.4" {
  84. fallbackTarget = "1.1.1.1"
  85. }
  86. fallbackUp, fallbackRtt, fallbackLoss := probeRouteHealthOnce(fallbackTarget, iface)
  87. if fallbackUp {
  88. return true, fallbackRtt, fallbackLoss, nil
  89. }
  90. return false, avgRtt, lossPct, nil
  91. }
  92. // startHealthCheckWorker initiates background internet connectivity probing for static routes
  93. func (m *RoutingManager) startHealthCheckWorker() {
  94. ticker := time.NewTicker(2 * time.Second)
  95. go func() {
  96. for {
  97. select {
  98. case <-m.stopChan:
  99. ticker.Stop()
  100. return
  101. case now := <-ticker.C:
  102. m.checkAllRoutesHealth(now)
  103. }
  104. }
  105. }()
  106. }
  107. func (m *RoutingManager) checkAllRoutesHealth(now time.Time) {
  108. m.mu.Lock()
  109. defer m.mu.Unlock()
  110. for i := range m.staticRoutes {
  111. r := &m.staticRoutes[i]
  112. if r.Disabled {
  113. continue
  114. }
  115. mode := strings.ToLower(strings.TrimSpace(r.CheckGateway))
  116. if mode != "ping" && mode != "internet" {
  117. continue
  118. }
  119. interval := r.CheckInterval
  120. if interval <= 0 {
  121. interval = 5
  122. }
  123. // Check if it's time to probe
  124. if r.HealthLastCheck != "" {
  125. if lastT, err := time.Parse(time.RFC3339, r.HealthLastCheck); err == nil {
  126. if now.Sub(lastT) < time.Duration(interval)*time.Second {
  127. continue
  128. }
  129. }
  130. }
  131. // Determine probe target
  132. target := r.CheckTarget
  133. if mode == "ping" {
  134. target = r.Gateway
  135. } else if mode == "internet" {
  136. if target == "" {
  137. target = "1.1.1.1"
  138. }
  139. }
  140. // Determine egress interface
  141. iface := r.Interface
  142. if iface == "" && r.Gateway != "" {
  143. iface = resolveGatewayInterface(r.Gateway)
  144. }
  145. // Threshold
  146. threshold := r.CheckThreshold
  147. if threshold < 5 {
  148. threshold = 5
  149. }
  150. // Execute probe asynchronously with timeout to prevent blocking routing manager lock
  151. go func(routeID string, probeTarget string, probeIface string, checkThreshold int) {
  152. isUp, rtt, loss, _ := probeRouteHealth(probeTarget, probeIface)
  153. m.mu.Lock()
  154. defer m.mu.Unlock()
  155. // Find route by ID again
  156. var targetRoute *RouteItem
  157. for idx := range m.staticRoutes {
  158. if m.staticRoutes[idx].ID == routeID {
  159. targetRoute = &m.staticRoutes[idx]
  160. break
  161. }
  162. }
  163. if targetRoute == nil || targetRoute.Disabled {
  164. return
  165. }
  166. targetRoute.HealthRTT = rtt
  167. targetRoute.HealthLossPct = loss
  168. targetRoute.HealthLastCheck = time.Now().Format(time.RFC3339)
  169. if isUp {
  170. wasOffline := targetRoute.HealthStatus == "offline"
  171. targetRoute.HealthStatus = "online"
  172. targetRoute.HealthFailCount = 0
  173. // If it was offline, restore route in kernel
  174. if wasOffline || !targetRoute.Active {
  175. targetRoute.Active = true
  176. _ = m.applyRouteToKernel(*targetRoute)
  177. log.Printf("[RouteHealth] Route %s via %s (target %s) Internet RESTORED (RTT: %.1fms) -> Re-installed in kernel",
  178. targetRoute.DstAddress, targetRoute.Gateway, probeTarget, rtt)
  179. _ = m.save()
  180. }
  181. } else {
  182. targetRoute.HealthFailCount++
  183. if targetRoute.HealthFailCount >= checkThreshold {
  184. wasOnline := targetRoute.HealthStatus != "offline"
  185. targetRoute.HealthStatus = "offline"
  186. // Safety guard: if this is a default route and the ONLY active default route remaining,
  187. // do NOT withdraw it from the kernel to prevent a total internet blackout!
  188. hasOtherActiveDefault := false
  189. for _, other := range m.staticRoutes {
  190. if other.ID != targetRoute.ID && normalizeDst(other.DstAddress) == "default" && !other.Disabled && other.HealthStatus != "offline" {
  191. hasOtherActiveDefault = true
  192. break
  193. }
  194. }
  195. if !hasOtherActiveDefault && normalizeDst(targetRoute.DstAddress) == "default" {
  196. 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",
  197. targetRoute.DstAddress, targetRoute.Gateway, probeTarget)
  198. if !targetRoute.Active {
  199. targetRoute.Active = true
  200. _ = m.applyRouteToKernel(*targetRoute)
  201. _ = m.save()
  202. }
  203. } else {
  204. // If it was online, withdraw route from kernel to allow failover
  205. if wasOnline || targetRoute.Active {
  206. targetRoute.Active = false
  207. _ = m.removeRouteFromKernel(*targetRoute)
  208. log.Printf("[RouteHealth] Route %s via %s (target %s) Internet DOWN (threshold %d reached) -> Removed from kernel for failover",
  209. targetRoute.DstAddress, targetRoute.Gateway, probeTarget, checkThreshold)
  210. _ = m.save()
  211. }
  212. }
  213. }
  214. }
  215. }(r.ID, target, iface, threshold)
  216. }
  217. }