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

272 lines
8.6 KiB
C++

#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);
}