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,153 @@
|
||||
// The way out. Everything above this line -- SOCKS5, health, admin -- programs
|
||||
// against this interface and nothing else.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Why the whole design hangs on this being a shared_ptr
|
||||
// ---------------------------------------------------------------------------
|
||||
// A SOCKS5 session takes a reference at accept time and holds it until it ends.
|
||||
// That single rule buys three things that would otherwise each need their own
|
||||
// machinery:
|
||||
//
|
||||
// * A session can never be re-homed underneath itself. The egress it started
|
||||
// on is the egress it finishes on, so there is no window where a half-sent
|
||||
// request goes out one tunnel and its continuation out another.
|
||||
// * Draining needs no session table. The reference count *is* the number of
|
||||
// sessions still using this egress; when it hits zero the destructor closes
|
||||
// the tunnel. Nothing to iterate, nothing to leak.
|
||||
// * The switch is a pointer swap. Promote = store a new shared_ptr in the
|
||||
// manager; every acquire() after that gets the new one, every session
|
||||
// before it is untouched.
|
||||
//
|
||||
// docs/ARCHITECTURE.md §5.2.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Threading
|
||||
// ---------------------------------------------------------------------------
|
||||
// Every method is safe to call from any thread. Each async method takes the
|
||||
// executor its handler must run on, and so does everything the handler hands
|
||||
// back: a stream created with a session's strand delivers all of its own
|
||||
// completions on that strand too.
|
||||
//
|
||||
// Passing the executor explicitly rather than fixing it at construction is what
|
||||
// lets one egress serve a thousand sessions on four io threads while each
|
||||
// session still sees a single-threaded world. Implementations that need
|
||||
// internal serialization -- the tunnel one does, because lwIP does -- arrange
|
||||
// that privately and never leak it to the caller.
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/endpoint.h"
|
||||
#include "netstack/stream.h"
|
||||
|
||||
namespace ovg::egress {
|
||||
|
||||
using netstack::TcpStreamPtr;
|
||||
using netstack::UdpSocketPtr;
|
||||
|
||||
enum class EgressState {
|
||||
Idle, // constructed, start() not called
|
||||
Connecting, // bringing the tunnel up; no traffic yet
|
||||
Ready, // usable
|
||||
Draining, // still serving existing sessions, refusing new ones
|
||||
Down, // unusable; `detail()` says why
|
||||
};
|
||||
const char *egress_state_name(EgressState s);
|
||||
|
||||
struct EgressStats {
|
||||
// Identity.
|
||||
std::string node_id;
|
||||
std::string node_country;
|
||||
std::string server_ip;
|
||||
std::string local_address; // our address inside the tunnel
|
||||
std::string proto; // "udp" / "tcp" / "direct"
|
||||
|
||||
// Age and use.
|
||||
int64_t uptime_ms = 0;
|
||||
int64_t sessions = 0; // shared_ptr use_count minus the manager's own refs
|
||||
|
||||
// Traffic, as seen at the tunnel. Zero for the direct egress, which has no
|
||||
// single aggregate to report.
|
||||
uint64_t tun_bytes_in = 0;
|
||||
uint64_t tun_bytes_out = 0;
|
||||
uint64_t transport_bytes_in = 0;
|
||||
uint64_t transport_bytes_out = 0;
|
||||
int last_packet_received_ms = -1; // -1 = never
|
||||
|
||||
// Connection outcomes, since construction.
|
||||
uint64_t tcp_opened = 0;
|
||||
uint64_t tcp_failed = 0;
|
||||
int64_t tcp_active = 0;
|
||||
int64_t udp_active = 0;
|
||||
|
||||
// Packet-level health, from the netstack. All zero for direct.
|
||||
uint64_t rx_packets = 0;
|
||||
uint64_t tx_packets = 0;
|
||||
uint64_t tx_dropped = 0;
|
||||
uint64_t rx_malformed = 0;
|
||||
uint64_t rx_dropped = 0;
|
||||
|
||||
double connect_failure_rate() const {
|
||||
const uint64_t total = tcp_opened + tcp_failed;
|
||||
return total == 0 ? 0.0 : static_cast<double>(tcp_failed) / total;
|
||||
}
|
||||
};
|
||||
|
||||
class Egress {
|
||||
public:
|
||||
using ConnectHandler =
|
||||
std::function<void(const std::error_code &, TcpStreamPtr)>;
|
||||
using UdpBindHandler =
|
||||
std::function<void(const std::error_code &, UdpSocketPtr)>;
|
||||
using ResolveHandler =
|
||||
std::function<void(const std::error_code &, std::vector<IpAddress>)>;
|
||||
|
||||
virtual ~Egress() = default;
|
||||
|
||||
// `target` may be a domain: resolving it here rather than at the caller is
|
||||
// what keeps DNS inside the tunnel. A literal address skips the lookup.
|
||||
virtual void async_connect_tcp(const asio::any_io_executor &ex,
|
||||
const Endpoint &target, Millis timeout,
|
||||
ConnectHandler h) = 0;
|
||||
|
||||
// An outbound datagram socket on this egress, for SOCKS5 UDP ASSOCIATE.
|
||||
virtual void async_bind_udp(const asio::any_io_executor &ex,
|
||||
UdpBindHandler h) = 0;
|
||||
|
||||
// Exposed separately from connect because SOCKS5 needs the resolved address
|
||||
// for the BND field in the reply, and the health monitor times a lookup as
|
||||
// its in-tunnel RTT probe.
|
||||
virtual void async_resolve(const asio::any_io_executor &ex,
|
||||
const std::string &host, ResolveHandler h) = 0;
|
||||
|
||||
virtual EgressState state() const = 0;
|
||||
virtual EgressStats stats() const = 0;
|
||||
|
||||
// Human-readable reason for Down, or an empty string.
|
||||
virtual std::string detail() const = 0;
|
||||
|
||||
// Stops accepting new work. Existing streams keep running: this is the state
|
||||
// an egress sits in between being replaced and its last session ending.
|
||||
// Idempotent.
|
||||
virtual void begin_drain() = 0;
|
||||
|
||||
// Tears everything down now, aborting live streams with Error::EgressGone.
|
||||
// `on_done` runs on the egress's executor once the tunnel is gone.
|
||||
virtual void shutdown(std::function<void()> on_done = {}) = 0;
|
||||
|
||||
// Stable label for logs: node id for a tunnel, "direct" otherwise.
|
||||
virtual const std::string &label() const = 0;
|
||||
|
||||
bool usable() const { return state() == EgressState::Ready; }
|
||||
};
|
||||
|
||||
using EgressPtr = std::shared_ptr<Egress>;
|
||||
|
||||
} // namespace ovg::egress
|
||||
Reference in New Issue
Block a user