// Turns health verdicts into switch requests. // // --------------------------------------------------------------------------- // Why this is not part of either neighbour // --------------------------------------------------------------------------- // HealthMonitor answers "is this node still good?". EgressManager answers "how // do I replace a node without dropping the world?". Neither should own the // policy that connects them, because that policy is the part most likely to // change: how long to wait after startup, whether to chase a better node while // the current one is fine, what a tunnel that went Down on its own means. // Keeping it here means those decisions are readable in one file instead of // spread across a health check and a state machine. // // The mechanism it drives is deliberately thin. EgressManager::request_switch // already enforces the anti-flap interval, the exponential backoff after a // failed switch, and the "must beat the incumbent by improvement_margin" test. // This class must not reimplement any of that -- it decides *whether to ask*, // and asking is cheap and idempotent. // // --------------------------------------------------------------------------- // What triggers a switch // --------------------------------------------------------------------------- // sustained unhealthy HealthMonitor fired after `unhealthy_windows` bad // rounds -> request_switch(Unhealthy) // tunnel gone the active egress reports Down without any help from // the health score -> request_switch(TunnelDown). This // is checked on its own short timer because a dead // tunnel should not wait for a probe to time out three // times to be noticed. // opportunistic off by default (SwitchConfig::opportunistic_interval) // -> request_switch(BetterCandidate), which the manager // declines unless a candidate is genuinely better. // // After a promotion the monitor is nudged (probe_now) rather than left to its // timer: the first question about a new node is whether it works at all, and // waiting a full interval to ask is a slow way to find out we switched onto // something worse. #pragma once #include #include #include #include #include #include "common/config.h" #include "common/strand_deleter.h" #include "egress/egress_manager.h" #include "health/health_monitor.h" namespace ovg::health { class SwitchController { public: SwitchController(asio::io_context &io, const Config &cfg, HealthMonitor &monitor, egress::EgressManager &manager); ~SwitchController(); SwitchController(const SwitchController &) = delete; SwitchController &operator=(const SwitchController &) = delete; // Subscribes to the monitor and starts the watchdog/opportunistic timers. // Call before HealthMonitor::start() so no verdict is missed. void start(); void stop(); // The admin endpoint's POST /switch. Bypasses the anti-flap interval and the // improvement margin; still refused while a switch is already running. On a // refusal `detail` (optional) gets the manager's specific reason, which is the // only thing that makes a 409 actionable. bool force_switch(const std::string &why, std::string *detail = nullptr); // Called by whoever owns EgressManager::set_on_promote -- there is only one // such hook and the SOCKS5 server needs it too, so app/ installs a handler // that fans out and this is our half of it. Re-probes health immediately: the // first thing worth knowing about a new node is whether it works at all, and // waiting a full health interval to ask is a slow way to discover we moved // onto something worse. void note_promotion(const std::string &from, const std::string &to); struct Stats { uint64_t requested = 0; // switches we asked for uint64_t declined = 0; // ...that the manager refused uint64_t unhealthy = 0; // triggered by a sustained bad score uint64_t tunnel_down = 0; // triggered by the egress reporting Down uint64_t opportunistic = 0; uint64_t manual = 0; std::string last_trigger = "-"; }; Stats stats() const; private: void arm_watchdog(); void check_tunnel(); void arm_opportunistic(); void ask(egress::SwitchReason reason, const std::string &why, bool force); asio::io_context &io_; Config cfg_; HealthMonitor &monitor_; egress::EgressManager &manager_; Strand strand_; asio::steady_timer watchdog_; asio::steady_timer opportunistic_; std::atomic running_{false}; // Strand-only: stops a tunnel that stays Down from producing one request per // watchdog tick while the switch it already asked for is still running. bool down_reported_ = false; mutable std::mutex mu_; Stats stats_; }; } // namespace ovg::health