Files
ovgate/src/vpngate/node_store.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

235 lines
6.8 KiB
C++

#include "vpngate/node_store.h"
#include <sys/stat.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include "common/error.h"
#include "common/http_get.h"
#include "common/logging.h"
#include "common/metrics.h"
namespace ovg::vpngate {
namespace {
constexpr const char *kMod = "vpngate";
auto *m_refresh_ok = metrics::counter("ovg_vpngate_refresh_success_total",
"Successful node list refreshes");
auto *m_refresh_fail = metrics::counter("ovg_vpngate_refresh_failure_total",
"Failed node list refreshes");
auto *m_nodes = metrics::gauge("ovg_vpngate_nodes",
"Nodes in the current snapshot");
} // namespace
NodeStore::NodeStore(asio::io_context &io, VpnGateConfig cfg)
: io_(io), cfg_(std::move(cfg)), timer_(io) {
nodes_ = std::make_shared<const NodeList>();
}
NodeStore::~NodeStore() { stop(); }
void NodeStore::start() {
// A stale cache is far better than no nodes: it lets us bring a tunnel up
// while the network fetch is still in flight (or if VPNGate is unreachable).
std::string body;
if (load_cache(&body)) {
ParseResult pr;
std::string err;
if (parse_node_list(body, &pr, &err)) {
auto list = std::make_shared<const NodeList>(std::move(pr.nodes));
{
std::lock_guard lk(mu_);
nodes_ = list;
}
m_nodes->set(static_cast<int64_t>(list->size()));
LOG_INFO(kMod, "loaded {} nodes from cache {}", list->size(),
cfg_.cache_path);
} else {
LOG_WARN(kMod, "cache at {} is unusable: {}", cfg_.cache_path, err);
}
}
refresh_now(nullptr);
schedule_next();
}
void NodeStore::stop() {
stopped_.store(true);
std::error_code ignored;
timer_.cancel(ignored);
}
void NodeStore::schedule_next() {
if (stopped_.load()) return;
timer_.expires_after(cfg_.refresh_interval);
timer_.async_wait([this](std::error_code ec) {
if (ec || stopped_.load()) return;
refresh_now(nullptr);
schedule_next();
});
}
void NodeStore::refresh_now(RefreshHandler handler) {
{
std::lock_guard lk(mu_);
if (fetch_in_flight_) {
// Coalesce: a second caller waits on the fetch already running instead of
// hammering the API.
if (handler) waiters_.push_back(std::move(handler));
return;
}
fetch_in_flight_ = true;
if (handler) waiters_.push_back(std::move(handler));
}
try_urls(0, nullptr);
}
void NodeStore::try_urls(size_t index, RefreshHandler handler) {
if (stopped_.load()) {
complete(make_error_code(Error::Cancelled), 0);
return;
}
if (index >= cfg_.api_urls.size()) {
LOG_WARN(kMod, "all {} API endpoints failed", cfg_.api_urls.size());
m_refresh_fail->inc();
complete(make_error_code(Error::UpstreamFailure), 0);
return;
}
http::Options opts;
opts.timeout = cfg_.http_timeout;
opts.max_bytes = cfg_.max_response_bytes;
const std::string &url = cfg_.api_urls[index];
http::async_get(io_, url, opts,
[this, index, url](std::error_code ec, http::Response resp) {
if (ec) {
LOG_WARN(kMod, "fetch {} failed: {}", url, ec.message());
try_urls(index + 1, nullptr);
return;
}
if (resp.status != 200) {
LOG_WARN(kMod, "fetch {} returned HTTP {}", url,
resp.status);
try_urls(index + 1, nullptr);
return;
}
LOG_DEBUG(kMod, "fetched {} bytes from {}",
resp.body.size(), url);
on_body(std::move(resp.body), /*from_cache=*/false, nullptr);
});
}
void NodeStore::on_body(std::string body, bool from_cache,
RefreshHandler /*handler*/) {
ParseResult pr;
std::string err;
if (!parse_node_list(body, &pr, &err)) {
LOG_WARN(kMod, "parse failed: {}", err);
m_refresh_fail->inc();
complete(make_error_code(Error::ProtocolError), 0);
return;
}
const size_t count = pr.nodes.size();
auto list = std::make_shared<const NodeList>(std::move(pr.nodes));
{
std::lock_guard lk(mu_);
nodes_ = list;
last_success_ = std::chrono::system_clock::now();
}
m_nodes->set(static_cast<int64_t>(count));
m_refresh_ok->inc();
if (!from_cache) save_cache(body);
LOG_INFO(kMod, "node list refreshed: {} usable nodes", count);
complete({}, count);
}
void NodeStore::complete(std::error_code ec, size_t count) {
std::vector<RefreshHandler> waiters;
{
std::lock_guard lk(mu_);
fetch_in_flight_ = false;
waiters.swap(waiters_);
}
for (auto &w : waiters) {
if (w) asio::post(io_, [w = std::move(w), ec, count] { w(ec, count); });
}
}
bool NodeStore::load_cache(std::string *body) {
if (cfg_.cache_path.empty()) return false;
std::error_code ec;
const auto path = std::filesystem::path(cfg_.cache_path);
if (!std::filesystem::exists(path, ec)) return false;
struct stat st{};
if (::stat(cfg_.cache_path.c_str(), &st) != 0) return false;
const auto age = std::chrono::system_clock::now() -
std::chrono::system_clock::from_time_t(st.st_mtime);
if (age > cfg_.cache_max_age) {
LOG_INFO(kMod, "cache {} is too old ({}h), ignoring", cfg_.cache_path,
std::chrono::duration_cast<std::chrono::hours>(age).count());
return false;
}
std::ifstream in(cfg_.cache_path, std::ios::binary);
if (!in) return false;
std::ostringstream ss;
ss << in.rdbuf();
*body = ss.str();
return !body->empty();
}
void NodeStore::save_cache(const std::string &body) {
if (cfg_.cache_path.empty()) return;
std::error_code ec;
const auto path = std::filesystem::path(cfg_.cache_path);
if (path.has_parent_path())
std::filesystem::create_directories(path.parent_path(), ec);
// Write-then-rename so a crash mid-write cannot leave a truncated cache that
// we would happily parse on the next start.
const std::string tmp = cfg_.cache_path + ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out) {
LOG_WARN(kMod, "cannot write cache {}", tmp);
return;
}
out.write(body.data(), static_cast<std::streamsize>(body.size()));
if (!out) {
LOG_WARN(kMod, "short write to cache {}", tmp);
return;
}
}
std::filesystem::rename(tmp, path, ec);
if (ec) LOG_WARN(kMod, "cannot rename cache into place: {}", ec.message());
}
NodeListPtr NodeStore::snapshot() const {
std::lock_guard lk(mu_);
return nodes_;
}
bool NodeStore::has_nodes() const {
std::lock_guard lk(mu_);
return nodes_ && !nodes_->empty();
}
std::chrono::system_clock::time_point NodeStore::last_success() const {
std::lock_guard lk(mu_);
return last_success_;
}
} // namespace ovg::vpngate