package engine import ( "context" "io" "log" "net/http" "strings" "time" "vpnem/internal/config" "vpnem/internal/models" ) // WatchdogConfig holds watchdog parameters. type WatchdogConfig struct { CheckInterval time.Duration // how often to check sing-box is alive (default 2s) DeepCheckInterval time.Duration // how often to verify exit IP (default 30s) ReconnectCooldown time.Duration // min time between reconnect attempts (default 5s) } // DefaultWatchdogConfig returns the default watchdog settings (from vpn.py). func DefaultWatchdogConfig() WatchdogConfig { return WatchdogConfig{ CheckInterval: 2 * time.Second, DeepCheckInterval: 30 * time.Second, ReconnectCooldown: 5 * time.Second, } } // Watchdog monitors sing-box and auto-reconnects on failure. type Watchdog struct { engine *Engine cfg WatchdogConfig cancel context.CancelFunc running bool // Reconnect parameters (set via StartWatching) server models.Server mode config.Mode ruleSets []models.RuleSet serverIPs []string } // NewWatchdog creates a new watchdog for the given engine. func NewWatchdog(engine *Engine, cfg WatchdogConfig) *Watchdog { return &Watchdog{ engine: engine, cfg: cfg, } } // StartWatching begins monitoring. It stores the connection params for reconnection. func (w *Watchdog) StartWatching(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string) { w.StopWatching() w.server = server w.mode = mode w.ruleSets = ruleSets w.serverIPs = serverIPs ctx, cancel := context.WithCancel(context.Background()) w.cancel = cancel w.running = true go w.loop(ctx) } // StopWatching stops the watchdog. func (w *Watchdog) StopWatching() { if w.cancel != nil { w.cancel() } w.running = false } // IsWatching returns whether the watchdog is active. func (w *Watchdog) IsWatching() bool { return w.running } func (w *Watchdog) loop(ctx context.Context) { ticker := time.NewTicker(w.cfg.CheckInterval) defer ticker.Stop() deepTicker := time.NewTicker(w.cfg.DeepCheckInterval) defer deepTicker.Stop() lastReconnect := time.Time{} for { select { case <-ctx.Done(): return case <-ticker.C: if !w.engine.IsRunning() { if time.Since(lastReconnect) < w.cfg.ReconnectCooldown { continue } log.Println("watchdog: sing-box not running, reconnecting...") if err := w.engine.Start(w.server, w.mode, w.ruleSets, w.serverIPs); err != nil { log.Printf("watchdog: reconnect failed: %v", err) } else { log.Println("watchdog: reconnected successfully") } lastReconnect = time.Now() } case <-deepTicker.C: if !w.engine.IsRunning() { continue } ip := checkExitIP() if ip == "" { log.Println("watchdog: deep check failed (no exit IP), restarting...") if time.Since(lastReconnect) < w.cfg.ReconnectCooldown { continue } if err := w.engine.Restart(w.server, w.mode, w.ruleSets, w.serverIPs); err != nil { log.Printf("watchdog: restart failed: %v", err) } lastReconnect = time.Now() } } } } func checkExitIP() string { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get("http://ifconfig.me/ip") if err != nil { return "" } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 64)) if err != nil { return "" } return strings.TrimSpace(string(body)) }