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>
117 lines
4.9 KiB
C++
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
|