Files
ovgate/src/socks5/server.cpp
T
iceBear67andClaude Opus 5 b2ba45c9f8 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>
2026-07-28 04:38:39 +00:00

236 lines
7.3 KiB
C++

#include "socks5/server.h"
#include "common/error.h"
#include "common/logging.h"
#include "common/metrics.h"
namespace ovg::socks5 {
namespace {
constexpr const char *kMod = "socks5";
metrics::Counter *m_accepted() {
static auto *c = metrics::counter("ovg_socks5_sessions_total",
"Accepted SOCKS5 connections");
return c;
}
metrics::Counter *m_rejected() {
static auto *c =
metrics::counter("ovg_socks5_rejected_total",
"Connections refused by max_sessions admission control");
return c;
}
metrics::Counter *m_no_egress() {
static auto *c = metrics::counter("ovg_socks5_no_egress_total",
"Connections refused: no usable tunnel");
return c;
}
metrics::Gauge *g_active() {
static auto *g =
metrics::gauge("ovg_socks5_sessions_active", "Live SOCKS5 sessions");
return g;
}
} // namespace
Server::Server(asio::io_context &io, const Config &cfg, EgressProvider acquire)
: io_(io),
cfg_(cfg.socks5),
acquire_(std::move(acquire)),
accept_strand_(asio::make_strand(io)),
acceptor_(accept_strand_),
auth_(cfg.socks5.users, cfg.socks5.require_auth) {
auto opts = std::make_shared<SessionOptions>();
opts->socks5 = cfg.socks5;
opts->retry_zero_progress = cfg.switching.retry_zero_progress;
opts->rehome_udp = cfg.switching.rehome_udp;
opts_ = std::move(opts);
}
Server::~Server() { stop(); }
bool Server::start(std::string *err) {
std::error_code ec;
const auto addr = asio::ip::make_address(cfg_.listen_address, ec);
if (ec) {
*err = "socks5.listen_address is not an IP address: " + cfg_.listen_address;
return false;
}
const asio::ip::tcp::endpoint ep(addr, cfg_.listen_port);
acceptor_.open(ep.protocol(), ec);
if (ec) {
*err = "cannot open listening socket: " + ec.message();
return false;
}
acceptor_.set_option(asio::socket_base::reuse_address(true), ec);
acceptor_.bind(ep, ec);
if (ec) {
*err = "cannot bind " + ep.address().to_string() + ":" +
std::to_string(ep.port()) + ": " + ec.message();
return false;
}
// A deep backlog matters here: 1000 clients reconnecting after a switch
// arrive in a burst, and the default of 5 would turn that into connection
// refused.
acceptor_.listen(asio::socket_base::max_listen_connections, ec);
if (ec) {
*err = "cannot listen: " + ec.message();
return false;
}
port_ = acceptor_.local_endpoint(ec).port();
LOG_INFO(kMod, "listening on {}:{} (auth {}, max_sessions {}, udp {})",
ep.address().to_string(), port_,
cfg_.require_auth ? "required" : "optional", cfg_.max_sessions,
cfg_.udp_associate_enabled ? "enabled" : "disabled");
do_accept();
return true;
}
void Server::do_accept() {
if (stopping_.load(std::memory_order_acquire)) return;
// Accepting straight onto a fresh strand gives the session a socket whose
// executor already is its strand -- see socks5/session.h.
acceptor_.async_accept(
asio::make_strand(io_),
[this](const std::error_code &ec, ClientSocket sock) {
if (ec) {
if (ec == asio::error::operation_aborted) return;
LOG_WARN(kMod, "accept failed: {}", ec.message());
// An accept error is usually per-connection (EMFILE, ECONNABORTED).
// Give up the listener only if it was closed under us.
if (acceptor_.is_open()) do_accept();
return;
}
if (stopping_.load(std::memory_order_acquire)) {
std::error_code ignored;
sock.close(ignored);
return;
}
// Admission control, before anything else allocates.
if (active_.load(std::memory_order_relaxed) >=
static_cast<int64_t>(cfg_.max_sessions)) {
rejected_.fetch_add(1, std::memory_order_relaxed);
m_rejected()->inc();
LOG_WARN(kMod, "refusing connection: {} sessions already live",
cfg_.max_sessions);
std::error_code ignored;
sock.close(ignored);
do_accept();
return;
}
egress::EgressPtr eg = acquire_ ? acquire_() : nullptr;
if (!eg) {
// Closing without a SOCKS5 reply is deliberate: we have not read the
// greeting yet, so there is no negotiated framing to reply in.
no_egress_.fetch_add(1, std::memory_order_relaxed);
m_no_egress()->inc();
LOG_WARN(kMod, "refusing connection: no usable egress");
std::error_code ignored;
sock.close(ignored);
do_accept();
return;
}
uint64_t id;
SessionPtr s;
{
std::lock_guard<std::mutex> lk(mu_);
id = next_id_++;
s = Session::create(id, std::move(sock), opts_, &auth_, std::move(eg),
[this](uint64_t sid) { reap(sid); });
sessions_.emplace(id, s);
}
active_.fetch_add(1, std::memory_order_relaxed);
g_active()->add(1);
accepted_.fetch_add(1, std::memory_order_relaxed);
m_accepted()->inc();
s->start();
do_accept();
});
}
void Server::reap(uint64_t id) {
{
std::lock_guard<std::mutex> lk(mu_);
if (sessions_.erase(id) == 0) return; // already reaped
}
active_.fetch_sub(1, std::memory_order_relaxed);
g_active()->sub(1);
}
void Server::stop() {
if (stopping_.exchange(true, std::memory_order_acq_rel)) return;
asio::post(accept_strand_, [this] {
std::error_code ignored;
acceptor_.close(ignored);
});
for (const auto &s : snapshot()) s->force_close("server shutting down");
LOG_INFO(kMod, "listener stopped");
}
std::vector<SessionPtr> Server::snapshot() const {
std::vector<SessionPtr> out;
std::lock_guard<std::mutex> lk(mu_);
out.reserve(sessions_.size());
for (const auto &kv : sessions_) {
if (auto s = kv.second.lock()) out.push_back(std::move(s));
}
return out;
}
void Server::on_promote(const egress::EgressPtr &old_e,
const egress::EgressPtr &new_e) {
if (!old_e || !new_e) return;
// Offer, do not command: each session decides on its own strand whether it is
// actually re-homeable. Everything that declines stays on the old egress and
// drains normally.
const auto live = snapshot();
for (const auto &s : live) s->try_rehome(old_e, new_e);
LOG_INFO(kMod, "offered {} live session(s) a move from {} to {}", live.size(),
old_e->label(), new_e->label());
}
void Server::on_drain_expired(const egress::EgressPtr &e) {
if (!e) return;
size_t n = 0;
for (const auto &s : snapshot()) {
s->close_if_on(e, "drain grace expired");
++n;
}
LOG_WARN(kMod, "drain window for {} expired; closing whatever is left of {} "
"session(s)",
e->label(), n);
}
Server::Stats Server::stats() const {
Stats s;
s.accepted = accepted_.load(std::memory_order_relaxed);
s.rejected = rejected_.load(std::memory_order_relaxed);
s.no_egress = no_egress_.load(std::memory_order_relaxed);
s.active = active_.load(std::memory_order_relaxed);
s.auth_ok = auth_.successes();
s.auth_failed = auth_.failures();
return s;
}
std::vector<Session::Info> Server::sessions(size_t limit) const {
std::vector<Session::Info> out;
for (const auto &s : snapshot()) {
if (out.size() >= limit) break;
out.push_back(s->info());
}
return out;
}
} // namespace ovg::socks5