| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- package tunnelmonitor
- import (
- "context"
- "errors"
- "fmt"
- "io"
- "net/http"
- "os"
- "strconv"
- "strings"
- "time"
- "github.com/mhsanaei/3x-ui/v3/internal/logger"
- "github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
- )
- const (
- defaultHealthURL = "https://www.cloudflare.com/cdn-cgi/trace"
- defaultInterval = 30 * time.Second
- defaultTimeout = 10 * time.Second
- defaultFailureThreshold = 3
- defaultCooldown = 5 * time.Minute
- )
- // Config controls the optional tunnel health monitor.
- type Config struct {
- Enabled bool
- URL string
- ProxyURL string
- Interval time.Duration
- Timeout time.Duration
- FailureThreshold int
- Cooldown time.Duration
- }
- // RecoveryFunc performs recovery after the monitor reaches the configured
- // failure threshold. The panel wires this to an Xray core restart.
- type RecoveryFunc func(context.Context) error
- // Monitor periodically probes a URL and triggers recovery after repeated
- // failures. It is intentionally independent from panel settings/UI so it can be
- // enabled safely through service environment variables first.
- type Monitor struct {
- cfg Config
- client *http.Client
- recover RecoveryFunc
- failures int
- lastRecovery time.Time
- now func() time.Time
- }
- // DefaultConfig returns disabled-by-default monitor settings.
- func DefaultConfig() Config {
- return Config{
- Enabled: false,
- URL: defaultHealthURL,
- Interval: defaultInterval,
- Timeout: defaultTimeout,
- FailureThreshold: defaultFailureThreshold,
- Cooldown: defaultCooldown,
- }
- }
- // ConfigFromEnv builds Config from XUI_TUNNEL_HEALTH_* environment variables.
- //
- // Supported variables:
- // - XUI_TUNNEL_HEALTH_MONITOR=true
- // - XUI_TUNNEL_HEALTH_URL=https://www.cloudflare.com/cdn-cgi/trace
- // - XUI_TUNNEL_HEALTH_PROXY=socks5://127.0.0.1:1080
- // - XUI_TUNNEL_HEALTH_INTERVAL=30s
- // - XUI_TUNNEL_HEALTH_TIMEOUT=10s
- // - XUI_TUNNEL_HEALTH_FAILURES=3
- // - XUI_TUNNEL_HEALTH_COOLDOWN=5m
- func ConfigFromEnv() Config {
- cfg := DefaultConfig()
- cfg.Enabled = parseBool(os.Getenv("XUI_TUNNEL_HEALTH_MONITOR"))
- cfg.URL = firstNonEmpty(os.Getenv("XUI_TUNNEL_HEALTH_URL"), cfg.URL)
- cfg.ProxyURL = strings.TrimSpace(os.Getenv("XUI_TUNNEL_HEALTH_PROXY"))
- cfg.Interval = parseDurationEnv("XUI_TUNNEL_HEALTH_INTERVAL", cfg.Interval)
- cfg.Timeout = parseDurationEnv("XUI_TUNNEL_HEALTH_TIMEOUT", cfg.Timeout)
- cfg.Cooldown = parseDurationEnv("XUI_TUNNEL_HEALTH_COOLDOWN", cfg.Cooldown)
- cfg.FailureThreshold = parseIntEnv("XUI_TUNNEL_HEALTH_FAILURES", cfg.FailureThreshold)
- return cfg.Normalize()
- }
- // Normalize applies safe bounds and defaults.
- func (c Config) Normalize() Config {
- if strings.TrimSpace(c.URL) == "" {
- c.URL = defaultHealthURL
- }
- c.URL = strings.TrimSpace(c.URL)
- c.ProxyURL = strings.TrimSpace(c.ProxyURL)
- if c.Interval < time.Second {
- c.Interval = defaultInterval
- }
- if c.Timeout < time.Second {
- c.Timeout = defaultTimeout
- }
- if c.FailureThreshold < 1 {
- c.FailureThreshold = defaultFailureThreshold
- }
- if c.Cooldown < time.Second {
- c.Cooldown = defaultCooldown
- }
- return c
- }
- // New creates a monitor with an HTTP client based on cfg.
- func New(cfg Config, recover RecoveryFunc) (*Monitor, error) {
- cfg = cfg.Normalize()
- client, err := netproxy.NewHTTPClient(cfg.ProxyURL, cfg.Timeout)
- if err != nil {
- return nil, err
- }
- return newWithClient(cfg, client, recover), nil
- }
- func newWithClient(cfg Config, client *http.Client, recover RecoveryFunc) *Monitor {
- cfg = cfg.Normalize()
- if client == nil {
- client = &http.Client{Timeout: cfg.Timeout}
- }
- return &Monitor{
- cfg: cfg,
- client: client,
- recover: recover,
- now: time.Now,
- }
- }
- // Run starts the monitor loop until ctx is cancelled.
- func (m *Monitor) Run(ctx context.Context) {
- if m == nil || !m.cfg.Enabled {
- return
- }
- logger.Info("Tunnel health monitor enabled: ", m.cfg.URL)
- ticker := time.NewTicker(m.cfg.Interval)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- logger.Info("Tunnel health monitor stopped")
- return
- case <-ticker.C:
- recovered, err := m.Step(ctx)
- if err != nil {
- logger.Warning("Tunnel health monitor check failed: ", err)
- }
- if recovered {
- logger.Warning("Tunnel health monitor triggered Xray restart")
- }
- }
- }
- }
- // Step performs one probe and maybe triggers recovery.
- func (m *Monitor) Step(ctx context.Context) (bool, error) {
- if m == nil {
- return false, errors.New("nil monitor")
- }
- if err := m.probe(ctx); err != nil {
- m.failures++
- if m.failures < m.cfg.FailureThreshold {
- return false, fmt.Errorf("probe failed %d/%d: %w", m.failures, m.cfg.FailureThreshold, err)
- }
- now := m.now()
- if !m.lastRecovery.IsZero() && now.Sub(m.lastRecovery) < m.cfg.Cooldown {
- m.failures = m.cfg.FailureThreshold
- return false, fmt.Errorf("probe failed %d/%d; recovery cooldown active: %w", m.failures, m.cfg.FailureThreshold, err)
- }
- if m.recover == nil {
- m.failures = m.cfg.FailureThreshold
- return false, errors.New("recovery function is not configured")
- }
- if recErr := m.recover(ctx); recErr != nil {
- return false, fmt.Errorf("recovery failed after probe error %v: %w", err, recErr)
- }
- m.lastRecovery = now
- m.failures = 0
- return true, err
- }
- if m.failures > 0 {
- logger.Info("Tunnel health monitor recovered after successful probe")
- }
- m.failures = 0
- return false, nil
- }
- func (m *Monitor) probe(ctx context.Context) error {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.URL, nil)
- if err != nil {
- return err
- }
- resp, err := m.client.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
- if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
- return fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)
- }
- return nil
- }
- func parseBool(value string) bool {
- switch strings.ToLower(strings.TrimSpace(value)) {
- case "1", "true", "yes", "y", "on", "enable", "enabled":
- return true
- default:
- return false
- }
- }
- func parseDurationEnv(name string, fallback time.Duration) time.Duration {
- value := strings.TrimSpace(os.Getenv(name))
- if value == "" {
- return fallback
- }
- d, err := time.ParseDuration(value)
- if err != nil {
- return fallback
- }
- return d
- }
- func parseIntEnv(name string, fallback int) int {
- value := strings.TrimSpace(os.Getenv(name))
- if value == "" {
- return fallback
- }
- n, err := strconv.Atoi(value)
- if err != nil {
- return fallback
- }
- return n
- }
- func firstNonEmpty(value string, fallback string) string {
- value = strings.TrimSpace(value)
- if value == "" {
- return fallback
- }
- return value
- }
|