1
0

monitor.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. package tunnelmonitor
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "os"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  13. "github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
  14. )
  15. const (
  16. defaultHealthURL = "https://www.cloudflare.com/cdn-cgi/trace"
  17. defaultInterval = 30 * time.Second
  18. defaultTimeout = 10 * time.Second
  19. defaultFailureThreshold = 3
  20. defaultCooldown = 5 * time.Minute
  21. )
  22. // Config controls the optional tunnel health monitor.
  23. type Config struct {
  24. Enabled bool
  25. URL string
  26. ProxyURL string
  27. Interval time.Duration
  28. Timeout time.Duration
  29. FailureThreshold int
  30. Cooldown time.Duration
  31. }
  32. // RecoveryFunc performs recovery after the monitor reaches the configured
  33. // failure threshold. The panel wires this to an Xray core restart.
  34. type RecoveryFunc func(context.Context) error
  35. // Monitor periodically probes a URL and triggers recovery after repeated
  36. // failures. It is intentionally independent from panel settings/UI so it can be
  37. // enabled safely through service environment variables first.
  38. type Monitor struct {
  39. cfg Config
  40. client *http.Client
  41. recover RecoveryFunc
  42. failures int
  43. lastRecovery time.Time
  44. now func() time.Time
  45. }
  46. // DefaultConfig returns disabled-by-default monitor settings.
  47. func DefaultConfig() Config {
  48. return Config{
  49. Enabled: false,
  50. URL: defaultHealthURL,
  51. Interval: defaultInterval,
  52. Timeout: defaultTimeout,
  53. FailureThreshold: defaultFailureThreshold,
  54. Cooldown: defaultCooldown,
  55. }
  56. }
  57. // ConfigFromEnv builds Config from XUI_TUNNEL_HEALTH_* environment variables.
  58. //
  59. // Supported variables:
  60. // - XUI_TUNNEL_HEALTH_MONITOR=true
  61. // - XUI_TUNNEL_HEALTH_URL=https://www.cloudflare.com/cdn-cgi/trace
  62. // - XUI_TUNNEL_HEALTH_PROXY=socks5://127.0.0.1:1080
  63. // - XUI_TUNNEL_HEALTH_INTERVAL=30s
  64. // - XUI_TUNNEL_HEALTH_TIMEOUT=10s
  65. // - XUI_TUNNEL_HEALTH_FAILURES=3
  66. // - XUI_TUNNEL_HEALTH_COOLDOWN=5m
  67. func ConfigFromEnv() Config {
  68. cfg := DefaultConfig()
  69. cfg.Enabled = parseBool(os.Getenv("XUI_TUNNEL_HEALTH_MONITOR"))
  70. cfg.URL = firstNonEmpty(os.Getenv("XUI_TUNNEL_HEALTH_URL"), cfg.URL)
  71. cfg.ProxyURL = strings.TrimSpace(os.Getenv("XUI_TUNNEL_HEALTH_PROXY"))
  72. cfg.Interval = parseDurationEnv("XUI_TUNNEL_HEALTH_INTERVAL", cfg.Interval)
  73. cfg.Timeout = parseDurationEnv("XUI_TUNNEL_HEALTH_TIMEOUT", cfg.Timeout)
  74. cfg.Cooldown = parseDurationEnv("XUI_TUNNEL_HEALTH_COOLDOWN", cfg.Cooldown)
  75. cfg.FailureThreshold = parseIntEnv("XUI_TUNNEL_HEALTH_FAILURES", cfg.FailureThreshold)
  76. return cfg.Normalize()
  77. }
  78. // Normalize applies safe bounds and defaults.
  79. func (c Config) Normalize() Config {
  80. if strings.TrimSpace(c.URL) == "" {
  81. c.URL = defaultHealthURL
  82. }
  83. c.URL = strings.TrimSpace(c.URL)
  84. c.ProxyURL = strings.TrimSpace(c.ProxyURL)
  85. if c.Interval < time.Second {
  86. c.Interval = defaultInterval
  87. }
  88. if c.Timeout < time.Second {
  89. c.Timeout = defaultTimeout
  90. }
  91. if c.FailureThreshold < 1 {
  92. c.FailureThreshold = defaultFailureThreshold
  93. }
  94. if c.Cooldown < time.Second {
  95. c.Cooldown = defaultCooldown
  96. }
  97. return c
  98. }
  99. // New creates a monitor with an HTTP client based on cfg.
  100. func New(cfg Config, recover RecoveryFunc) (*Monitor, error) {
  101. cfg = cfg.Normalize()
  102. client, err := netproxy.NewHTTPClient(cfg.ProxyURL, cfg.Timeout)
  103. if err != nil {
  104. return nil, err
  105. }
  106. return newWithClient(cfg, client, recover), nil
  107. }
  108. func newWithClient(cfg Config, client *http.Client, recover RecoveryFunc) *Monitor {
  109. cfg = cfg.Normalize()
  110. if client == nil {
  111. client = &http.Client{Timeout: cfg.Timeout}
  112. }
  113. return &Monitor{
  114. cfg: cfg,
  115. client: client,
  116. recover: recover,
  117. now: time.Now,
  118. }
  119. }
  120. // Run starts the monitor loop until ctx is cancelled.
  121. func (m *Monitor) Run(ctx context.Context) {
  122. if m == nil || !m.cfg.Enabled {
  123. return
  124. }
  125. logger.Info("Tunnel health monitor enabled: ", m.cfg.URL)
  126. ticker := time.NewTicker(m.cfg.Interval)
  127. defer ticker.Stop()
  128. for {
  129. select {
  130. case <-ctx.Done():
  131. logger.Info("Tunnel health monitor stopped")
  132. return
  133. case <-ticker.C:
  134. recovered, err := m.Step(ctx)
  135. if err != nil {
  136. logger.Warning("Tunnel health monitor check failed: ", err)
  137. }
  138. if recovered {
  139. logger.Warning("Tunnel health monitor triggered Xray restart")
  140. }
  141. }
  142. }
  143. }
  144. // Step performs one probe and maybe triggers recovery.
  145. func (m *Monitor) Step(ctx context.Context) (bool, error) {
  146. if m == nil {
  147. return false, errors.New("nil monitor")
  148. }
  149. if err := m.probe(ctx); err != nil {
  150. m.failures++
  151. if m.failures < m.cfg.FailureThreshold {
  152. return false, fmt.Errorf("probe failed %d/%d: %w", m.failures, m.cfg.FailureThreshold, err)
  153. }
  154. now := m.now()
  155. if !m.lastRecovery.IsZero() && now.Sub(m.lastRecovery) < m.cfg.Cooldown {
  156. m.failures = m.cfg.FailureThreshold
  157. return false, fmt.Errorf("probe failed %d/%d; recovery cooldown active: %w", m.failures, m.cfg.FailureThreshold, err)
  158. }
  159. if m.recover == nil {
  160. m.failures = m.cfg.FailureThreshold
  161. return false, errors.New("recovery function is not configured")
  162. }
  163. if recErr := m.recover(ctx); recErr != nil {
  164. return false, fmt.Errorf("recovery failed after probe error %v: %w", err, recErr)
  165. }
  166. m.lastRecovery = now
  167. m.failures = 0
  168. return true, err
  169. }
  170. if m.failures > 0 {
  171. logger.Info("Tunnel health monitor recovered after successful probe")
  172. }
  173. m.failures = 0
  174. return false, nil
  175. }
  176. func (m *Monitor) probe(ctx context.Context) error {
  177. req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.URL, nil)
  178. if err != nil {
  179. return err
  180. }
  181. resp, err := m.client.Do(req)
  182. if err != nil {
  183. return err
  184. }
  185. defer resp.Body.Close()
  186. _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
  187. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
  188. return fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)
  189. }
  190. return nil
  191. }
  192. func parseBool(value string) bool {
  193. switch strings.ToLower(strings.TrimSpace(value)) {
  194. case "1", "true", "yes", "y", "on", "enable", "enabled":
  195. return true
  196. default:
  197. return false
  198. }
  199. }
  200. func parseDurationEnv(name string, fallback time.Duration) time.Duration {
  201. value := strings.TrimSpace(os.Getenv(name))
  202. if value == "" {
  203. return fallback
  204. }
  205. d, err := time.ParseDuration(value)
  206. if err != nil {
  207. return fallback
  208. }
  209. return d
  210. }
  211. func parseIntEnv(name string, fallback int) int {
  212. value := strings.TrimSpace(os.Getenv(name))
  213. if value == "" {
  214. return fallback
  215. }
  216. n, err := strconv.Atoi(value)
  217. if err != nil {
  218. return fallback
  219. }
  220. return n
  221. }
  222. func firstNonEmpty(value string, fallback string) string {
  223. value = strings.TrimSpace(value)
  224. if value == "" {
  225. return fallback
  226. }
  227. return value
  228. }