Files
ovgate/tests/test_egress.cpp
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

1056 lines
38 KiB
C++

// Egress tests: the direct backend end to end, and the switch controller
// against a fake backend.
//
// The switch controller is the subtlest code in the program and the hardest to
// observe in production -- it fires rarely, on a schedule set by a volunteer
// server going bad. Testing it against a real VPNGate node would measure the
// weather. So EgressManager takes an egress factory (egress_manager.h), and
// everything below drives the state machine through a fake whose failures are
// exactly reproducible.
//
// The *selector* underneath is real, not faked: node lists, scoring, and the
// latency prober all run for real, aimed at a loopback listener this file
// starts. That keeps the seam at one place -- "how a node becomes a tunnel" --
// instead of stubbing out half the program.
#include <asio.hpp>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <fstream>
#include <memory>
#include <string>
#include <vector>
#include "common/config.h"
#include "common/error.h"
#include "egress/direct_egress.h"
#include "egress/egress_manager.h"
#include "harness.h"
#include "selector/history.h"
#include "selector/selector.h"
#include "vpngate/node_store.h"
using namespace ovg;
using namespace std::chrono_literals;
namespace {
using Clock = std::chrono::steady_clock;
bool run_until(asio::io_context &io, const std::function<bool()> &pred,
std::chrono::milliseconds budget) {
const auto deadline = Clock::now() + budget;
while (!pred() && Clock::now() < deadline) {
io.restart();
io.run_for(2ms);
}
return pred();
}
void settle(asio::io_context &io, std::chrono::milliseconds d = 40ms) {
const auto deadline = Clock::now() + d;
while (Clock::now() < deadline) {
io.restart();
io.run_for(2ms);
}
}
// ---------------------------------------------------------------------------
// Loopback peers
// ---------------------------------------------------------------------------
// Accepts forever and echoes. Used both as a target for the direct egress and
// as something the selector's latency prober can actually complete a handshake
// against.
class EchoServer {
public:
explicit EchoServer(asio::io_context &io)
: io_(io), acceptor_(io, asio::ip::tcp::endpoint(
asio::ip::make_address("127.0.0.1"), 0)) {
accept();
}
uint16_t port() const { return acceptor_.local_endpoint().port(); }
void stop() {
std::error_code ignored;
acceptor_.close(ignored);
}
private:
struct Conn : std::enable_shared_from_this<Conn> {
explicit Conn(asio::ip::tcp::socket s) : sock(std::move(s)) {}
asio::ip::tcp::socket sock;
char buf[512];
void go() {
auto self = shared_from_this();
sock.async_read_some(
asio::buffer(buf), [self](const std::error_code &ec, size_t n) {
if (ec) return;
asio::async_write(self->sock, asio::buffer(self->buf, n),
[self](const std::error_code &wec, size_t) {
if (!wec) self->go();
});
});
}
};
void accept() {
acceptor_.async_accept([this](const std::error_code &ec,
asio::ip::tcp::socket s) {
if (ec) return;
std::make_shared<Conn>(std::move(s))->go();
accept();
});
}
asio::io_context &io_;
asio::ip::tcp::acceptor acceptor_;
};
class UdpEcho {
public:
explicit UdpEcho(asio::io_context &io)
: sock_(io, asio::ip::udp::endpoint(asio::ip::make_address("127.0.0.1"),
0)) {
recv();
}
uint16_t port() const { return sock_.local_endpoint().port(); }
void stop() {
std::error_code ignored;
sock_.close(ignored);
}
private:
void recv() {
sock_.async_receive_from(asio::buffer(buf_), from_,
[this](const std::error_code &ec, size_t n) {
if (ec) return;
std::error_code ignored;
sock_.send_to(asio::buffer(buf_, n), from_, 0,
ignored);
recv();
});
}
asio::ip::udp::socket sock_;
asio::ip::udp::endpoint from_;
char buf_[2048];
};
// ---------------------------------------------------------------------------
// A synthetic node list whose remotes point at a loopback listener
// ---------------------------------------------------------------------------
std::string base64_encode(const std::string &in) {
static const char *tbl =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
size_t i = 0;
for (; i + 2 < in.size(); i += 3) {
const uint32_t v = (static_cast<uint8_t>(in[i]) << 16) |
(static_cast<uint8_t>(in[i + 1]) << 8) |
static_cast<uint8_t>(in[i + 2]);
out += tbl[(v >> 18) & 63];
out += tbl[(v >> 12) & 63];
out += tbl[(v >> 6) & 63];
out += tbl[v & 63];
}
if (i < in.size()) {
uint32_t v = static_cast<uint8_t>(in[i]) << 16;
const bool two = i + 1 < in.size();
if (two) v |= static_cast<uint8_t>(in[i + 1]) << 8;
out += tbl[(v >> 18) & 63];
out += tbl[(v >> 12) & 63];
out += two ? tbl[(v >> 6) & 63] : '=';
out += '=';
}
return out;
}
struct FakeFeed {
std::string path;
std::vector<std::string> ids; // node ids, best prior first
~FakeFeed() {
if (!path.empty()) std::remove(path.c_str());
}
};
// Three nodes, all of whose OpenVPN profiles say "connect to this loopback
// port". Nothing here ever speaks OpenVPN -- the port only has to complete a
// TCP handshake, which is exactly what the prober measures.
std::string feed_csv(uint16_t port) {
std::string csv =
"*vpn_servers\n"
"#HostName,IP,Score,Ping,Speed,CountryLong,CountryShort,NumVpnSessions,"
"Uptime,TotalUsers,TotalTraffic,LogType,Operator,Message,"
"OpenVPN_ConfigData_Base64\n";
struct Row {
const char *host;
const char *ip;
const char *score;
const char *country_long;
const char *country_short;
};
const Row rows[] = {
{"fake-a", "192.0.2.1", "9000000", "Japan", "JP"},
{"fake-b", "192.0.2.2", "6000000", "Korea", "KR"},
{"fake-c", "192.0.2.3", "3000000", "Taiwan", "TW"},
};
for (const auto &r : rows) {
const std::string profile =
std::string("client\ndev tun\nproto tcp\nremote 127.0.0.1 ") +
std::to_string(port) + " tcp\nnobind\n";
csv += std::string(r.host) + "," + r.ip + "," + r.score +
",10,100000000," + r.country_long + "," + r.country_short +
",50,86400000,1000,100000,2weeks,tester,," +
base64_encode(profile) + "\n";
}
csv += "*\n";
return csv;
}
std::shared_ptr<FakeFeed> write_feed(uint16_t port) {
auto feed = std::make_shared<FakeFeed>();
feed->path = "/tmp/ovg_test_feed_" + std::to_string(port) + ".csv";
feed->ids = {"fake-a@192.0.2.1", "fake-b@192.0.2.2", "fake-c@192.0.2.3"};
std::ofstream out(feed->path, std::ios::trunc);
out << feed_csv(port);
out.close();
return feed;
}
// Serves the node list over HTTP -- but only once a test says so. Before that
// it answers 503, which is how the store sees a cold start: the process is up,
// the API is reachable, and there is still no directory to select from.
class FeedServer {
public:
explicit FeedServer(asio::io_context &io)
: acceptor_(io, asio::ip::tcp::endpoint(
asio::ip::make_address("127.0.0.1"), 0)) {
acceptor_.listen();
accept();
}
std::string url() const {
return "http://127.0.0.1:" +
std::to_string(acceptor_.local_endpoint().port()) + "/api/iphone/";
}
void arm(std::string csv) { body_ = std::move(csv); }
void stop() {
std::error_code ec;
acceptor_.close(ec);
}
private:
void accept() {
auto sock =
std::make_shared<asio::ip::tcp::socket>(acceptor_.get_executor());
acceptor_.async_accept(*sock, [this, sock](std::error_code ec) {
if (ec) return;
auto resp = std::make_shared<std::string>(
body_.empty()
? std::string("HTTP/1.1 503 Service Unavailable\r\n"
"Content-Length: 0\r\n\r\n")
: "HTTP/1.1 200 OK\r\nContent-Length: " +
std::to_string(body_.size()) + "\r\n\r\n" + body_);
auto buf = std::make_shared<asio::streambuf>();
asio::async_read_until(
*sock, *buf, "\r\n\r\n", [sock, resp, buf](std::error_code, size_t) {
asio::async_write(*sock, asio::buffer(*resp),
[sock, resp](std::error_code, size_t) {
std::error_code ig;
sock->shutdown(
asio::ip::tcp::socket::shutdown_both, ig);
sock->close(ig);
});
});
accept();
});
}
asio::ip::tcp::acceptor acceptor_;
std::string body_;
};
// ---------------------------------------------------------------------------
// A fake egress: no tunnel, no sockets, fully observable
// ---------------------------------------------------------------------------
class FakeEgress final : public egress::Egress {
public:
FakeEgress(std::string node_id, asio::io_context &io)
: node_id_(std::move(node_id)), io_(io), label_(node_id_) {}
void async_connect_tcp(const asio::any_io_executor &ex, const Endpoint &,
Millis, ConnectHandler h) override {
asio::post(ex, [h = std::move(h)]() mutable {
h(make_error_code(Error::NotSupported), nullptr);
});
}
void async_bind_udp(const asio::any_io_executor &ex,
UdpBindHandler h) override {
asio::post(ex, [h = std::move(h)]() mutable {
h(make_error_code(Error::NotSupported), nullptr);
});
}
void async_resolve(const asio::any_io_executor &ex, const std::string &,
ResolveHandler h) override {
asio::post(ex, [h = std::move(h)]() mutable {
h(make_error_code(Error::NotSupported), {});
});
}
egress::EgressState state() const override { return state_.load(); }
egress::EgressStats stats() const override {
egress::EgressStats s;
s.node_id = node_id_;
s.proto = "fake";
return s;
}
std::string detail() const override { return {}; }
void begin_drain() override {
auto want = egress::EgressState::Ready;
state_.compare_exchange_strong(want, egress::EgressState::Draining);
drained.store(true);
}
void shutdown(std::function<void()> on_done) override {
state_.store(egress::EgressState::Down);
shutdowns.fetch_add(1);
if (on_shutdown) on_shutdown(node_id_);
if (on_done) asio::post(io_, std::move(on_done));
}
const std::string &label() const override { return label_; }
std::atomic<egress::EgressState> state_{egress::EgressState::Ready};
std::atomic<bool> drained{false};
std::atomic<int> shutdowns{0};
// Reported out rather than polled, because the interesting case is the one
// where the test is deliberately holding no reference to this object.
std::function<void(const std::string &)> on_shutdown;
private:
std::string node_id_;
asio::io_context &io_;
std::string label_;
};
// Everything a manager test needs, wired up once.
struct ManagerFixture {
asio::io_context io;
EchoServer echo{io};
// Unused unless a test points cfg.vpngate.api_urls at it; see the cold-start
// test. Constructed unconditionally because it needs `io`, and `io` is only
// in scope inside the fixture.
FeedServer feed_srv{io};
std::shared_ptr<FakeFeed> feed;
Config cfg;
std::unique_ptr<vpngate::NodeStore> store;
std::unique_ptr<selector::HistoryStore> history;
std::unique_ptr<selector::Selector> selector;
std::unique_ptr<egress::EgressManager> mgr;
// Node ids the factory should refuse, and with which error.
std::map<std::string, std::error_code> fail_with;
// Node ids that fail exactly once, then succeed. Keyed by id, value is the
// remaining number of failures.
std::map<std::string, int> fail_once;
// Labels only: holding an egress here would keep the reference count off zero
// and the drain would never finish.
std::vector<std::pair<std::string, std::string>> promotions;
std::vector<std::string> drain_expired;
std::vector<std::string> shut_down;
// `tweak` runs after the defaults are in place and before the store is built,
// which is the only window in which cfg.vpngate still matters: NodeStore
// copies it and loads the cache in its constructor.
explicit ManagerFixture(std::function<void(ManagerFixture &)> tweak = {}) {
feed = write_feed(echo.port());
cfg.vpngate.api_urls = {"http://127.0.0.1:1/"};
cfg.vpngate.cache_path = feed->path;
cfg.vpngate.cache_max_age = std::chrono::hours(24);
cfg.vpngate.refresh_interval = std::chrono::hours(24);
cfg.selector.probe_samples = 1;
cfg.selector.probe_timeout = 500ms;
cfg.switching.min_interval = 0ms;
cfg.switching.backoff_initial = 10ms;
cfg.switching.backoff_max = 40ms;
if (tweak) tweak(*this);
store = std::make_unique<vpngate::NodeStore>(io, cfg.vpngate);
store->start();
history = std::make_unique<selector::HistoryStore>("", cfg.selector);
selector = std::make_unique<selector::Selector>(io, cfg.selector, *store,
*history);
}
~ManagerFixture() {
if (mgr) {
bool done = false;
mgr->shutdown([&] { done = true; });
run_until(io, [&] { return done; }, 2s);
}
store->stop();
echo.stop();
feed_srv.stop();
settle(io);
}
// Built after the test has adjusted cfg.
void build() {
mgr = std::make_unique<egress::EgressManager>(io, cfg, selector.get(),
history.get(), nullptr);
mgr->set_egress_factory(
[this](const vpngate::Node &node, const vpngate::Remote &,
egress::EgressManager::NodeReadyHandler on_ready)
-> egress::EgressPtr {
const std::string id = node.id();
auto it = fail_with.find(id);
if (it != fail_with.end()) {
auto ec = it->second;
asio::post(io, [on_ready, ec] { on_ready(ec, "test: refused"); });
// Non-null: the failure is reported through on_ready, which is the
// path a real tunnel that comes up and then dies also takes.
return std::make_shared<FakeEgress>(id, io);
}
auto once = fail_once.find(id);
if (once != fail_once.end() && once->second > 0) {
once->second--;
asio::post(io, [on_ready] {
on_ready(make_error_code(Error::ResourceExhausted),
"test: address collision");
});
return std::make_shared<FakeEgress>(id, io);
}
auto e = std::make_shared<FakeEgress>(id, io);
e->on_shutdown = [this](const std::string &n) {
shut_down.push_back(n);
};
asio::post(io, [on_ready] { on_ready({}, ""); });
return e;
});
mgr->set_on_promote([this](const egress::EgressPtr &old_e,
const egress::EgressPtr &new_e) {
promotions.emplace_back(old_e ? old_e->label() : "", new_e->label());
});
mgr->set_on_drain_expired([this](const egress::EgressPtr &e) {
drain_expired.push_back(e->label());
});
}
std::error_code start() {
std::error_code got = make_error_code(Error::Internal);
bool done = false;
mgr->start([&](const std::error_code &ec) {
got = ec;
done = true;
});
run_until(io, [&] { return done; }, 10s);
return got;
}
};
} // namespace
// ---------------------------------------------------------------------------
// DirectEgress
// ---------------------------------------------------------------------------
OVG_TEST(direct_egress_tcp_roundtrip) {
asio::io_context io;
EchoServer echo(io);
auto e = egress::DirectEgress::create(io, DnsConfig{});
netstack::TcpStreamPtr stream;
std::error_code connect_ec = make_error_code(Error::Internal);
bool connected = false;
e->async_connect_tcp(
io.get_executor(),
Endpoint(*IpAddress::parse("127.0.0.1"), echo.port()), 2000ms,
[&](const std::error_code &ec, netstack::TcpStreamPtr s) {
connect_ec = ec;
stream = std::move(s);
connected = true;
});
CHECK(run_until(io, [&] { return connected; }, 3s));
CHECK_EQ(connect_ec, std::error_code{});
CHECK(stream != nullptr);
CHECK(stream->is_open());
const std::string msg = "hello egress";
bool wrote = false;
stream->async_write(asio::buffer(msg),
[&](const std::error_code &ec, size_t n) {
CHECK_EQ(ec, std::error_code{});
CHECK_EQ(n, msg.size());
wrote = true;
});
CHECK(run_until(io, [&] { return wrote; }, 3s));
char rbuf[64];
size_t got = 0;
bool read_done = false;
stream->async_read_some(asio::buffer(rbuf),
[&](const std::error_code &ec, size_t n) {
CHECK_EQ(ec, std::error_code{});
got = n;
read_done = true;
});
CHECK(run_until(io, [&] { return read_done; }, 3s));
CHECK_EQ(std::string(rbuf, got), msg);
CHECK_EQ(stream->bytes_written(), msg.size());
CHECK_EQ(stream->bytes_read(), msg.size());
auto st = e->stats();
CHECK_EQ(st.tcp_opened, uint64_t{1});
CHECK_EQ(st.tcp_failed, uint64_t{0});
CHECK_EQ(st.tcp_active, int64_t{1});
// The active gauge counts live streams, not live handlers: it only drops when
// the object is actually destroyed.
stream.reset();
settle(io);
CHECK_EQ(e->stats().tcp_active, int64_t{0});
echo.stop();
settle(io);
}
OVG_TEST(direct_egress_half_close_propagates) {
asio::io_context io;
EchoServer echo(io);
auto e = egress::DirectEgress::create(io, DnsConfig{});
netstack::TcpStreamPtr stream;
bool connected = false;
e->async_connect_tcp(io.get_executor(),
Endpoint(*IpAddress::parse("127.0.0.1"), echo.port()),
2000ms,
[&](const std::error_code &ec, netstack::TcpStreamPtr s) {
CHECK_EQ(ec, std::error_code{});
stream = std::move(s);
connected = true;
});
CHECK(run_until(io, [&] { return connected; }, 3s));
// Our FIN makes the echo server's read fail, so it drops the connection and
// we see EOF -- the exact sequence a SOCKS5 relay must forward rather than
// treat as an error.
stream->shutdown_send();
char rbuf[16];
std::error_code read_ec;
bool read_done = false;
stream->async_read_some(asio::buffer(rbuf),
[&](const std::error_code &ec, size_t) {
read_ec = ec;
read_done = true;
});
CHECK(run_until(io, [&] { return read_done; }, 3s));
CHECK_EQ(read_ec, std::error_code(asio::error::eof));
echo.stop();
settle(io);
}
OVG_TEST(direct_egress_reports_connect_and_resolve_failures_separately) {
asio::io_context io;
auto e = egress::DirectEgress::create(io, DnsConfig{});
// Port 1 on loopback: nothing listens, and the RST is immediate.
std::error_code connect_ec;
bool done = false;
e->async_connect_tcp(io.get_executor(),
Endpoint(*IpAddress::parse("127.0.0.1"), 1), 2000ms,
[&](const std::error_code &ec, netstack::TcpStreamPtr s) {
connect_ec = ec;
CHECK(s == nullptr);
done = true;
});
CHECK(run_until(io, [&] { return done; }, 5s));
CHECK_NE(connect_ec, std::error_code{});
CHECK_NE(connect_ec, std::error_code(make_error_code(Error::ResolveFailed)));
CHECK_EQ(e->stats().tcp_failed, uint64_t{1});
CHECK_EQ(e->stats().tcp_opened, uint64_t{0});
// A name that cannot resolve must not be reported as a connect failure: the
// SOCKS5 reply code and the operator's debugging both depend on the
// difference. Some environments answer for .invalid anyway (a wildcard
// resolver, a captive DNS), and then there is nothing here to assert.
std::error_code resolve_ec;
bool resolved_anyway = false;
bool done2 = false;
e->async_resolve(io.get_executor(), "no-such-host.invalid",
[&](const std::error_code &ec, std::vector<IpAddress> a) {
resolve_ec = ec;
resolved_anyway = !ec && !a.empty();
done2 = true;
});
if (!run_until(io, [&] { return done2; }, 10s)) {
SKIP("resolver did not answer for .invalid within 10s");
}
if (resolved_anyway) SKIP("this environment resolves .invalid");
CHECK_EQ(resolve_ec, std::error_code(make_error_code(Error::ResolveFailed)));
std::error_code by_name_ec;
bool done3 = false;
e->async_connect_tcp(io.get_executor(), Endpoint("no-such-host.invalid", 80),
2000ms,
[&](const std::error_code &ec, netstack::TcpStreamPtr s) {
by_name_ec = ec;
CHECK(s == nullptr);
done3 = true;
});
CHECK(run_until(io, [&] { return done3; }, 10s));
CHECK_EQ(by_name_ec, std::error_code(make_error_code(Error::ResolveFailed)));
}
OVG_TEST(direct_egress_udp_roundtrip) {
asio::io_context io;
UdpEcho peer(io);
auto e = egress::DirectEgress::create(io, DnsConfig{});
netstack::UdpSocketPtr sock;
bool bound = false;
e->async_bind_udp(io.get_executor(),
[&](const std::error_code &ec, netstack::UdpSocketPtr s) {
CHECK_EQ(ec, std::error_code{});
sock = std::move(s);
bound = true;
});
CHECK(run_until(io, [&] { return bound; }, 3s));
CHECK(sock != nullptr);
CHECK_EQ(e->stats().udp_active, int64_t{1});
char rbuf[64];
size_t got = 0;
Endpoint from;
bool received = false;
sock->async_receive_from(asio::buffer(rbuf),
[&](const std::error_code &ec, size_t n,
const Endpoint &f) {
CHECK_EQ(ec, std::error_code{});
got = n;
from = f;
received = true;
});
const std::string msg = "datagram";
bool sent = false;
sock->async_send_to(asio::buffer(msg),
Endpoint(*IpAddress::parse("127.0.0.1"), peer.port()),
[&](const std::error_code &ec, size_t n) {
CHECK_EQ(ec, std::error_code{});
CHECK_EQ(n, msg.size());
sent = true;
});
CHECK(run_until(io, [&] { return sent && received; }, 3s));
CHECK_EQ(std::string(rbuf, got), msg);
CHECK_EQ(from.port(), peer.port());
sock.reset();
settle(io);
CHECK_EQ(e->stats().udp_active, int64_t{0});
peer.stop();
settle(io);
}
OVG_TEST(direct_egress_draining_refuses_new_work) {
asio::io_context io;
EchoServer echo(io);
auto e = egress::DirectEgress::create(io, DnsConfig{});
e->begin_drain();
CHECK_EQ(e->state(), egress::EgressState::Draining);
CHECK(!e->usable());
// Draining is not "gone": the distinction is what tells the SOCKS5 layer to
// retry on the new egress rather than report a failure to the client.
std::error_code ec1;
bool done1 = false;
e->async_connect_tcp(io.get_executor(),
Endpoint(*IpAddress::parse("127.0.0.1"), echo.port()),
1000ms,
[&](const std::error_code &ec, netstack::TcpStreamPtr) {
ec1 = ec;
done1 = true;
});
CHECK(run_until(io, [&] { return done1; }, 2s));
CHECK_EQ(ec1, std::error_code(make_error_code(Error::EgressDraining)));
bool shut = false;
e->shutdown([&] { shut = true; });
CHECK(run_until(io, [&] { return shut; }, 2s));
CHECK_EQ(e->state(), egress::EgressState::Down);
std::error_code ec2;
bool done2 = false;
e->async_bind_udp(io.get_executor(),
[&](const std::error_code &ec, netstack::UdpSocketPtr) {
ec2 = ec;
done2 = true;
});
CHECK(run_until(io, [&] { return done2; }, 2s));
CHECK_EQ(ec2, std::error_code(make_error_code(Error::EgressGone)));
echo.stop();
settle(io);
}
// ---------------------------------------------------------------------------
// EgressManager: direct mode
// ---------------------------------------------------------------------------
OVG_TEST(manager_direct_mode_needs_no_selector) {
asio::io_context io;
Config cfg;
cfg.egress_mode = "direct";
egress::EgressManager mgr(io, cfg, nullptr, nullptr, nullptr);
std::error_code start_ec = make_error_code(Error::Internal);
bool started = false;
mgr.start([&](const std::error_code &ec) {
start_ec = ec;
started = true;
});
CHECK(run_until(io, [&] { return started; }, 3s));
CHECK_EQ(start_ec, std::error_code{});
auto e = mgr.acquire();
CHECK(e != nullptr);
CHECK(e->usable());
CHECK_EQ(e->label(), std::string("direct"));
// There is nothing to switch to, and pretending otherwise would mean
// silently dropping sessions for no gain.
CHECK(!mgr.request_switch(egress::SwitchReason::Manual, /*force=*/true));
bool shut = false;
mgr.shutdown([&] { shut = true; });
CHECK(run_until(io, [&] { return shut; }, 3s));
CHECK(mgr.acquire() == nullptr);
}
// ---------------------------------------------------------------------------
// EgressManager: make-before-break
// ---------------------------------------------------------------------------
OVG_TEST(manager_promotes_without_touching_existing_sessions) {
ManagerFixture fx;
fx.cfg.switching.drain_grace = 30s; // long enough that nothing expires here
fx.build();
CHECK_EQ(fx.start(), std::error_code{});
auto first = fx.mgr->acquire();
CHECK(first != nullptr);
CHECK(first->usable());
// This is a session: it holds the egress for its whole life, and that
// reference is the only thing keeping the old tunnel alive after the switch.
auto session = fx.mgr->acquire();
CHECK_EQ(session.get(), first.get());
CHECK(fx.mgr->request_switch(egress::SwitchReason::Manual));
CHECK(run_until(
fx.io, [&] { return fx.mgr->acquire() != nullptr &&
fx.mgr->acquire().get() != first.get(); },
10s));
auto second = fx.mgr->acquire();
CHECK(second != nullptr);
CHECK_NE(second->label(), first->label());
// The old egress refuses new work but was not torn down: the session on it is
// still connected, which is the entire point of make-before-break.
CHECK_EQ(first->state(), egress::EgressState::Draining);
CHECK_EQ(std::static_pointer_cast<FakeEgress>(first)->shutdowns.load(), 0);
CHECK_EQ(fx.mgr->status().draining, size_t{1});
// The callback fired before the drain started, with both egresses live, so
// the SOCKS5 layer can re-home what is safe to re-home.
CHECK_EQ(fx.promotions.size(), size_t{2}); // startup + this switch
CHECK_EQ(fx.promotions.back().first, first->label());
CHECK_EQ(fx.promotions.back().second, second->label());
// Session ends. use_count() falling to the manager's own reference is what
// completes the drain -- there is no session table to consult. Which is also
// why the test must let go of every reference of its own first.
const std::string old_label = first->label();
std::weak_ptr<egress::Egress> weak_old = first;
session.reset();
first.reset();
CHECK(run_until(fx.io, [&] { return !fx.shut_down.empty(); }, 5s));
CHECK_EQ(fx.shut_down.back(), old_label);
CHECK_EQ(fx.mgr->status().draining, size_t{0});
CHECK(fx.drain_expired.empty()); // it finished, it did not time out
// And nothing kept it alive: a leak here would be a tunnel that never closes.
CHECK(weak_old.expired());
}
OVG_TEST(manager_closes_stragglers_when_the_grace_window_expires) {
ManagerFixture fx;
fx.cfg.switching.drain_grace = 150ms;
fx.build();
CHECK_EQ(fx.start(), std::error_code{});
auto first = fx.mgr->acquire();
CHECK(first != nullptr);
auto stuck_session = first; // never released: a long-lived download
CHECK(fx.mgr->request_switch(egress::SwitchReason::Unhealthy));
CHECK(run_until(
fx.io, [&] { auto a = fx.mgr->acquire();
return a && a.get() != first.get(); }, 10s));
// The requirement's own fallback: sessions that cannot be carried across are
// dropped rather than kept alive on a node we have decided is bad.
CHECK(run_until(fx.io, [&] { return !fx.drain_expired.empty(); }, 5s));
CHECK_EQ(fx.drain_expired.front(), first->label());
CHECK_EQ(std::static_pointer_cast<FakeEgress>(first)->shutdowns.load(), 1);
CHECK_EQ(first->state(), egress::EgressState::Down);
CHECK_EQ(fx.mgr->status().draining, size_t{0});
(void)stuck_session;
}
OVG_TEST(manager_hard_mode_closes_everything_at_promotion) {
ManagerFixture fx;
fx.cfg.switching.mode = SwitchConfig::Mode::Hard;
fx.build();
CHECK_EQ(fx.start(), std::error_code{});
auto first = fx.mgr->acquire();
CHECK(first != nullptr);
auto session = first;
CHECK(fx.mgr->request_switch(egress::SwitchReason::Manual));
CHECK(run_until(
fx.io, [&] { auto a = fx.mgr->acquire();
return a && a.get() != first.get(); }, 10s));
// No drain at all: the old egress is down before the switch is reported, and
// the SOCKS5 layer was told to close what was on it.
CHECK_EQ(first->state(), egress::EgressState::Down);
CHECK_EQ(fx.mgr->status().draining, size_t{0});
CHECK_EQ(fx.drain_expired.size(), size_t{1});
CHECK_EQ(fx.drain_expired.front(), first->label());
(void)session;
}
OVG_TEST(manager_walks_the_candidate_list_when_one_fails) {
ManagerFixture fx;
// Refuse the two highest-scoring nodes; the third must still bring the
// service up. A failure here costs nothing, because nothing was promoted.
fx.fail_with[fx.feed->ids[0]] = make_error_code(Error::TunnelSetupFailed);
fx.fail_with[fx.feed->ids[1]] = make_error_code(Error::Timeout);
fx.build();
CHECK_EQ(fx.start(), std::error_code{});
auto e = fx.mgr->acquire();
CHECK(e != nullptr);
CHECK_EQ(e->label(), fx.feed->ids[2]);
CHECK_EQ(fx.mgr->status().switch_failures, uint64_t{0});
}
OVG_TEST(manager_gives_up_startup_after_the_retry_budget) {
ManagerFixture fx;
for (const auto &id : fx.feed->ids)
fx.fail_with[id] = make_error_code(Error::TunnelSetupFailed);
fx.build();
// Reporting the failure rather than retrying forever: a service that never
// finishes starting is harder to operate than one that exits with a reason.
const auto ec = fx.start();
CHECK_EQ(ec, std::error_code(make_error_code(Error::TunnelSetupFailed)));
CHECK(fx.mgr->acquire() == nullptr);
CHECK_GT(fx.mgr->status().switch_failures, uint64_t{0});
}
OVG_TEST(manager_waits_for_the_directory_instead_of_spending_startup_rounds) {
// Regression: the first selection normally loses the race with the first
// directory fetch by about a second. Charging that to the backoff ladder cost
// a full backoff -- and four of them exhausted the startup budget, so a
// process whose API call took a few seconds gave up and exited with
// "no node came up after 4 rounds" while the node list was still in flight.
// Nothing had been attempted. It is a wait, not a failure.
std::string cache;
ManagerFixture fx([&](ManagerFixture &f) {
// No cache on disk and nothing served yet: a genuinely cold start.
cache = f.feed->path + ".cold";
std::remove(cache.c_str());
f.cfg.vpngate.cache_path = cache;
f.cfg.vpngate.api_urls = {f.feed_srv.url()};
// Small enough that the buggy path would have burned all four rounds
// (5 + 10 + 10 + 10 ms) long before the check below.
f.cfg.switching.backoff_initial = 5ms;
f.cfg.switching.backoff_max = 10ms;
});
fx.build();
std::error_code got = make_error_code(Error::Internal);
bool done = false;
fx.mgr->start([&](const std::error_code &ec) {
got = ec;
done = true;
});
// Still starting, with nothing charged against it.
CHECK(!run_until(fx.io, [&] { return done; }, 200ms));
CHECK_EQ(fx.mgr->status().switch_failures, uint64_t{0});
// And reachable: the wait releases the phase, so the tunnel-down watchdog and
// an admin switch are not told a selection is in progress for the whole wait.
CHECK(fx.mgr->status().phase == egress::SwitchPhase::Idle);
fx.feed_srv.arm(feed_csv(fx.echo.port()));
fx.store->refresh_now(nullptr);
// The poll is on a one-second cadence, so allow a couple of them.
CHECK(run_until(fx.io, [&] { return done; }, 4s));
CHECK_EQ(got, std::error_code{});
CHECK_EQ(fx.promotions.size(), size_t{1});
CHECK_EQ(fx.mgr->status().switch_failures, uint64_t{0});
CHECK(fx.mgr->acquire() != nullptr);
std::remove(cache.c_str());
}
OVG_TEST(manager_address_collision_degrades_to_a_hard_switch) {
ManagerFixture fx;
fx.cfg.switching.drain_grace = 30s;
fx.build();
CHECK_EQ(fx.start(), std::error_code{});
auto first = fx.mgr->acquire();
CHECK(first != nullptr);
auto session = first;
// Every remaining node reports the collision once. lwIP cannot tell two
// tunnels pushing the same private address apart (netstack/lwip_stack.h), so
// make-before-break is simply unavailable and the switch must degrade rather
// than stall.
for (const auto &id : fx.feed->ids) fx.fail_once[id] = 1;
CHECK(fx.mgr->request_switch(egress::SwitchReason::Unhealthy));
CHECK(run_until(
fx.io, [&] { auto a = fx.mgr->acquire();
return a && a.get() != first.get(); }, 15s));
// The old egress was closed to make room, and the layer above was told so it
// could close the sessions that were on it.
CHECK_EQ(first->state(), egress::EgressState::Down);
CHECK_GT(fx.drain_expired.size(), size_t{0});
CHECK_EQ(fx.drain_expired.front(), first->label());
CHECK(fx.mgr->acquire()->usable());
(void)session;
}
OVG_TEST(manager_bounds_the_number_of_draining_egresses) {
ManagerFixture fx;
fx.cfg.switching.drain_grace = 30s;
fx.cfg.switching.max_draining = 1;
fx.build();
CHECK_EQ(fx.start(), std::error_code{});
auto a = fx.mgr->acquire();
auto session_a = a; // held: a would otherwise drain immediately
CHECK(fx.mgr->request_switch(egress::SwitchReason::Manual));
CHECK(run_until(fx.io, [&] { auto x = fx.mgr->acquire();
return x && x.get() != a.get(); }, 10s));
auto b = fx.mgr->acquire();
auto session_b = b;
CHECK_EQ(fx.mgr->status().draining, size_t{1});
CHECK(fx.mgr->request_switch(egress::SwitchReason::Manual));
CHECK(run_until(fx.io, [&] { auto x = fx.mgr->acquire();
return x && x.get() != b.get(); }, 10s));
// Each draining tunnel still costs an OpenVPN session, a netif and its
// buffers; without a cap a flapping node would accumulate them until memory
// ran out. The oldest goes, even though its grace window had not expired.
CHECK_EQ(fx.mgr->status().draining, size_t{1});
CHECK_EQ(a->state(), egress::EgressState::Down);
CHECK_EQ(b->state(), egress::EgressState::Draining);
(void)session_a;
(void)session_b;
}
OVG_TEST(manager_suppresses_flapping_but_honours_force) {
ManagerFixture fx;
fx.cfg.switching.min_interval = 60s;
fx.build();
CHECK_EQ(fx.start(), std::error_code{});
CHECK(fx.mgr->acquire() != nullptr);
// A health monitor that fires every 15s must not be able to switch every 15s.
CHECK(!fx.mgr->request_switch(egress::SwitchReason::Unhealthy));
CHECK(!fx.mgr->request_switch(egress::SwitchReason::BetterCandidate));
// The admin endpoint can override it, because an operator asking for a switch
// knows something the anti-flap timer does not.
CHECK(fx.mgr->request_switch(egress::SwitchReason::Manual, /*force=*/true));
auto first = fx.mgr->acquire();
CHECK(run_until(fx.io, [&] { auto x = fx.mgr->acquire();
return x && x.get() != first.get(); }, 10s));
}
OVG_TEST(manager_opportunistic_switch_requires_a_better_score) {
ManagerFixture fx;
// A challenger has to beat the incumbent by a wide margin. Since all three
// synthetic nodes probe the same loopback listener, none can, so an
// opportunistic request must decide to stay put -- and must not be recorded
// as a failure, because nothing failed.
fx.cfg.switching.improvement_margin = 5.0;
fx.build();
CHECK_EQ(fx.start(), std::error_code{});
auto before = fx.mgr->acquire();
CHECK(before != nullptr);
CHECK(fx.mgr->request_switch(egress::SwitchReason::BetterCandidate));
settle(fx.io, 400ms);
CHECK_EQ(fx.mgr->acquire().get(), before.get());
CHECK_EQ(fx.mgr->status().switch_failures, uint64_t{0});
CHECK_EQ(fx.mgr->status().phase, egress::SwitchPhase::Idle);
// Unhealthy is not opportunistic: when the current node is bad, anything that
// connects is an improvement and the margin does not apply.
CHECK(fx.mgr->request_switch(egress::SwitchReason::Unhealthy));
CHECK(run_until(fx.io, [&] { auto x = fx.mgr->acquire();
return x && x.get() != before.get(); }, 10s));
}
OVG_TEST(manager_never_hands_out_a_draining_egress) {
ManagerFixture fx;
fx.cfg.switching.drain_grace = 30s;
fx.build();
CHECK_EQ(fx.start(), std::error_code{});
auto first = fx.mgr->acquire();
auto session = first;
CHECK(fx.mgr->request_switch(egress::SwitchReason::Manual));
CHECK(run_until(fx.io, [&] { auto x = fx.mgr->acquire();
return x && x.get() != first.get(); }, 10s));
// A hundred new sessions all land on the new egress. If any of them got the
// draining one, its drain would never finish.
for (int i = 0; i < 100; ++i) {
auto e = fx.mgr->acquire();
CHECK(e != nullptr);
CHECK_NE(e.get(), first.get());
CHECK(e->usable());
}
(void)session;
}