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

249 lines
7.6 KiB
C++

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