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,234 @@
|
||||
#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
|
||||
Reference in New Issue
Block a user