forked from cloud/ovgate
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>
235 lines
6.8 KiB
C++
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
|