forked from cloud/ovgate
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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> ®enerated() {
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user