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

261 lines
7.6 KiB
C++

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