feat(xray): add tunnel health monitor (#5480)

* feat(xray): add tunnel health monitor

* fix(tunnelmonitor): reuse netproxy client and init logger in tests

Replace the duplicated newHTTPClient/dialContextWithProxy with netproxy.NewHTTPClient, which centralises the http/https/socks5 handling and avoids the dial-goroutine connection leak on context cancellation. Cap failures at the threshold during cooldown so the counter stays a true consecutive-failure count. Add TestMain to initialise the logger and fix the nil-pointer panic in the success-after-failure path.

* fix(tunnelmonitor): observable recovery, signal headroom, and hardening

Address the remaining review findings on the tunnel health monitor:

- Recovery is now synchronous and observable: the callback calls
  server.RestartXray() directly and returns its error instead of just
  enqueuing SIGUSR1, so a failed restart no longer masks as success and
  arms the cooldown while the tunnel is still down.
- Give the OS signal channel headroom (buffer 8) so producers cannot
  starve a SIGTERM/SIGINT out of the single slot.
- Warn at startup when the monitor is enabled without a proxy, since the
  probe then measures host connectivity rather than the xray tunnel.
- Cap failures at the threshold in the nil-recover branch too, matching
  the cooldown cap.
- Document the XUI_TUNNEL_HEALTH_* vars in .env.example and the README.
- Add tests for status-code classification, Normalize bounds, New proxy
  scheme errors, the recovery-error and nil-recover paths, the cooldown
  cap, and Run context cancellation (coverage 90%).

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Rick Sanchez
2026-06-24 23:31:37 +03:30
committed by GitHub
parent 3ba43bd86d
commit fe025e8af3
5 changed files with 773 additions and 1 deletions
+28 -1
View File
@@ -3,6 +3,7 @@
package main
import (
"context"
"flag"
"fmt"
"log"
@@ -18,6 +19,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/sub"
"github.com/mhsanaei/3x-ui/v3/internal/tunnelmonitor"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
"github.com/mhsanaei/3x-ui/v3/internal/web"
@@ -91,7 +93,7 @@ func runWebServer() {
return
}
sigCh := make(chan os.Signal, 1)
sigCh := make(chan os.Signal, 8)
// Trap shutdown signals
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGTERM, sys.SIGUSR1, os.Interrupt)
global.SetRestartHook(func() {
@@ -100,6 +102,27 @@ func runWebServer() {
default:
}
})
var stopTunnelHealthMonitor context.CancelFunc
monitorCfg := tunnelmonitor.ConfigFromEnv()
if monitorCfg.Enabled {
if monitorCfg.ProxyURL == "" {
logger.Warning("Tunnel health monitor enabled without XUI_TUNNEL_HEALTH_PROXY: the probe measures host connectivity, not the xray tunnel, so failures will restart xray without fixing host network issues")
}
monitorCtx, cancel := context.WithCancel(context.Background())
stopTunnelHealthMonitor = cancel
monitor, err := tunnelmonitor.New(monitorCfg, func(_ context.Context) error {
logger.Warning("Tunnel health monitor threshold reached, restarting xray-core")
return server.RestartXray()
})
if err != nil {
logger.Warning("Tunnel health monitor disabled: ", err)
} else {
go monitor.Run(monitorCtx)
}
}
for {
sig := <-sigCh
@@ -142,6 +165,10 @@ func runWebServer() {
}
default:
if stopTunnelHealthMonitor != nil {
stopTunnelHealthMonitor()
}
// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---
tgbot.StopBot()
// ------------------------------------------------------------