| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- package tunnelmonitor
- import (
- "context"
- "errors"
- "net/http"
- "strings"
- "sync"
- "testing"
- "time"
- "github.com/mhsanaei/3x-ui/v3/internal/logger"
- "github.com/op/go-logging"
- )
- func TestMain(m *testing.M) {
- logger.InitLogger(logging.ERROR)
- m.Run()
- }
- type roundTripFunc func(*http.Request) (*http.Response, error)
- func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
- return f(req)
- }
- func TestMonitorRestartsAfterFailureThreshold(t *testing.T) {
- cfg := Config{
- Enabled: true,
- URL: "http://example.test",
- Interval: time.Minute,
- Timeout: time.Second,
- FailureThreshold: 2,
- Cooldown: time.Minute,
- }
- client := &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- return nil, errors.New("tunnel down")
- }),
- }
- restarts := 0
- monitor := newWithClient(cfg, client, func(ctx context.Context) error {
- restarts++
- return nil
- })
- monitor.now = func() time.Time {
- return time.Unix(100, 0)
- }
- if recovered, _ := monitor.Step(context.Background()); recovered {
- t.Fatal("first failure must not trigger recovery")
- }
- if recovered, _ := monitor.Step(context.Background()); !recovered {
- t.Fatal("second consecutive failure should trigger recovery")
- }
- if restarts != 1 {
- t.Fatalf("expected 1 recovery, got %d", restarts)
- }
- }
- func TestMonitorRespectsRecoveryCooldown(t *testing.T) {
- cfg := Config{
- Enabled: true,
- URL: "http://example.test",
- Interval: time.Minute,
- Timeout: time.Second,
- FailureThreshold: 1,
- Cooldown: time.Minute,
- }
- client := &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- return nil, errors.New("tunnel down")
- }),
- }
- now := time.Unix(100, 0)
- restarts := 0
- monitor := newWithClient(cfg, client, func(ctx context.Context) error {
- restarts++
- return nil
- })
- monitor.now = func() time.Time {
- return now
- }
- recovered, _ := monitor.Step(context.Background())
- if !recovered {
- t.Fatal("first failure should trigger recovery when threshold is 1")
- }
- recovered, _ = monitor.Step(context.Background())
- if recovered {
- t.Fatal("cooldown should suppress immediate second recovery")
- }
- if restarts != 1 {
- t.Fatalf("expected 1 recovery during cooldown, got %d", restarts)
- }
- now = now.Add(time.Minute + time.Second)
- recovered, _ = monitor.Step(context.Background())
- if !recovered {
- t.Fatal("recovery should be allowed after cooldown")
- }
- if restarts != 2 {
- t.Fatalf("expected 2 recoveries after cooldown, got %d", restarts)
- }
- }
- func TestMonitorSuccessResetsFailures(t *testing.T) {
- cfg := Config{
- Enabled: true,
- URL: "http://example.test",
- Interval: time.Minute,
- Timeout: time.Second,
- FailureThreshold: 2,
- Cooldown: time.Minute,
- }
- fail := true
- client := &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- if fail {
- return nil, errors.New("tunnel down")
- }
- return &http.Response{
- StatusCode: http.StatusOK,
- Body: http.NoBody,
- }, nil
- }),
- }
- restarts := 0
- monitor := newWithClient(cfg, client, func(ctx context.Context) error {
- restarts++
- return nil
- })
- _, _ = monitor.Step(context.Background())
- fail = false
- if recovered, err := monitor.Step(context.Background()); recovered || err != nil {
- t.Fatalf("successful probe should not recover or fail, recovered=%v err=%v", recovered, err)
- }
- fail = true
- if recovered, _ := monitor.Step(context.Background()); recovered {
- t.Fatal("failure after success should be counted as first failure again")
- }
- if restarts != 0 {
- t.Fatalf("expected no recovery, got %d", restarts)
- }
- }
- func TestConfigFromEnvParsesValues(t *testing.T) {
- t.Setenv("XUI_TUNNEL_HEALTH_MONITOR", "true")
- t.Setenv("XUI_TUNNEL_HEALTH_URL", "https://example.com/health")
- t.Setenv("XUI_TUNNEL_HEALTH_PROXY", "socks5://127.0.0.1:1080")
- t.Setenv("XUI_TUNNEL_HEALTH_INTERVAL", "15s")
- t.Setenv("XUI_TUNNEL_HEALTH_TIMEOUT", "3s")
- t.Setenv("XUI_TUNNEL_HEALTH_FAILURES", "4")
- t.Setenv("XUI_TUNNEL_HEALTH_COOLDOWN", "2m")
- cfg := ConfigFromEnv()
- if !cfg.Enabled {
- t.Fatal("expected monitor to be enabled")
- }
- if cfg.URL != "https://example.com/health" {
- t.Fatalf("unexpected URL: %s", cfg.URL)
- }
- if !strings.HasPrefix(cfg.ProxyURL, "socks5://") {
- t.Fatalf("unexpected proxy URL: %s", cfg.ProxyURL)
- }
- if cfg.Interval != 15*time.Second {
- t.Fatalf("unexpected interval: %s", cfg.Interval)
- }
- if cfg.Timeout != 3*time.Second {
- t.Fatalf("unexpected timeout: %s", cfg.Timeout)
- }
- if cfg.FailureThreshold != 4 {
- t.Fatalf("unexpected threshold: %d", cfg.FailureThreshold)
- }
- if cfg.Cooldown != 2*time.Minute {
- t.Fatalf("unexpected cooldown: %s", cfg.Cooldown)
- }
- }
- func failingClient() *http.Client {
- return &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- return nil, errors.New("tunnel down")
- }),
- }
- }
- func statusClient(code int) *http.Client {
- return &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- return &http.Response{StatusCode: code, Body: http.NoBody}, nil
- }),
- }
- }
- func TestProbeStatusCodeClassification(t *testing.T) {
- cases := []struct {
- status int
- healthy bool
- }{
- {199, false},
- {200, true},
- {204, true},
- {301, true},
- {399, true},
- {400, false},
- {404, false},
- {500, false},
- }
- for _, tc := range cases {
- cfg := Config{
- Enabled: true,
- URL: "http://example.test",
- Interval: time.Minute,
- Timeout: time.Second,
- FailureThreshold: 100,
- Cooldown: time.Minute,
- }
- monitor := newWithClient(cfg, statusClient(tc.status), func(ctx context.Context) error {
- return nil
- })
- recovered, err := monitor.Step(context.Background())
- if recovered {
- t.Fatalf("status %d: unexpected recovery", tc.status)
- }
- if tc.healthy && err != nil {
- t.Fatalf("status %d: expected healthy probe, got error %v", tc.status, err)
- }
- if !tc.healthy && err == nil {
- t.Fatalf("status %d: expected failure, got nil error", tc.status)
- }
- }
- }
- func TestNormalizeClampsBounds(t *testing.T) {
- got := Config{
- URL: " ",
- Interval: 0,
- Timeout: 500 * time.Millisecond,
- FailureThreshold: 0,
- Cooldown: 0,
- }.Normalize()
- if got.URL != defaultHealthURL {
- t.Fatalf("URL not defaulted: %q", got.URL)
- }
- if got.Interval != defaultInterval {
- t.Fatalf("Interval not clamped: %s", got.Interval)
- }
- if got.Timeout != defaultTimeout {
- t.Fatalf("Timeout not clamped: %s", got.Timeout)
- }
- if got.FailureThreshold != defaultFailureThreshold {
- t.Fatalf("FailureThreshold not clamped: %d", got.FailureThreshold)
- }
- if got.Cooldown != defaultCooldown {
- t.Fatalf("Cooldown not clamped: %s", got.Cooldown)
- }
- valid := Config{
- URL: "https://example.com/health",
- Interval: 15 * time.Second,
- Timeout: 3 * time.Second,
- FailureThreshold: 5,
- Cooldown: 2 * time.Minute,
- }.Normalize()
- if valid.URL != "https://example.com/health" ||
- valid.Interval != 15*time.Second ||
- valid.Timeout != 3*time.Second ||
- valid.FailureThreshold != 5 ||
- valid.Cooldown != 2*time.Minute {
- t.Fatalf("valid config was mutated: %+v", valid)
- }
- }
- func TestNewRejectsUnsupportedProxyScheme(t *testing.T) {
- m, err := New(Config{ProxyURL: "ftp://127.0.0.1:21"}, func(ctx context.Context) error {
- return nil
- })
- if err == nil || m != nil {
- t.Fatalf("expected error and nil monitor for bad scheme, got m=%v err=%v", m, err)
- }
- m, err = New(Config{}, func(ctx context.Context) error {
- return nil
- })
- if err != nil || m == nil {
- t.Fatalf("expected a valid monitor for empty proxy, got m=%v err=%v", m, err)
- }
- }
- func TestMonitorRecoveryErrorDoesNotArmCooldown(t *testing.T) {
- cfg := Config{
- Enabled: true,
- URL: "http://example.test",
- Interval: time.Minute,
- Timeout: time.Second,
- FailureThreshold: 1,
- Cooldown: time.Minute,
- }
- attempts := 0
- monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
- attempts++
- return errors.New("restart failed")
- })
- monitor.now = func() time.Time {
- return time.Unix(100, 0)
- }
- recovered, err := monitor.Step(context.Background())
- if recovered || err == nil {
- t.Fatalf("failed recovery must report recovered=false with an error, got recovered=%v err=%v", recovered, err)
- }
- if !monitor.lastRecovery.IsZero() {
- t.Fatal("a failed recovery must not arm the cooldown")
- }
- if _, err := monitor.Step(context.Background()); err == nil {
- t.Fatal("expected error on the second failing step")
- }
- if attempts != 2 {
- t.Fatalf("recovery should be retried (no cooldown) after a failure, attempts=%d", attempts)
- }
- }
- func TestMonitorNilRecoverStaysBounded(t *testing.T) {
- cfg := Config{
- Enabled: true,
- URL: "http://example.test",
- Interval: time.Minute,
- Timeout: time.Second,
- FailureThreshold: 2,
- Cooldown: time.Minute,
- }
- monitor := newWithClient(cfg, failingClient(), nil)
- for i := 0; i < 5; i++ {
- recovered, _ := monitor.Step(context.Background())
- if recovered {
- t.Fatal("a nil recovery func must never report recovery")
- }
- if monitor.failures > cfg.FailureThreshold {
- t.Fatalf("failures must stay capped at threshold %d, got %d", cfg.FailureThreshold, monitor.failures)
- }
- }
- }
- func TestMonitorFailuresCappedDuringCooldown(t *testing.T) {
- cfg := Config{
- Enabled: true,
- URL: "http://example.test",
- Interval: time.Minute,
- Timeout: time.Second,
- FailureThreshold: 2,
- Cooldown: time.Minute,
- }
- restarts := 0
- monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
- restarts++
- return nil
- })
- monitor.now = func() time.Time {
- return time.Unix(100, 0)
- }
- monitor.Step(context.Background())
- if recovered, _ := monitor.Step(context.Background()); !recovered {
- t.Fatal("expected recovery once the threshold is reached")
- }
- for i := 0; i < 6; i++ {
- monitor.Step(context.Background())
- if monitor.failures > cfg.FailureThreshold {
- t.Fatalf("failures must never exceed threshold %d during cooldown, got %d", cfg.FailureThreshold, monitor.failures)
- }
- }
- if restarts != 1 {
- t.Fatalf("cooldown should suppress further recoveries, restarts=%d", restarts)
- }
- }
- func TestMonitorRunStopsOnContextCancel(t *testing.T) {
- cfg := Config{
- Enabled: true,
- URL: "http://example.test",
- Timeout: time.Second,
- FailureThreshold: 1,
- Cooldown: time.Hour,
- }
- recovered := make(chan struct{})
- var once sync.Once
- monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
- once.Do(func() { close(recovered) })
- return nil
- })
- monitor.cfg.Interval = 5 * time.Millisecond
- ctx, cancel := context.WithCancel(context.Background())
- done := make(chan struct{})
- go func() {
- monitor.Run(ctx)
- close(done)
- }()
- select {
- case <-recovered:
- case <-time.After(2 * time.Second):
- cancel()
- t.Fatal("Run did not trigger recovery within the deadline")
- }
- cancel()
- select {
- case <-done:
- case <-time.After(2 * time.Second):
- t.Fatal("Run did not return after context cancellation")
- }
- }
|