forked from cloud/ovgate
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>
261 lines
7.6 KiB
C++
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));
|
|
}
|