package engine import ( "context" "log" "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 customBypass []string localProxyPort int policy *models.RoutingPolicy } // 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, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) { w.StopWatching() w.server = server w.mode = mode w.ruleSets = ruleSets w.serverIPs = serverIPs w.customBypass = append([]string{}, customBypass...) w.localProxyPort = localProxyPort w.policy = policy 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 } localProxyPort, err := ResolveLocalProxyPort() if err != nil { log.Printf("watchdog: local proxy port selection failed: %v", err) continue } w.localProxyPort = localProxyPort log.Println("watchdog: sing-box not running, reconnecting...") if err := w.engine.StartFull(w.server, w.mode, w.ruleSets, w.serverIPs, w.customBypass, w.localProxyPort, w.policy); 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 } exitIP := CheckExitIP(w.localProxyPort) _, probeErr := ProbeBlockedSite(w.localProxyPort, DefaultBlockedSiteProbeURL, 8*time.Second) if DeepCheckRequiresRestart(w.mode, exitIP, probeErr) { if ModeRequiresExitIP(w.mode) { log.Println("watchdog: deep check failed (no exit IP), restarting...") } else { log.Printf("watchdog: deep check failed for direct-final mode (%v), restarting...", probeErr) } if time.Since(lastReconnect) < w.cfg.ReconnectCooldown { continue } localProxyPort, err := ResolveLocalProxyPort() if err != nil { log.Printf("watchdog: local proxy port selection failed: %v", err) continue } w.localProxyPort = localProxyPort if err := w.engine.RestartFull(w.server, w.mode, w.ruleSets, w.serverIPs, w.customBypass, w.localProxyPort, w.policy); err != nil { log.Printf("watchdog: restart failed: %v", err) } lastReconnect = time.Now() } } } }