Files
ovgate/src/health/switch_controller.h
T
iceBear67andClaude Opus 5 b2ba45c9f8 OpenVPN client with an authenticated SOCKS5 front door
A userspace VPN gateway: builds an OpenVPN tunnel to a VPNGate node with
the OpenVPN 3 core, terminates it in-process with lwIP, and serves SOCKS5
(RFC 1928/1929, CONNECT and UDP ASSOCIATE) over it. No root, no tun
device, no routing table changes.

Layout follows the module boundaries in docs/ARCHITECTURE.md:

  vpngate/   directory fetch + CSV parse (lines run to ~13.5 KB, so the
             parser streams rather than splitting on newlines)
  selector/  two-phase pick: cheap prior over the whole list, then real
             TCP handshake timing of the top K
  ovpn/      openvpn3 driven through TunBuilder, packets over a socketpair
  netstack/  lwIP: the TCP/IP stack that makes "no root" possible
  egress/    the swappable way out, and make-before-break switching
  socks5/    the front door
  health/    per-window scoring, and the decision to move
  app/       wiring, admin HTTP, signals

docs/FEASIBILITY.md is the analysis this was built from, including the
one requirement that is not physically possible -- carrying established
TCP connections across a node switch -- and what is done instead
(zero-progress redial, UDP re-homing, grace-period drain).

Tests: 155 without the tunnel egress, 172 with it. The seam is the egress
factory; selection, scoring, history and probing all run for real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 04:38:39 +00:00

117 lines
4.9 KiB
C++

// 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 <asio.hpp>
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
#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<bool> 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