forked from cloud/ovgate
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>
164 lines
6.3 KiB
C++
164 lines
6.3 KiB
C++
// L2 health checking: is the *current* egress still worth keeping?
|
|
//
|
|
// ---------------------------------------------------------------------------
|
|
// Two layers, and this is only the second
|
|
// ---------------------------------------------------------------------------
|
|
// L1 is openvpn3's own ping/ping-restart reconnect. It happens inside the
|
|
// session, the tun fd survives it (tunPersist), and lwIP never notices -- so it
|
|
// costs nothing and covers the overwhelming majority of transient trouble. Do
|
|
// not duplicate it here.
|
|
//
|
|
// L2 is "this node is not coming back, find another one". It is expensive: a
|
|
// switch drops every session that has already moved bytes. So the bar has to be
|
|
// high, and it has to be *sustained* -- `unhealthy_windows` consecutive bad
|
|
// samples, not one. A single 5s blip on a volunteer-run VPN in another country
|
|
// is normal weather (docs/ARCHITECTURE.md §7).
|
|
//
|
|
// ---------------------------------------------------------------------------
|
|
// Why the probe is a TCP handshake and not a DNS lookup
|
|
// ---------------------------------------------------------------------------
|
|
// The obvious in-tunnel RTT probe is a timed DNS query. It is also wrong: both
|
|
// resolvers we ship cache, so the second query onwards is answered from memory
|
|
// in ~0ms without a single byte crossing the tunnel. That reports a *dead*
|
|
// tunnel as the healthiest thing in the fleet -- the exact failure the monitor
|
|
// exists to catch.
|
|
//
|
|
// So the probe dials `probe_domain:probe_port` through the egress and drops the
|
|
// stream the moment it is up. A handshake cannot be served from a cache, it
|
|
// exercises the same path a real session uses, and its timing is a genuine
|
|
// round trip. The name lookup still happens inside it, so a broken in-tunnel
|
|
// resolver still shows up -- as a failed probe rather than a slow one.
|
|
//
|
|
// ---------------------------------------------------------------------------
|
|
// Scoring, and what happens when a signal is not available
|
|
// ---------------------------------------------------------------------------
|
|
// Four weighted terms in [0,1], plus one veto:
|
|
//
|
|
// veto egress absent or not Ready -> score 0, no probe attempted
|
|
// rtt probe latency, 0ms..1000ms -> 0.35
|
|
// connect SOCKS5 CONNECT success rate -> 0.30
|
|
// stall active sessions but no byte motion -> 0.20
|
|
// loss netstack drop counters -> 0.15
|
|
//
|
|
// The stall term needs byte counters the direct egress does not keep (it has no
|
|
// single aggregate to report, egress/egress.h). Rather than let a permanent
|
|
// zero read as a permanent stall, a term whose input is unavailable is dropped
|
|
// and the remaining weights are renormalised. A missing signal must not be
|
|
// scored as a bad one.
|
|
//
|
|
// ---------------------------------------------------------------------------
|
|
// Threading
|
|
// ---------------------------------------------------------------------------
|
|
// Everything runs on an internal strand. `last()`/`recent()` take a short mutex
|
|
// and are callable from the admin thread. The unhealthy handler is invoked on
|
|
// the strand; SwitchController forwards it straight to EgressManager, which has
|
|
// its own.
|
|
#pragma once
|
|
|
|
#include <asio.hpp>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <deque>
|
|
#include <functional>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "common/config.h"
|
|
#include "common/strand_deleter.h" // ovg::Strand
|
|
#include "egress/egress.h"
|
|
|
|
namespace ovg::health {
|
|
|
|
using Clock = std::chrono::steady_clock;
|
|
|
|
// One sampling round. Kept whole (rather than reduced to a bool) because "why
|
|
// did it decide that" is the only question anyone asks of a health check.
|
|
struct Sample {
|
|
int64_t age_ms = 0; // how long ago this was taken, filled in on read
|
|
std::string egress_label;
|
|
bool egress_present = false;
|
|
bool tunnel_up = false;
|
|
|
|
double rtt_ms = -1.0; // -1 = probe did not complete
|
|
bool probe_ok = false;
|
|
double connect_failure_rate = 0.0;
|
|
int64_t stalled_ms = 0; // 0 = moving, or unmeasurable
|
|
bool stall_known = false; // false = egress keeps no byte counters
|
|
double loss_rate = 0.0;
|
|
|
|
double score = 0.0;
|
|
bool healthy = false;
|
|
std::string verdict; // one line, human-first
|
|
|
|
Clock::time_point at{};
|
|
};
|
|
|
|
class HealthMonitor {
|
|
public:
|
|
// Returns the egress to sample, or null if there is none. Same seam as
|
|
// socks5::Server::EgressProvider, and for the same reason: the monitor is
|
|
// testable against a DirectEgress with no manager in sight.
|
|
using EgressProvider = std::function<egress::EgressPtr()>;
|
|
using UnhealthyHandler = std::function<void(const std::string &why)>;
|
|
|
|
HealthMonitor(asio::io_context &io, HealthConfig cfg, EgressProvider acquire);
|
|
~HealthMonitor();
|
|
|
|
HealthMonitor(const HealthMonitor &) = delete;
|
|
HealthMonitor &operator=(const HealthMonitor &) = delete;
|
|
|
|
// Fires once per *sustained* failure, not once per bad sample: after it fires
|
|
// the window counter resets, so a node that stays bad does not produce a
|
|
// switch request every `interval`. Set before start().
|
|
void set_on_unhealthy(UnhealthyHandler h);
|
|
|
|
void start();
|
|
void stop();
|
|
|
|
// Runs a round now, off-schedule, and re-arms the timer from here. Used by
|
|
// the admin endpoint and by SwitchController right after a promotion, when
|
|
// waiting a full interval to learn whether the new node works is too slow.
|
|
void probe_now();
|
|
|
|
Sample last() const;
|
|
std::vector<Sample> recent(size_t n) const;
|
|
int consecutive_bad() const;
|
|
uint64_t rounds() const;
|
|
|
|
private:
|
|
void arm();
|
|
void run_round();
|
|
void finish_probe(egress::EgressPtr eg, double rtt_ms, bool ok,
|
|
const std::string &probe_note);
|
|
void publish(Sample s);
|
|
// Byte-motion bookkeeping, reset whenever the egress underneath changes.
|
|
void update_stall(const egress::EgressStats &st, Sample *out);
|
|
|
|
asio::io_context &io_;
|
|
HealthConfig cfg_;
|
|
EgressProvider acquire_;
|
|
Strand strand_;
|
|
asio::steady_timer timer_;
|
|
asio::steady_timer probe_timer_;
|
|
|
|
UnhealthyHandler on_unhealthy_;
|
|
std::atomic<bool> running_{false};
|
|
|
|
// Strand-only.
|
|
bool probe_in_flight_ = false;
|
|
std::string stall_label_;
|
|
uint64_t stall_bytes_ = 0;
|
|
Clock::time_point stall_since_{};
|
|
bool traffic_ever_seen_ = false;
|
|
|
|
mutable std::mutex mu_;
|
|
std::deque<Sample> history_;
|
|
int consecutive_bad_ = 0;
|
|
uint64_t rounds_ = 0;
|
|
};
|
|
|
|
} // namespace ovg::health
|