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>
This commit is contained in:
iceBear67
2026-07-28 04:38:39 +00:00
co-authored by Claude Opus 5
commit b2ba45c9f8
98 changed files with 24119 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
#include "ovpn/packet_pipe.h"
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include "common/logging.h"
#include "common/metrics.h"
namespace ovg::ovpn {
namespace {
constexpr const char *kMod = "ovpn.pipe";
metrics::Counter *tx_dropped_metric() {
static auto *c = metrics::counter(
"ovg_tun_tx_dropped_total",
"IP packets dropped writing to the tunnel (peer queue full)");
return c;
}
// Asks for `want` bytes and reports what the kernel settled on. Linux doubles
// the request internally for bookkeeping, so the value read back is roughly
// 2x what was asked for -- and is capped by net.core.{r,w}mem_max when we do
// not hold CAP_NET_ADMIN, which is the normal case for us.
int set_and_read_buf(int fd, int optname, int want) {
if (want > 0) ::setsockopt(fd, SOL_SOCKET, optname, &want, sizeof(want));
int got = 0;
socklen_t len = sizeof(got);
if (::getsockopt(fd, SOL_SOCKET, optname, &got, &len) != 0) return 0;
return got;
}
} // namespace
PacketPipe::PacketPipe(asio::io_context &io) : sock_(io) {}
PacketPipe::~PacketPipe() { close(); }
bool PacketPipe::open(int socket_buffer_bytes, std::string *err) {
if (sock_.is_open()) {
if (err) *err = "packet pipe already open";
return false;
}
int fds[2] = {-1, -1};
if (::socketpair(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC, 0, fds) != 0) {
if (err) *err = std::string("socketpair: ") + std::strerror(errno);
return false;
}
// fds[0] is ours, fds[1] goes to openvpn3. Both directions need the buffer:
// ours holds packets the tunnel delivered until the netstack drains them,
// theirs holds packets we wrote until openvpn3 encrypts them.
granted_sndbuf_ = set_and_read_buf(fds[0], SO_SNDBUF, socket_buffer_bytes);
granted_rcvbuf_ = set_and_read_buf(fds[0], SO_RCVBUF, socket_buffer_bytes);
set_and_read_buf(fds[1], SO_SNDBUF, socket_buffer_bytes);
set_and_read_buf(fds[1], SO_RCVBUF, socket_buffer_bytes);
std::error_code ec;
sock_.assign(asio::local::datagram_protocol(), fds[0], ec);
if (ec) {
::close(fds[0]);
::close(fds[1]);
if (err) *err = "assign socketpair to asio: " + ec.message();
return false;
}
peer_fd_ = fds[1];
peer_released_ = false;
if (socket_buffer_bytes > 0 && granted_sndbuf_ < socket_buffer_bytes) {
// Worth a line: the usual cause is net.core.wmem_max, and an operator
// chasing throughput needs to know the knob they set did not take effect.
LOG_INFO(kMod,
"tun socket buffers clamped by the kernel: asked {} B, got "
"snd={} B rcv={} B (raise net.core.wmem_max/rmem_max to lift it)",
socket_buffer_bytes, granted_sndbuf_, granted_rcvbuf_);
} else {
LOG_DEBUG(kMod, "tun packet pipe up: fd={} peer_fd={} snd={} rcv={}",
sock_.native_handle(), peer_fd_, granted_sndbuf_,
granted_rcvbuf_);
}
return true;
}
int PacketPipe::release_peer_fd() {
if (peer_fd_ < 0 || peer_released_) return -1;
peer_released_ = true;
const int fd = peer_fd_;
peer_fd_ = -1;
return fd;
}
void PacketPipe::close() {
if (peer_fd_ >= 0 && !peer_released_) {
::close(peer_fd_);
peer_fd_ = -1;
}
if (sock_.is_open()) {
std::error_code ignored;
sock_.close(ignored);
}
}
PacketPipe::SendStatus PacketPipe::send_packet(const void *data, size_t len) {
if (!sock_.is_open()) return SendStatus::Closed;
if (len == 0 || len > kMaxPacketSize) {
tx_dropped_.fetch_add(1, std::memory_order_relaxed);
tx_dropped_metric()->inc();
return SendStatus::Dropped;
}
// MSG_NOSIGNAL because a closed peer must surface as EPIPE, not SIGPIPE.
for (;;) {
const ssize_t n = ::send(sock_.native_handle(), data, len,
MSG_DONTWAIT | MSG_NOSIGNAL);
if (n >= 0) {
tx_packets_.fetch_add(1, std::memory_order_relaxed);
tx_bytes_.fetch_add(static_cast<uint64_t>(n), std::memory_order_relaxed);
return SendStatus::Ok;
}
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == ENOBUFS ||
errno == EMSGSIZE || errno == ENOMEM) {
tx_dropped_.fetch_add(1, std::memory_order_relaxed);
tx_dropped_metric()->inc();
return SendStatus::Dropped;
}
// EPIPE / ECONNREFUSED / EBADF: openvpn3 closed its end.
LOG_DEBUG(kMod, "tun write failed, peer gone: {}", std::strerror(errno));
return SendStatus::Closed;
}
}
void PacketPipe::note_received(size_t bytes) {
rx_packets_.fetch_add(1, std::memory_order_relaxed);
rx_bytes_.fetch_add(bytes, std::memory_order_relaxed);
}
PacketPipe::Counters PacketPipe::counters() const {
Counters c;
c.tx_packets = tx_packets_.load(std::memory_order_relaxed);
c.tx_bytes = tx_bytes_.load(std::memory_order_relaxed);
c.tx_dropped = tx_dropped_.load(std::memory_order_relaxed);
c.rx_packets = rx_packets_.load(std::memory_order_relaxed);
c.rx_bytes = rx_bytes_.load(std::memory_order_relaxed);
return c;
}
} // namespace ovg::ovpn
+114
View File
@@ -0,0 +1,114 @@
// The tun replacement: a socketpair that carries raw IP packets.
//
// openvpn3's TunBuilder contract says tun_builder_establish() returns a file
// descriptor "which the caller will henceforth own", and the Linux tun builder
// client wraps that descriptor in an openvpn_io::posix::stream_descriptor. It
// never issues a single tun ioctl on it, so the descriptor does not have to be
// a tun device -- one end of a socketpair works exactly as well, and needs no
// root, no CAP_NET_ADMIN and no device node. That is the hinge the whole
// userspace design turns on (docs/FEASIBILITY.md 2.2).
//
// SOCK_DGRAM, not SOCK_STREAM, and the distinction matters: a tun device
// delivers whole IP packets with framing, and a datagram socket preserves
// exactly that boundary. Over SOCK_STREAM we would have to reassemble packets
// by parsing IP length fields, and a single desync would corrupt the stream
// forever.
//
// On Linux under USE_TUN_BUILDER the core leaves tun_prefix false, so what
// crosses this pipe is bare IP -- no 4-byte address-family prefix.
#pragma once
#include <asio.hpp>
#include <asio/local/datagram_protocol.hpp>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <string>
namespace ovg::ovpn {
// The largest IP packet we will move. VPNGate pushes MTUs at or below 1500;
// the slack covers a server that pushes something unusual. Sizing matters
// because a datagram socket silently truncates anything larger than the read
// buffer -- no error, no short-read indication, just a corrupt packet.
inline constexpr size_t kMaxPacketSize = 4096;
// One end of the pipe (ours, asio-driven). The other end is a bare fd that
// gets handed to openvpn3, which owns and closes it from that point on.
//
// Not thread-safe for open()/close()/release_peer_fd(): those run on the owner
// before the worker thread starts and after it exits. The counters are atomic
// so the admin endpoint can read them from anywhere.
class PacketPipe {
public:
using Socket = asio::local::datagram_protocol::socket;
enum class SendStatus {
Ok,
Dropped, // buffer full or packet too big -- legal for IP, counted
Closed, // peer end is gone; the tunnel is down
};
struct Counters {
uint64_t tx_packets = 0;
uint64_t tx_bytes = 0;
uint64_t tx_dropped = 0;
uint64_t rx_packets = 0;
uint64_t rx_bytes = 0;
};
explicit PacketPipe(asio::io_context &io);
~PacketPipe();
PacketPipe(const PacketPipe &) = delete;
PacketPipe &operator=(const PacketPipe &) = delete;
// Creates the socketpair and adopts our end. `socket_buffer_bytes` is a
// request, not a guarantee: without CAP_NET_ADMIN the kernel clamps it to
// net.core.{r,w}mem_max, so the value actually granted is logged.
bool open(int socket_buffer_bytes, std::string *err);
bool is_open() const { return sock_.is_open(); }
// Transfers ownership of the far end to the caller (openvpn3). Returns -1 if
// already released or never opened. After this the pipe will not close that
// descriptor, because openvpn3's TunPersist will.
int release_peer_fd();
bool peer_released() const { return peer_released_; }
void close();
// Our end, for the netstack's async_receive loop.
Socket &socket() { return sock_; }
// Writes one IP packet. Never blocks: on a full peer queue the packet is
// dropped and counted, which is what a real NIC transmit ring does under
// congestion and what TCP is built to recover from. Queuing here instead
// would just add latency to a path that already has an SO_SNDBUF of queue.
SendStatus send_packet(const void *data, size_t len);
// The netstack owns the receive loop, so it reports what it read.
void note_received(size_t bytes);
Counters counters() const;
// Bytes the kernel actually granted, for logging and for sizing decisions.
int granted_sndbuf() const { return granted_sndbuf_; }
int granted_rcvbuf() const { return granted_rcvbuf_; }
private:
Socket sock_;
int peer_fd_ = -1;
bool peer_released_ = false;
int granted_sndbuf_ = 0;
int granted_rcvbuf_ = 0;
std::atomic<uint64_t> tx_packets_{0};
std::atomic<uint64_t> tx_bytes_{0};
std::atomic<uint64_t> tx_dropped_{0};
std::atomic<uint64_t> rx_packets_{0};
std::atomic<uint64_t> rx_bytes_{0};
};
} // namespace ovg::ovpn
+321
View File
@@ -0,0 +1,321 @@
#include "ovpn/profile_sanitizer.h"
#include <algorithm>
#include <cctype>
#include <set>
#include <unordered_set>
#include <fmt/format.h>
#include "vpngate/csv_parser.h"
namespace ovg::ovpn {
namespace {
std::string_view trim(std::string_view s) {
size_t b = 0, e = s.size();
while (b < e && std::isspace(static_cast<unsigned char>(s[b]))) ++b;
while (e > b && std::isspace(static_cast<unsigned char>(s[e - 1]))) --e;
return s.substr(b, e - b);
}
std::string lower(std::string_view s) {
std::string r(s);
std::transform(r.begin(), r.end(), r.begin(),
[](unsigned char c) { return std::tolower(c); });
return r;
}
// Directives removed for safety. Three families:
// - hooks that name a program to run,
// - process/host state we have no business changing (and, running as an
// unprivileged user, could not anyway),
// - transport redirection, which would move our packets somewhere the
// selector never measured.
const std::unordered_set<std::string_view> &denied() {
static const std::unordered_set<std::string_view> s = {
// Script hooks -- arbitrary command execution.
"up", "down", "down-pre", "up-delay", "up-restart", "route-up",
"route-pre-down", "ipchange", "tls-verify", "tls-export-cert",
"client-connect", "client-disconnect", "learn-address",
"auth-user-pass-verify", "script-security", "plugin", "askpass",
// Process and host state.
"daemon", "user", "group", "chroot", "cd", "setcon", "service",
"writepid", "log", "log-append", "status", "status-version", "iproute",
"register-dns", "dhcp-renew", "dhcp-release", "show-net-up", "win-sys",
"block-outside-dns",
// Management interface: an unauthenticated control socket by default.
"management", "management-client", "management-client-auth",
"management-client-pf", "management-client-user",
"management-client-group", "management-external-cert",
"management-external-key", "management-forget-disconnect",
"management-hold", "management-log-cache", "management-query-passwords",
"management-query-proxy", "management-query-remote", "management-signal",
"management-up-down",
// Transport redirection.
"http-proxy", "http-proxy-option", "http-proxy-retry",
"http-proxy-timeout", "socks-proxy", "socks-proxy-retry",
// Interface addressing: ours to decide, not the profile's. A local
// ifconfig here would fight the addresses the server pushes.
"ifconfig", "ifconfig-ipv6", "ifconfig-noexec", "dev-node", "dev-type",
// Server-side directive; meaningless (and confusing) in a client profile.
"push",
};
return s;
}
// Directives we drop from the input because we emit our own canonical version.
// Not reported as "dropped": that would be noise on every single profile.
const std::unordered_set<std::string_view> &regenerated() {
static const std::unordered_set<std::string_view> s = {
"client", "pull", "tls-client", "dev",
"nobind", "bind", "local", "lport",
"verb", "remote", "proto", "rport",
"port", "remote-random", "remote-random-hostname",
"resolv-retry", "persist-tun", "persist-key",
};
return s;
}
// Inline blocks worth keeping: keys, certificates and the pre-shared material
// that goes with them. Anything else is dropped whole -- notably <connection>,
// which is an alternative way to smuggle in remotes we did not score.
const std::unordered_set<std::string_view> &kept_blocks() {
static const std::unordered_set<std::string_view> s = {
"ca", "cert", "key", "extra-certs", "dh",
"tls-auth", "tls-crypt", "tls-crypt-v2", "secret", "pkcs12",
"crl-verify",
};
return s;
}
std::vector<std::string_view> tokenize(std::string_view line) {
std::vector<std::string_view> tok;
size_t i = 0;
while (i < line.size()) {
while (i < line.size() && std::isspace(static_cast<unsigned char>(line[i])))
++i;
const size_t start = i;
while (i < line.size() && !std::isspace(static_cast<unsigned char>(line[i])))
++i;
if (i > start) tok.push_back(line.substr(start, i - start));
}
return tok;
}
// A profile is text. Anything that is not printable, tab, CR or LF means we are
// looking at binary garbage (a truncated base64 decode, say), and passing it to
// an option parser is not something to do hopefully.
bool looks_like_text(const std::string &s, size_t *bad_offset) {
for (size_t i = 0; i < s.size(); ++i) {
const unsigned char c = static_cast<unsigned char>(s[i]);
if (c == '\t' || c == '\r' || c == '\n') continue;
if (c < 0x20 || c == 0x7f) {
*bad_offset = i;
return false;
}
}
return true;
}
} // namespace
bool is_denied_directive(std::string_view name) {
return denied().count(name) > 0;
}
bool is_kept_inline_block(std::string_view tag) {
return kept_blocks().count(tag) > 0;
}
bool sanitize_profile(const std::string &raw, const SanitizeOptions &opt,
SanitizedProfile *out, std::string *err) {
*out = SanitizedProfile{};
if (raw.empty()) {
if (err) *err = "empty profile";
return false;
}
if (raw.size() > opt.max_bytes) {
if (err)
*err = fmt::format("profile is {} bytes, limit is {}", raw.size(),
opt.max_bytes);
return false;
}
size_t bad = 0;
if (!looks_like_text(raw, &bad)) {
if (err)
*err = fmt::format("profile contains a control byte at offset {}", bad);
return false;
}
std::set<std::string> dropped;
std::string body;
body.reserve(raw.size());
bool in_block = false;
bool block_kept = false;
std::string block_tag;
size_t line_no = 0;
const std::string_view rv(raw);
size_t pos = 0;
while (pos <= rv.size()) {
const size_t nl = rv.find('\n', pos);
std::string_view line = rv.substr(
pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos);
pos = (nl == std::string_view::npos) ? rv.size() + 1 : nl + 1;
if (++line_no > opt.max_lines) {
if (err) *err = fmt::format("profile exceeds {} lines", opt.max_lines);
return false;
}
const std::string_view t = trim(line);
if (in_block) {
if (t.size() > 3 && t.rfind("</", 0) == 0 && t.back() == '>' &&
lower(t.substr(2, t.size() - 3)) == block_tag) {
if (block_kept) body.append("</").append(block_tag).append(">\n");
in_block = false;
block_kept = false;
block_tag.clear();
} else if (block_kept) {
// Payload lines pass through untouched apart from surrounding
// whitespace (a stray CR would otherwise land inside the PEM). No
// comment stripping: '#' is a legal byte in base64-adjacent data.
body.append(t);
body.push_back('\n');
}
continue;
}
if (t.empty() || t.front() == '#' || t.front() == ';') continue;
// Opening inline block?
if (t.front() == '<' && t.back() == '>' && t.rfind("</", 0) != 0) {
block_tag = lower(t.substr(1, t.size() - 2));
if (block_tag.empty()) continue;
in_block = true;
block_kept = is_kept_inline_block(block_tag);
if (block_kept) {
if (block_tag == "ca") out->has_ca = true;
if (block_tag == "cert" || block_tag == "pkcs12")
out->has_client_cert = true;
body.append("<").append(block_tag).append(">\n");
} else {
dropped.insert("<" + block_tag + ">");
}
continue;
}
const auto tok = tokenize(t);
if (tok.empty()) continue;
const std::string name = lower(tok[0]);
if (name == "dev" && tok.size() >= 2 &&
lower(tok[1]).rfind("tap", 0) == 0) {
// Layer 2 would hand us Ethernet frames; lwIP is wired up for layer 3
// and nothing downstream knows what to do with an ARP request. Rejecting
// here gives a clear reason instead of a confusing failure at tun setup.
if (err) *err = "profile requests a TAP (layer 2) device; only TUN is supported";
return false;
}
if (name == "ca" || name == "cert" || name == "key" ||
name == "pkcs12" || name == "tls-auth" || name == "tls-crypt" ||
name == "secret" || name == "dh" || name == "crl-verify" ||
name == "extra-certs") {
// File-reference form. We have no file to point at -- the profile
// arrived over HTTP as one blob -- so this cannot be honoured, and
// silently keeping it would make openvpn3 try to open a path.
dropped.insert(name + " (file reference)");
continue;
}
if (name == "auth-user-pass") {
// Credentials come from provide_creds(), never from a file on disk.
out->wants_userpass = true;
if (tok.size() >= 2) dropped.insert("auth-user-pass (file reference)");
body.append("auth-user-pass\n");
continue;
}
if (name == "peer-fingerprint") {
out->has_ca = true; // an alternative to a CA, and a stronger one
body.append(t);
body.push_back('\n');
continue;
}
if (!opt.allow_compression &&
(name == "comp-lzo" || name == "compress" || name == "comp-noadapt")) {
dropped.insert(name);
continue;
}
if (is_denied_directive(name)) {
dropped.insert(name);
continue;
}
if (regenerated().count(name)) continue;
body.append(t);
body.push_back('\n');
}
if (in_block) {
if (err) *err = "unterminated inline <" + block_tag + "> block";
return false;
}
// Which remote do we dial? The pinned one if the selector gave us one,
// otherwise whatever the profile declared (parsed by the same code the node
// list uses, so the two can never disagree).
if (opt.pin_remote) {
out->remotes.push_back(*opt.pin_remote);
} else {
out->remotes = vpngate::extract_remotes(raw);
}
if (out->remotes.empty()) {
if (err) *err = "profile declares no usable remote";
return false;
}
if (!out->has_ca) {
if (err) *err = "profile has neither an inline <ca> nor a peer-fingerprint";
return false;
}
const int verb = std::clamp(opt.verb, 0, 6);
std::string head;
head.reserve(256 + body.size());
head += "# sanitized by openvpngate -- see src/ovpn/profile_sanitizer.cpp\n";
head += "client\n";
head += "dev tun\n";
head += "nobind\n";
// No persist-key / resolv-retry / persist-tun here even though a hand-written
// client profile would carry them: openvpn3 does all three unconditionally
// and reports anything it did not consume as "Unsupported option (ignored)".
// Emitting them would put three warning lines in the log on every single
// connection, which is how logs stop being read.
head += fmt::format("verb {}\n", verb);
for (const auto &r : out->remotes) {
head += fmt::format("remote {} {} {}\n", r.host, r.port,
vpngate::proto_name(r.proto));
}
// A global proto as well: some option paths in the core consult it before
// the per-remote value, and the two agreeing costs nothing.
head += fmt::format("proto {}\n", vpngate::proto_name(out->remotes.front().proto));
out->text = head + body;
out->dropped.assign(dropped.begin(), dropped.end());
return true;
}
} // namespace ovg::ovpn
+69
View File
@@ -0,0 +1,69 @@
// Rewrites a VPNGate .ovpn profile into something we are willing to feed to
// openvpn3.
//
// The profiles come from the last column of a public, unauthenticated API and
// are authored by anonymous volunteers. Two separate problems follow from that:
//
// 1. Safety. An OpenVPN profile is a small programming language: `up`, `down`,
// `tls-verify`, `plugin` and friends name programs to execute, and
// `http-proxy` / `socks-proxy` redirect our transport somewhere of the
// profile's choosing. openvpn3 implements none of the script hooks, so
// today most of these are inert -- but "inert in the version we happen to
// link" is not a security property. They are stripped here so the guarantee
// holds regardless of what the core does with them tomorrow.
//
// 2. Determinism. The selector scores one specific remote. If the profile is
// passed through untouched, the core is free to pick any remote it lists,
// in any order, and the node we measured is not necessarily the node we
// connect to. Pinning the remote makes the measurement mean something.
//
// Everything else is kept verbatim. A denylist rather than an allowlist is
// deliberate: an allowlist would break the first time a volunteer's server
// pushed a directive we had not thought of, and the hazards here are a small,
// enumerable set.
#pragma once
#include <string>
#include <string_view>
#include <vector>
#include "vpngate/node.h"
namespace ovg::ovpn {
struct SanitizeOptions {
// The remote the selector chose. All remote/proto/port directives in the
// input are replaced by this one. Null keeps whatever the profile declares
// (used by the unit tests and by anyone connecting to a hand-written file).
const vpngate::Remote *pin_remote = nullptr;
bool allow_compression = true;
int verb = 3;
// Guards against a pathological or hostile row. A real profile is 3-8 KB.
size_t max_bytes = 512 * 1024;
size_t max_lines = 20000;
};
struct SanitizedProfile {
std::string text;
// Directive names removed, deduplicated and sorted. Logged once per
// connection: a name appearing here that we expected to keep is the first
// sign that a profile is doing something out of the ordinary.
std::vector<std::string> dropped;
std::vector<vpngate::Remote> remotes;
bool has_ca = false;
bool has_client_cert = false;
bool wants_userpass = false; // profile carries auth-user-pass
};
bool sanitize_profile(const std::string &raw, const SanitizeOptions &opt,
SanitizedProfile *out, std::string *err);
// Exposed for testing.
bool is_denied_directive(std::string_view name);
bool is_kept_inline_block(std::string_view tag);
} // namespace ovg::ovpn
+755
View File
@@ -0,0 +1,755 @@
#include "ovpn/tunnel_client.h"
#include <chrono>
#include <utility>
#include <fmt/format.h>
#include "common/logging.h"
#include "common/metrics.h"
#include "ovpn/profile_sanitizer.h"
#if OVG_WITH_TUNNEL
#include <client/ovpncli.hpp>
#endif
namespace ovg::ovpn {
namespace {
constexpr const char *kMod = "ovpn";
constexpr const char *kCoreMod = "ovpn3";
// A pushed route list is not load-bearing for us -- every packet goes to the
// tunnel netif regardless -- so we keep a bounded sample for the admin
// endpoint and let the rest go.
constexpr size_t kMaxCapturedRoutes = 64;
metrics::Counter *starts() {
static auto *c = metrics::counter("ovg_tunnel_starts_total",
"OpenVPN sessions started");
return c;
}
metrics::Counter *ups() {
static auto *c = metrics::counter("ovg_tunnel_up_total",
"OpenVPN sessions that reached CONNECTED");
return c;
}
metrics::Counter *failures() {
static auto *c = metrics::counter(
"ovg_tunnel_failures_total",
"OpenVPN sessions that ended without ever reaching CONNECTED");
return c;
}
#if OVG_WITH_TUNNEL
metrics::Counter *reconnects() {
static auto *c = metrics::counter("ovg_tunnel_reconnects_total",
"In-session reconnects reported by the core");
return c;
}
#endif
metrics::Gauge *active() {
static auto *g = metrics::gauge("ovg_tunnels_active",
"OpenVPN sessions currently up");
return g;
}
} // namespace
const char *tunnel_state_name(TunnelState s) {
switch (s) {
case TunnelState::Idle: return "idle";
case TunnelState::Connecting: return "connecting";
case TunnelState::Up: return "up";
case TunnelState::Reconnecting: return "reconnecting";
case TunnelState::Down: return "down";
}
return "?";
}
// ---------------------------------------------------------------------------
// Impl: everything that touches openvpn3.
// ---------------------------------------------------------------------------
#if OVG_WITH_TUNNEL
class TunnelClient::Impl : public openvpn::ClientAPI::OpenVPNClient {
public:
Impl(std::weak_ptr<TunnelClient> owner, PacketPipe *pipe, OvpnConfig cfg,
std::string node_id)
: owner_(std::move(owner)),
pipe_(pipe),
cfg_(std::move(cfg)),
node_id_(std::move(node_id)) {}
// Worker thread body.
void run(openvpn::ClientAPI::Config cc);
// Safe from any thread, before or after connect() is running.
void request_stop() {
stop_requested_.store(true, std::memory_order_relaxed);
if (connecting_.load(std::memory_order_acquire)) stop();
}
TunnelCounters counters() const;
bool ever_up() const { return ever_up_.load(std::memory_order_relaxed); }
// --- TunBuilderBase -----------------------------------------------------
bool tun_builder_new() override;
bool tun_builder_set_layer(int layer) override;
bool tun_builder_set_remote_address(const std::string &address,
bool ipv6) override;
bool tun_builder_add_address(const std::string &address, int prefix_length,
const std::string &gateway, bool ipv6,
bool net30) override;
bool tun_builder_reroute_gw(bool ipv4, bool ipv6, unsigned int flags) override;
bool tun_builder_add_route(const std::string &address, int prefix_length,
int metric, bool ipv6) override;
bool tun_builder_exclude_route(const std::string &address, int prefix_length,
int metric, bool ipv6) override;
bool tun_builder_set_dns_options(const openvpn::DnsOptions &dns) override;
bool tun_builder_set_mtu(int mtu) override;
bool tun_builder_set_session_name(const std::string &name) override;
bool tun_builder_add_proxy_bypass(const std::string &host) override;
bool tun_builder_set_proxy_auto_config_url(const std::string &url) override;
bool tun_builder_set_proxy_http(const std::string &host, int port) override;
bool tun_builder_set_proxy_https(const std::string &host, int port) override;
bool tun_builder_add_wins_server(const std::string &address) override;
bool tun_builder_set_route_metric_default(int metric) override { return true; }
bool tun_builder_set_allow_family(int af, bool allow) override { return true; }
bool tun_builder_set_allow_local_dns(bool allow) override { return true; }
int tun_builder_establish() override;
bool tun_builder_persist() override { return true; }
void tun_builder_establish_lite() override;
void tun_builder_teardown(bool disconnect) override;
std::vector<std::string> tun_builder_get_local_networks(bool ipv6) override {
return {};
}
// --- OpenVPNClient ------------------------------------------------------
void event(const openvpn::ClientAPI::Event &ev) override;
void acc_event(const openvpn::ClientAPI::AppCustomControlMessageEvent &ev)
override;
void log(const openvpn::ClientAPI::LogInfo &li) override;
void external_pki_cert_request(
openvpn::ClientAPI::ExternalPKICertRequest &req) override;
void external_pki_sign_request(
openvpn::ClientAPI::ExternalPKISignRequest &req) override;
// We would rather fail over to one of the ninety-odd other nodes than sit
// in PAUSE on a server that has stopped answering.
bool pause_on_connection_timeout() override { return false; }
// Nothing to protect. The classic reason for this callback is that the
// client rewrites the host routing table, so the transport socket to the VPN
// server would otherwise route back into the tunnel it is carrying. We never
// touch host routing -- the tunnel lives entirely in this process -- so the
// kernel routes this socket like any other and no loop is possible.
bool socket_protect(openvpn_io::detail::socket_type socket, std::string remote,
bool ipv6) override {
return true;
}
private:
void emit(TunnelState s, std::string detail, bool with_info);
std::weak_ptr<TunnelClient> owner_;
PacketPipe *pipe_;
OvpnConfig cfg_;
std::string node_id_;
mutable std::mutex mu_; // guards pending_
TunnelInfo pending_;
int establish_count_ = 0;
size_t route_count_ = 0;
std::atomic<bool> stop_requested_{false};
std::atomic<bool> connecting_{false};
std::atomic<bool> ever_up_{false};
};
void TunnelClient::Impl::emit(TunnelState s, std::string detail,
bool with_info) {
TunnelInfo snapshot;
if (with_info) {
std::lock_guard lk(mu_);
snapshot = pending_;
}
if (auto owner = owner_.lock())
owner->post_state(s, std::move(snapshot), std::move(detail));
}
void TunnelClient::Impl::run(openvpn::ClientAPI::Config cc) {
using openvpn::ClientAPI::EvalConfig;
using openvpn::ClientAPI::ProvideCreds;
using openvpn::ClientAPI::Status;
try {
const EvalConfig eval = eval_config(cc);
if (eval.error) {
emit(TunnelState::Down, "profile rejected: " + eval.message, false);
return;
}
LOG_DEBUG(kMod, "{}: profile ok, remote={}:{}/{} autologin={}", node_id_,
eval.remoteHost, eval.remotePort, eval.remoteProto,
eval.autologin);
if (!eval.autologin) {
// Most VPNGate profiles carry the shared client certificate and need no
// credentials at all; the ones that do accept anything, and the operator
// can override the pair in [ovpn] if some node ever gets picky.
ProvideCreds creds;
creds.username = cfg_.username;
creds.password = cfg_.password;
const Status cs = provide_creds(creds);
if (cs.error) {
emit(TunnelState::Down, "credentials rejected: " + cs.message, false);
return;
}
}
if (stop_requested_.load(std::memory_order_relaxed)) {
emit(TunnelState::Down, "stopped before connect", false);
return;
}
connecting_.store(true, std::memory_order_release);
if (stop_requested_.load(std::memory_order_relaxed)) stop();
const Status st = connect(); // blocks for the life of the session
std::string why;
if (st.error)
why = st.status.empty() ? st.message : st.status + ": " + st.message;
else
why = "session ended";
emit(TunnelState::Down, std::move(why), false);
} catch (const std::exception &e) {
emit(TunnelState::Down, std::string("openvpn3 threw: ") + e.what(), false);
} catch (...) {
emit(TunnelState::Down, "openvpn3 threw a non-standard exception", false);
}
}
TunnelCounters TunnelClient::Impl::counters() const {
TunnelCounters c;
if (!connecting_.load(std::memory_order_acquire)) return c;
try {
const auto ts = transport_stats();
const auto is = tun_stats();
c.transport_bytes_in = ts.bytesIn;
c.transport_bytes_out = ts.bytesOut;
c.tun_bytes_in = is.bytesIn;
c.tun_bytes_out = is.bytesOut;
// The core counts in "binary milliseconds" (1/1024 s); convert so callers
// are not quietly 2.4% out when they compare against a wall-clock budget.
c.last_packet_received_ms =
ts.lastPacketReceived < 0
? -1
: static_cast<int>((static_cast<int64_t>(ts.lastPacketReceived) *
1000) / 1024);
c.valid = true;
} catch (const std::exception &e) {
LOG_DEBUG(kMod, "{}: stats unavailable: {}", node_id_, e.what());
}
return c;
}
bool TunnelClient::Impl::tun_builder_new() {
std::lock_guard lk(mu_);
pending_ = TunnelInfo{};
route_count_ = 0;
return true;
}
bool TunnelClient::Impl::tun_builder_set_layer(int layer) {
if (layer != 3) {
LOG_WARN(kMod, "{}: server wants OSI layer {}; only layer 3 is supported",
node_id_, layer);
return false;
}
return true;
}
bool TunnelClient::Impl::tun_builder_set_remote_address(
const std::string &address, bool ipv6) {
std::lock_guard lk(mu_);
pending_.server_ip = address;
return true;
}
bool TunnelClient::Impl::tun_builder_add_address(const std::string &address,
int prefix_length,
const std::string &gateway,
bool ipv6, bool net30) {
std::lock_guard lk(mu_);
if (ipv6) {
pending_.ipv6 = address;
pending_.prefix6 = prefix_length;
} else {
pending_.ipv4 = address;
pending_.prefix4 = prefix_length;
pending_.gateway4 = gateway;
}
return true;
}
bool TunnelClient::Impl::tun_builder_reroute_gw(bool ipv4, bool ipv6,
unsigned int flags) {
std::lock_guard lk(mu_);
pending_.redirect_gateway = ipv4 || ipv6;
return true;
}
bool TunnelClient::Impl::tun_builder_add_route(const std::string &address,
int prefix_length, int metric,
bool ipv6) {
std::lock_guard lk(mu_);
if (++route_count_ <= kMaxCapturedRoutes)
pending_.routes.push_back(address + "/" + std::to_string(prefix_length));
return true;
}
bool TunnelClient::Impl::tun_builder_exclude_route(const std::string &address,
int prefix_length,
int metric, bool ipv6) {
// There is no host routing table for an excluded route to be excluded from:
// every packet the netstack produces goes to the tunnel by construction.
return true;
}
bool TunnelClient::Impl::tun_builder_set_dns_options(
const openvpn::DnsOptions &dns) {
std::lock_guard lk(mu_);
pending_.dns.clear();
// std::map<int, DnsServer> keyed by priority, so iteration is already in the
// order the server intended.
for (const auto &[priority, server] : dns.servers) {
for (const auto &a : server.addresses) {
if (!a.address.empty()) pending_.dns.push_back(a.address);
}
}
return true;
}
bool TunnelClient::Impl::tun_builder_set_mtu(int mtu) {
std::lock_guard lk(mu_);
if (mtu <= 0 || static_cast<size_t>(mtu) > kMaxPacketSize) {
// Not fatal -- the default is workable, and refusing here would throw away
// an otherwise fine node over one bad pushed option.
LOG_WARN(kMod, "{}: server pushed MTU {}, outside [1, {}]; keeping {}",
node_id_, mtu, kMaxPacketSize, pending_.mtu);
return true;
}
pending_.mtu = mtu;
return true;
}
bool TunnelClient::Impl::tun_builder_set_session_name(const std::string &name) {
std::lock_guard lk(mu_);
pending_.session_name = name;
return true;
}
bool TunnelClient::Impl::tun_builder_add_proxy_bypass(const std::string &host) {
return true; // no system proxy to bypass
}
bool TunnelClient::Impl::tun_builder_set_proxy_auto_config_url(
const std::string &url) {
// Returning false here would abort the connection over a setting we simply
// do not implement, so it is ignored -- but loudly, because a server pushing
// a PAC file is asking to see our plaintext HTTP.
LOG_WARN(kMod, "{}: ignoring pushed proxy auto-config URL {}", node_id_, url);
return true;
}
bool TunnelClient::Impl::tun_builder_set_proxy_http(const std::string &host,
int port) {
LOG_WARN(kMod, "{}: ignoring pushed HTTP proxy {}:{}", node_id_, host, port);
return true;
}
bool TunnelClient::Impl::tun_builder_set_proxy_https(const std::string &host,
int port) {
LOG_WARN(kMod, "{}: ignoring pushed HTTPS proxy {}:{}", node_id_, host, port);
return true;
}
bool TunnelClient::Impl::tun_builder_add_wins_server(
const std::string &address) {
return true; // Windows name resolution; nothing here consumes it
}
int TunnelClient::Impl::tun_builder_establish() {
if (++establish_count_ > 1) {
// The core only re-establishes when the pushed tunnel configuration has
// changed -- a different address, prefix or MTU. Every lwIP PCB behind
// this pipe is bound to the old address, so there is nothing to salvage
// even if we handed over a fresh socketpair: the netstack would have to be
// rebuilt anyway. Failing here turns that into an ordinary node failure
// that the egress layer already knows how to handle (make-before-break to
// a freshly built tunnel), instead of a half-migrated stack.
LOG_WARN(kMod,
"{}: openvpn3 asked for a second tun descriptor (pushed config "
"changed); ending the session so the egress layer rebuilds it",
node_id_);
return -1;
}
const int fd = pipe_->release_peer_fd();
if (fd < 0) {
LOG_ERROR(kMod, "{}: packet pipe has no descriptor to hand over", node_id_);
return -1;
}
LOG_DEBUG(kMod, "{}: handed tun fd {} to openvpn3", node_id_, fd);
return fd;
}
void TunnelClient::Impl::tun_builder_establish_lite() {
LOG_INFO(kMod, "{}: reconnected with the tun kept in place", node_id_);
}
void TunnelClient::Impl::tun_builder_teardown(bool disconnect) {
LOG_DEBUG(kMod, "{}: tun teardown (disconnect={})", node_id_, disconnect);
}
void TunnelClient::Impl::event(const openvpn::ClientAPI::Event &ev) {
if (ev.error)
LOG_WARN(kCoreMod, "{}: {}{}{}{}", node_id_, ev.fatal ? "fatal " : "",
ev.name, ev.info.empty() ? "" : ": ", ev.info);
else
LOG_DEBUG(kCoreMod, "{}: {}{}{}", node_id_, ev.name,
ev.info.empty() ? "" : " ", ev.info);
if (ev.name == "CONNECTED") {
const auto ci = connection_info();
{
std::lock_guard lk(mu_);
if (pending_.server_ip.empty()) pending_.server_ip = ci.serverIp;
if (pending_.session_name.empty()) pending_.session_name = ci.tunName;
}
ever_up_.store(true, std::memory_order_relaxed);
emit(TunnelState::Up, "", true);
return;
}
if (ev.name == "RECONNECTING") {
reconnects()->inc();
emit(TunnelState::Reconnecting, ev.info, false);
return;
}
if (ev.fatal) {
emit(TunnelState::Down,
ev.info.empty() ? ev.name : ev.name + ": " + ev.info, false);
}
// DISCONNECTED is not special-cased: connect() is about to return and run()
// posts the terminal state with the full status attached.
}
void TunnelClient::Impl::acc_event(
const openvpn::ClientAPI::AppCustomControlMessageEvent &ev) {
LOG_DEBUG(kCoreMod, "{}: app control message on {} ({} bytes)", node_id_,
ev.protocol, ev.payload.size());
}
void TunnelClient::Impl::log(const openvpn::ClientAPI::LogInfo &li) {
std::string_view s(li.text);
while (!s.empty() && (s.back() == '\n' || s.back() == '\r'))
s.remove_suffix(1);
if (!s.empty()) LOG_DEBUG(kCoreMod, "{}", s);
}
void TunnelClient::Impl::external_pki_cert_request(
openvpn::ClientAPI::ExternalPKICertRequest &req) {
req.error = true;
req.errorText = "external PKI is not supported: profiles must carry an "
"inline <cert>/<key>";
}
void TunnelClient::Impl::external_pki_sign_request(
openvpn::ClientAPI::ExternalPKISignRequest &req) {
req.error = true;
req.errorText = "external PKI is not supported";
}
bool TunnelClient::supported() { return true; }
bool TunnelClient::launch(std::string *err) {
openvpn::ClientAPI::Config cc;
cc.content = profile_;
cc.guiVersion = "openvpngate 0.1.0";
// Fail the whole attempt rather than retrying forever: with a list of
// ninety-odd nodes, moving on beats waiting.
cc.connTimeout = cfg_.connect_timeout_s;
// Keeps the tun fd -- and so the netstack and every session behind it --
// alive across ping-restart reconnects inside one session.
cc.tunPersist = true;
cc.googleDnsFallback = true;
cc.autologinSessions = true;
cc.retryOnAuthFailed = false;
cc.dco = false; // kernel data-channel offload needs root and a module
cc.allowLocalLanAccess = true;
cc.synchronousDnsLookup = false;
cc.clockTickMS = 0;
cc.info = true;
cc.echo = false;
cc.sslDebugLevel = 0;
// "asym" means the server may compress what it sends us but we never
// compress what we send. Compressing attacker-influenced plaintext next to
// secrets is what made VORACLE work; this keeps the interop win without it.
cc.compressionMode = cfg_.compression ? "asym" : "no";
// VPNGate is a wall of AES-128-CBC + SHA1 with occasional 1024-bit RSA. The
// modern defaults reject all of it, so without these the node list is
// effectively empty. See docs/FEASIBILITY.md 2.4: the tunnel is treated as
// an untrusted transport regardless of cipher, because the operator on the
// far end is an anonymous volunteer who can see the plaintext either way.
cc.enableNonPreferredDCAlgorithms = true;
cc.enableLegacyAlgorithms = cfg_.allow_legacy_algorithms;
if (cfg_.allow_legacy_algorithms) {
cc.tlsCertProfileOverride = "legacy";
cc.tlsVersionMinOverride = "tls_1_0";
}
impl_ = std::make_unique<Impl>(weak_from_this(), &pipe_, cfg_, node_id_);
std::weak_ptr<TunnelClient> weak = weak_from_this();
Impl *impl = impl_.get();
try {
worker_ = std::thread([weak, impl, cc = std::move(cc)]() mutable {
impl->run(std::move(cc));
// Last thing the thread does: tell the owner it is safe to join.
if (auto self = weak.lock()) self->post_worker_finished();
});
} catch (const std::system_error &e) {
impl_.reset();
if (err) *err = std::string("cannot start openvpn worker thread: ") + e.what();
return false;
}
return true;
}
#else // !OVG_WITH_TUNNEL
// Placeholder so the class layout, the link graph and every caller stay
// identical between the two builds. Only start() behaves differently.
class TunnelClient::Impl {
public:
void request_stop() {}
TunnelCounters counters() const { return {}; }
bool ever_up() const { return false; }
};
bool TunnelClient::supported() { return false; }
bool TunnelClient::launch(std::string *err) {
if (err)
*err = "this build has no openvpn3 linked in (-DOVG_WITH_TUNNEL=OFF); "
"only egress_mode=direct works";
return false;
}
#endif // OVG_WITH_TUNNEL
// ---------------------------------------------------------------------------
// TunnelClient: the io_context-facing half, identical in both builds.
// ---------------------------------------------------------------------------
std::shared_ptr<TunnelClient> TunnelClient::create(asio::io_context &io,
OvpnConfig cfg) {
return std::shared_ptr<TunnelClient>(new TunnelClient(io, std::move(cfg)));
}
TunnelClient::TunnelClient(asio::io_context &io, OvpnConfig cfg)
: io_(io), cfg_(std::move(cfg)), pipe_(io), up_timer_(io) {}
TunnelClient::~TunnelClient() {
if (impl_) impl_->request_stop();
if (worker_.joinable()) {
if (worker_.get_id() == std::this_thread::get_id()) {
// Only reachable if someone let the last reference die inside an
// openvpn3 callback. Joining would deadlock, so leak deliberately and
// say so: a leaked thread is recoverable, a self-join is not.
LOG_ERROR(kMod, "{}: destroyed from its own worker thread; leaking it",
node_id_);
worker_.detach();
(void)impl_.release();
return;
}
worker_.join();
}
}
bool TunnelClient::start(const vpngate::Node &node,
const vpngate::Remote &remote, StateHandler on_state,
std::string *err) {
{
std::lock_guard lk(mu_);
if (state_ != TunnelState::Idle) {
if (err) *err = "tunnel client already started";
return false;
}
}
node_id_ = node.id();
remote_ = remote;
SanitizeOptions so;
so.pin_remote = &remote;
so.allow_compression = cfg_.compression;
SanitizedProfile sp;
if (!sanitize_profile(node.profile, so, &sp, err)) {
if (err) *err = node_id_ + ": " + *err;
return false;
}
profile_ = std::move(sp.text);
if (!sp.dropped.empty()) {
LOG_DEBUG(kMod, "{}: dropped {} directive(s) from the profile: {}",
node_id_, sp.dropped.size(), fmt::join(sp.dropped, ", "));
}
if (!sp.has_client_cert) {
LOG_DEBUG(kMod, "{}: profile has no client certificate; expecting "
"username/password auth", node_id_);
}
if (!pipe_.open(cfg_.packet_socket_buffer, err)) {
if (err) *err = node_id_ + ": " + *err;
return false;
}
{
std::lock_guard lk(mu_);
on_state_ = std::move(on_state);
state_ = TunnelState::Connecting;
}
if (!launch(err)) {
pipe_.close();
std::lock_guard lk(mu_);
state_ = TunnelState::Idle;
on_state_ = nullptr;
return false;
}
starts()->inc();
LOG_INFO(kMod, "{}: connecting to {}:{}/{}", node_id_, remote.host,
remote.port, vpngate::proto_name(remote.proto));
arm_up_timer();
return true;
}
void TunnelClient::stop(std::function<void()> on_stopped) {
bool already_done = false;
{
std::lock_guard lk(mu_);
already_done = worker_finished_;
if (!already_done && on_stopped) on_stopped_ = std::move(on_stopped);
}
cancel_up_timer();
if (already_done) {
if (on_stopped) asio::post(io_, std::move(on_stopped));
return;
}
if (impl_) impl_->request_stop();
}
TunnelState TunnelClient::state() const {
std::lock_guard lk(mu_);
return state_;
}
TunnelInfo TunnelClient::info() const {
std::lock_guard lk(mu_);
return info_;
}
TunnelCounters TunnelClient::counters() const {
if (!impl_) return {};
return impl_->counters();
}
void TunnelClient::post_state(TunnelState s, TunnelInfo info,
std::string detail) {
{
std::lock_guard lk(mu_);
if (terminal_seen_) return; // Down is final; later noise is dropped
if (s == TunnelState::Down) terminal_seen_ = true;
if (state_ == TunnelState::Up && s != TunnelState::Up) active()->sub(1);
if (state_ != TunnelState::Up && s == TunnelState::Up) active()->add(1);
state_ = s;
if (s == TunnelState::Up) info_ = info;
}
auto self = shared_from_this();
asio::post(io_, [self, s, info = std::move(info),
detail = std::move(detail)]() mutable {
if (s == TunnelState::Up || s == TunnelState::Down) self->cancel_up_timer();
switch (s) {
case TunnelState::Up:
ups()->inc();
LOG_INFO(kMod, "{}: up -- {}/{} via {}, mtu {}, dns [{}]",
self->node_id_, info.ipv4, info.prefix4, info.server_ip,
info.mtu, fmt::join(info.dns, ", "));
break;
case TunnelState::Reconnecting:
LOG_WARN(kMod, "{}: reconnecting{}{}", self->node_id_,
detail.empty() ? "" : " -- ", detail);
break;
case TunnelState::Down: {
const bool never_up = !(self->impl_ && self->impl_->ever_up());
if (never_up) failures()->inc();
LOG_INFO(kMod, "{}: down -- {}", self->node_id_, detail);
break;
}
default:
break;
}
StateHandler h;
{
std::lock_guard lk(self->mu_);
h = self->on_state_;
}
if (h) h(s, info, detail);
});
}
void TunnelClient::post_worker_finished() {
std::function<void()> cb;
{
std::lock_guard lk(mu_);
worker_finished_ = true;
cb.swap(on_stopped_);
}
auto self = shared_from_this();
asio::post(io_, [self, cb = std::move(cb)] {
if (cb) cb();
});
}
void TunnelClient::arm_up_timer() {
if (cfg_.tunnel_up_timeout_s <= 0) return;
up_timer_.expires_after(std::chrono::seconds(cfg_.tunnel_up_timeout_s));
auto self = shared_from_this();
up_timer_.async_wait([self](const std::error_code &ec) {
if (ec) return; // cancelled: we came up, or we are already down
// openvpn3's own connTimeout covers the transport handshake, but a server
// can complete that and then never push a tunnel config. This is the
// backstop for that case.
self->post_state(
TunnelState::Down, TunnelInfo{},
fmt::format("no tunnel after {}s", self->cfg_.tunnel_up_timeout_s));
self->stop();
});
}
void TunnelClient::cancel_up_timer() { up_timer_.cancel(); }
} // namespace ovg::ovpn
+159
View File
@@ -0,0 +1,159 @@
// One OpenVPN session, wrapped so the rest of the program never sees openvpn3.
//
// Threading. ClientAPI::OpenVPNClient::connect() blocks until the session ends
// and makes all of its callbacks -- events, logs, every tun_builder_* call --
// from the thread that called it. So each TunnelClient owns exactly one worker
// thread, and every observable effect is marshalled back onto the io_context
// the object was constructed with. Callers only ever see io_context threads.
//
// Lifetime. Always hold a TunnelClient through the shared_ptr that create()
// returns, and prefer stop(on_stopped) to simply dropping the last reference:
// the destructor has to join the worker, and joining from inside an io_context
// handler stalls that thread for as long as openvpn3 takes to unwind.
//
// The tun. The session's tun descriptor is one end of a socketpair (see
// packet_pipe.h). It is created before the worker starts, so pipe() is valid
// from the moment start() returns true -- the netstack can attach its read
// loop immediately and does not have to race the CONNECTED event.
#pragma once
#include <asio.hpp>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "common/config.h"
#include "ovpn/packet_pipe.h"
#include "vpngate/node.h"
namespace ovg::ovpn {
enum class TunnelState {
Idle, // constructed, start() not called (or it failed)
Connecting, // worker running, no tunnel yet
Up, // server pushed a config; packets can flow
Reconnecting, // transient loss; the tun fd survives (tunPersist)
Down, // finished for good -- `detail` says why
};
const char *tunnel_state_name(TunnelState s);
// What the server pushed. Everything the netstack needs to bring up a netif,
// and nothing it does not.
struct TunnelInfo {
std::string ipv4;
int prefix4 = 0;
std::string gateway4;
std::string ipv6;
int prefix6 = 0;
int mtu = 1500;
std::vector<std::string> dns; // in server priority order
std::vector<std::string> routes; // "10.0.0.0/8", capped; diagnostics only
std::string server_ip; // the VPN server we are talking to
std::string session_name;
bool redirect_gateway = false;
bool usable() const { return !ipv4.empty() && prefix4 > 0; }
};
struct TunnelCounters {
int64_t transport_bytes_in = 0;
int64_t transport_bytes_out = 0;
int64_t tun_bytes_in = 0;
int64_t tun_bytes_out = 0;
// Milliseconds since the last packet arrived from the server, or -1 if none
// ever has. This is the single most useful liveness signal we get from the
// core, and the health monitor's stall detector is built on it.
int last_packet_received_ms = -1;
bool valid = false;
};
class TunnelClient : public std::enable_shared_from_this<TunnelClient> {
public:
// Runs on `io`. For Up, `info` is populated; otherwise it is empty and
// `detail` carries the reason.
using StateHandler = std::function<void(TunnelState state,
const TunnelInfo &info,
const std::string &detail)>;
static std::shared_ptr<TunnelClient> create(asio::io_context &io,
OvpnConfig cfg);
~TunnelClient();
TunnelClient(const TunnelClient &) = delete;
TunnelClient &operator=(const TunnelClient &) = delete;
// Sanitizes the node's profile, opens the packet pipe, starts the worker.
// Returns false without starting anything if the profile is unusable, which
// is a node-level failure the selector should record, not a fatal error.
bool start(const vpngate::Node &node, const vpngate::Remote &remote,
StateHandler on_state, std::string *err);
// Idempotent and asynchronous. `on_stopped` runs on the io_context once the
// worker has exited and the object is safe to destroy.
void stop(std::function<void()> on_stopped = {});
TunnelState state() const;
TunnelInfo info() const;
TunnelCounters counters() const;
const std::string &node_id() const { return node_id_; }
const vpngate::Remote &remote() const { return remote_; }
// The netstack's end of the tun. Valid from a successful start() until the
// object is destroyed.
PacketPipe &pipe() { return pipe_; }
// The profile actually handed to openvpn3, after sanitizing. Kept for the
// admin endpoint: when a node fails to connect, this is the first thing
// anyone will want to look at.
const std::string &sanitized_profile() const { return profile_; }
// True when the build has openvpn3 linked in. When false, start() always
// fails and the only usable egress is "direct".
static bool supported();
private:
class Impl;
friend class Impl;
TunnelClient(asio::io_context &io, OvpnConfig cfg);
// Builds the openvpn3 client and starts the worker. Split out from start()
// so the profile handling above it is shared with builds that have no
// openvpn3 linked in.
bool launch(std::string *err);
// Called from the worker thread.
void post_state(TunnelState s, TunnelInfo info, std::string detail);
void post_worker_finished();
void arm_up_timer();
void cancel_up_timer();
asio::io_context &io_;
OvpnConfig cfg_;
PacketPipe pipe_;
asio::steady_timer up_timer_;
std::string node_id_;
vpngate::Remote remote_;
std::string profile_;
mutable std::mutex mu_; // guards state_, info_, on_state_, on_stopped_
TunnelState state_ = TunnelState::Idle;
TunnelInfo info_;
StateHandler on_state_;
std::function<void()> on_stopped_;
bool terminal_seen_ = false;
bool worker_finished_ = false;
std::unique_ptr<Impl> impl_;
std::thread worker_;
};
} // namespace ovg::ovpn