monitor_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. package tunnelmonitor
  2. import (
  3. "context"
  4. "errors"
  5. "net/http"
  6. "strings"
  7. "sync"
  8. "testing"
  9. "time"
  10. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  11. "github.com/op/go-logging"
  12. )
  13. func TestMain(m *testing.M) {
  14. logger.InitLogger(logging.ERROR)
  15. m.Run()
  16. }
  17. type roundTripFunc func(*http.Request) (*http.Response, error)
  18. func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
  19. return f(req)
  20. }
  21. func TestMonitorRestartsAfterFailureThreshold(t *testing.T) {
  22. cfg := Config{
  23. Enabled: true,
  24. URL: "http://example.test",
  25. Interval: time.Minute,
  26. Timeout: time.Second,
  27. FailureThreshold: 2,
  28. Cooldown: time.Minute,
  29. }
  30. client := &http.Client{
  31. Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
  32. return nil, errors.New("tunnel down")
  33. }),
  34. }
  35. restarts := 0
  36. monitor := newWithClient(cfg, client, func(ctx context.Context) error {
  37. restarts++
  38. return nil
  39. })
  40. monitor.now = func() time.Time {
  41. return time.Unix(100, 0)
  42. }
  43. if recovered, _ := monitor.Step(context.Background()); recovered {
  44. t.Fatal("first failure must not trigger recovery")
  45. }
  46. if recovered, _ := monitor.Step(context.Background()); !recovered {
  47. t.Fatal("second consecutive failure should trigger recovery")
  48. }
  49. if restarts != 1 {
  50. t.Fatalf("expected 1 recovery, got %d", restarts)
  51. }
  52. }
  53. func TestMonitorRespectsRecoveryCooldown(t *testing.T) {
  54. cfg := Config{
  55. Enabled: true,
  56. URL: "http://example.test",
  57. Interval: time.Minute,
  58. Timeout: time.Second,
  59. FailureThreshold: 1,
  60. Cooldown: time.Minute,
  61. }
  62. client := &http.Client{
  63. Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
  64. return nil, errors.New("tunnel down")
  65. }),
  66. }
  67. now := time.Unix(100, 0)
  68. restarts := 0
  69. monitor := newWithClient(cfg, client, func(ctx context.Context) error {
  70. restarts++
  71. return nil
  72. })
  73. monitor.now = func() time.Time {
  74. return now
  75. }
  76. recovered, _ := monitor.Step(context.Background())
  77. if !recovered {
  78. t.Fatal("first failure should trigger recovery when threshold is 1")
  79. }
  80. recovered, _ = monitor.Step(context.Background())
  81. if recovered {
  82. t.Fatal("cooldown should suppress immediate second recovery")
  83. }
  84. if restarts != 1 {
  85. t.Fatalf("expected 1 recovery during cooldown, got %d", restarts)
  86. }
  87. now = now.Add(time.Minute + time.Second)
  88. recovered, _ = monitor.Step(context.Background())
  89. if !recovered {
  90. t.Fatal("recovery should be allowed after cooldown")
  91. }
  92. if restarts != 2 {
  93. t.Fatalf("expected 2 recoveries after cooldown, got %d", restarts)
  94. }
  95. }
  96. func TestMonitorSuccessResetsFailures(t *testing.T) {
  97. cfg := Config{
  98. Enabled: true,
  99. URL: "http://example.test",
  100. Interval: time.Minute,
  101. Timeout: time.Second,
  102. FailureThreshold: 2,
  103. Cooldown: time.Minute,
  104. }
  105. fail := true
  106. client := &http.Client{
  107. Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
  108. if fail {
  109. return nil, errors.New("tunnel down")
  110. }
  111. return &http.Response{
  112. StatusCode: http.StatusOK,
  113. Body: http.NoBody,
  114. }, nil
  115. }),
  116. }
  117. restarts := 0
  118. monitor := newWithClient(cfg, client, func(ctx context.Context) error {
  119. restarts++
  120. return nil
  121. })
  122. _, _ = monitor.Step(context.Background())
  123. fail = false
  124. if recovered, err := monitor.Step(context.Background()); recovered || err != nil {
  125. t.Fatalf("successful probe should not recover or fail, recovered=%v err=%v", recovered, err)
  126. }
  127. fail = true
  128. if recovered, _ := monitor.Step(context.Background()); recovered {
  129. t.Fatal("failure after success should be counted as first failure again")
  130. }
  131. if restarts != 0 {
  132. t.Fatalf("expected no recovery, got %d", restarts)
  133. }
  134. }
  135. func TestConfigFromEnvParsesValues(t *testing.T) {
  136. t.Setenv("XUI_TUNNEL_HEALTH_MONITOR", "true")
  137. t.Setenv("XUI_TUNNEL_HEALTH_URL", "https://example.com/health")
  138. t.Setenv("XUI_TUNNEL_HEALTH_PROXY", "socks5://127.0.0.1:1080")
  139. t.Setenv("XUI_TUNNEL_HEALTH_INTERVAL", "15s")
  140. t.Setenv("XUI_TUNNEL_HEALTH_TIMEOUT", "3s")
  141. t.Setenv("XUI_TUNNEL_HEALTH_FAILURES", "4")
  142. t.Setenv("XUI_TUNNEL_HEALTH_COOLDOWN", "2m")
  143. cfg := ConfigFromEnv()
  144. if !cfg.Enabled {
  145. t.Fatal("expected monitor to be enabled")
  146. }
  147. if cfg.URL != "https://example.com/health" {
  148. t.Fatalf("unexpected URL: %s", cfg.URL)
  149. }
  150. if !strings.HasPrefix(cfg.ProxyURL, "socks5://") {
  151. t.Fatalf("unexpected proxy URL: %s", cfg.ProxyURL)
  152. }
  153. if cfg.Interval != 15*time.Second {
  154. t.Fatalf("unexpected interval: %s", cfg.Interval)
  155. }
  156. if cfg.Timeout != 3*time.Second {
  157. t.Fatalf("unexpected timeout: %s", cfg.Timeout)
  158. }
  159. if cfg.FailureThreshold != 4 {
  160. t.Fatalf("unexpected threshold: %d", cfg.FailureThreshold)
  161. }
  162. if cfg.Cooldown != 2*time.Minute {
  163. t.Fatalf("unexpected cooldown: %s", cfg.Cooldown)
  164. }
  165. }
  166. func failingClient() *http.Client {
  167. return &http.Client{
  168. Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
  169. return nil, errors.New("tunnel down")
  170. }),
  171. }
  172. }
  173. func statusClient(code int) *http.Client {
  174. return &http.Client{
  175. Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
  176. return &http.Response{StatusCode: code, Body: http.NoBody}, nil
  177. }),
  178. }
  179. }
  180. func TestProbeStatusCodeClassification(t *testing.T) {
  181. cases := []struct {
  182. status int
  183. healthy bool
  184. }{
  185. {199, false},
  186. {200, true},
  187. {204, true},
  188. {301, true},
  189. {399, true},
  190. {400, false},
  191. {404, false},
  192. {500, false},
  193. }
  194. for _, tc := range cases {
  195. cfg := Config{
  196. Enabled: true,
  197. URL: "http://example.test",
  198. Interval: time.Minute,
  199. Timeout: time.Second,
  200. FailureThreshold: 100,
  201. Cooldown: time.Minute,
  202. }
  203. monitor := newWithClient(cfg, statusClient(tc.status), func(ctx context.Context) error {
  204. return nil
  205. })
  206. recovered, err := monitor.Step(context.Background())
  207. if recovered {
  208. t.Fatalf("status %d: unexpected recovery", tc.status)
  209. }
  210. if tc.healthy && err != nil {
  211. t.Fatalf("status %d: expected healthy probe, got error %v", tc.status, err)
  212. }
  213. if !tc.healthy && err == nil {
  214. t.Fatalf("status %d: expected failure, got nil error", tc.status)
  215. }
  216. }
  217. }
  218. func TestNormalizeClampsBounds(t *testing.T) {
  219. got := Config{
  220. URL: " ",
  221. Interval: 0,
  222. Timeout: 500 * time.Millisecond,
  223. FailureThreshold: 0,
  224. Cooldown: 0,
  225. }.Normalize()
  226. if got.URL != defaultHealthURL {
  227. t.Fatalf("URL not defaulted: %q", got.URL)
  228. }
  229. if got.Interval != defaultInterval {
  230. t.Fatalf("Interval not clamped: %s", got.Interval)
  231. }
  232. if got.Timeout != defaultTimeout {
  233. t.Fatalf("Timeout not clamped: %s", got.Timeout)
  234. }
  235. if got.FailureThreshold != defaultFailureThreshold {
  236. t.Fatalf("FailureThreshold not clamped: %d", got.FailureThreshold)
  237. }
  238. if got.Cooldown != defaultCooldown {
  239. t.Fatalf("Cooldown not clamped: %s", got.Cooldown)
  240. }
  241. valid := Config{
  242. URL: "https://example.com/health",
  243. Interval: 15 * time.Second,
  244. Timeout: 3 * time.Second,
  245. FailureThreshold: 5,
  246. Cooldown: 2 * time.Minute,
  247. }.Normalize()
  248. if valid.URL != "https://example.com/health" ||
  249. valid.Interval != 15*time.Second ||
  250. valid.Timeout != 3*time.Second ||
  251. valid.FailureThreshold != 5 ||
  252. valid.Cooldown != 2*time.Minute {
  253. t.Fatalf("valid config was mutated: %+v", valid)
  254. }
  255. }
  256. func TestNewRejectsUnsupportedProxyScheme(t *testing.T) {
  257. m, err := New(Config{ProxyURL: "ftp://127.0.0.1:21"}, func(ctx context.Context) error {
  258. return nil
  259. })
  260. if err == nil || m != nil {
  261. t.Fatalf("expected error and nil monitor for bad scheme, got m=%v err=%v", m, err)
  262. }
  263. m, err = New(Config{}, func(ctx context.Context) error {
  264. return nil
  265. })
  266. if err != nil || m == nil {
  267. t.Fatalf("expected a valid monitor for empty proxy, got m=%v err=%v", m, err)
  268. }
  269. }
  270. func TestMonitorRecoveryErrorDoesNotArmCooldown(t *testing.T) {
  271. cfg := Config{
  272. Enabled: true,
  273. URL: "http://example.test",
  274. Interval: time.Minute,
  275. Timeout: time.Second,
  276. FailureThreshold: 1,
  277. Cooldown: time.Minute,
  278. }
  279. attempts := 0
  280. monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
  281. attempts++
  282. return errors.New("restart failed")
  283. })
  284. monitor.now = func() time.Time {
  285. return time.Unix(100, 0)
  286. }
  287. recovered, err := monitor.Step(context.Background())
  288. if recovered || err == nil {
  289. t.Fatalf("failed recovery must report recovered=false with an error, got recovered=%v err=%v", recovered, err)
  290. }
  291. if !monitor.lastRecovery.IsZero() {
  292. t.Fatal("a failed recovery must not arm the cooldown")
  293. }
  294. if _, err := monitor.Step(context.Background()); err == nil {
  295. t.Fatal("expected error on the second failing step")
  296. }
  297. if attempts != 2 {
  298. t.Fatalf("recovery should be retried (no cooldown) after a failure, attempts=%d", attempts)
  299. }
  300. }
  301. func TestMonitorNilRecoverStaysBounded(t *testing.T) {
  302. cfg := Config{
  303. Enabled: true,
  304. URL: "http://example.test",
  305. Interval: time.Minute,
  306. Timeout: time.Second,
  307. FailureThreshold: 2,
  308. Cooldown: time.Minute,
  309. }
  310. monitor := newWithClient(cfg, failingClient(), nil)
  311. for i := 0; i < 5; i++ {
  312. recovered, _ := monitor.Step(context.Background())
  313. if recovered {
  314. t.Fatal("a nil recovery func must never report recovery")
  315. }
  316. if monitor.failures > cfg.FailureThreshold {
  317. t.Fatalf("failures must stay capped at threshold %d, got %d", cfg.FailureThreshold, monitor.failures)
  318. }
  319. }
  320. }
  321. func TestMonitorFailuresCappedDuringCooldown(t *testing.T) {
  322. cfg := Config{
  323. Enabled: true,
  324. URL: "http://example.test",
  325. Interval: time.Minute,
  326. Timeout: time.Second,
  327. FailureThreshold: 2,
  328. Cooldown: time.Minute,
  329. }
  330. restarts := 0
  331. monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
  332. restarts++
  333. return nil
  334. })
  335. monitor.now = func() time.Time {
  336. return time.Unix(100, 0)
  337. }
  338. monitor.Step(context.Background())
  339. if recovered, _ := monitor.Step(context.Background()); !recovered {
  340. t.Fatal("expected recovery once the threshold is reached")
  341. }
  342. for i := 0; i < 6; i++ {
  343. monitor.Step(context.Background())
  344. if monitor.failures > cfg.FailureThreshold {
  345. t.Fatalf("failures must never exceed threshold %d during cooldown, got %d", cfg.FailureThreshold, monitor.failures)
  346. }
  347. }
  348. if restarts != 1 {
  349. t.Fatalf("cooldown should suppress further recoveries, restarts=%d", restarts)
  350. }
  351. }
  352. func TestMonitorRunStopsOnContextCancel(t *testing.T) {
  353. cfg := Config{
  354. Enabled: true,
  355. URL: "http://example.test",
  356. Timeout: time.Second,
  357. FailureThreshold: 1,
  358. Cooldown: time.Hour,
  359. }
  360. recovered := make(chan struct{})
  361. var once sync.Once
  362. monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
  363. once.Do(func() { close(recovered) })
  364. return nil
  365. })
  366. monitor.cfg.Interval = 5 * time.Millisecond
  367. ctx, cancel := context.WithCancel(context.Background())
  368. done := make(chan struct{})
  369. go func() {
  370. monitor.Run(ctx)
  371. close(done)
  372. }()
  373. select {
  374. case <-recovered:
  375. case <-time.After(2 * time.Second):
  376. cancel()
  377. t.Fatal("Run did not trigger recovery within the deadline")
  378. }
  379. cancel()
  380. select {
  381. case <-done:
  382. case <-time.After(2 * time.Second):
  383. t.Fatal("Run did not return after context cancellation")
  384. }
  385. }