#include "vpngate/node_store.h" #include #include #include #include #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(); } 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(std::move(pr.nodes)); { std::lock_guard lk(mu_); nodes_ = list; } m_nodes->set(static_cast(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(std::move(pr.nodes)); { std::lock_guard lk(mu_); nodes_ = list; last_success_ = std::chrono::system_clock::now(); } m_nodes->set(static_cast(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 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(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(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