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>
197 lines
6.2 KiB
C++
197 lines
6.2 KiB
C++
// Configuration model and INI-style loader.
|
|
//
|
|
// Format:
|
|
// [section]
|
|
// key = value # comment
|
|
// list_key = a, b, c
|
|
//
|
|
// Durations accept a unit suffix: "30s", "5m", "1h", "250ms". Bare numbers are
|
|
// seconds. Keeping the format boring is intentional -- config bugs at 3am are
|
|
// worse than a missing feature.
|
|
#pragma once
|
|
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <map>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "common/logging.h"
|
|
|
|
namespace ovg {
|
|
|
|
using Millis = std::chrono::milliseconds;
|
|
|
|
struct Credential {
|
|
std::string username;
|
|
// Stored as sha256(salt || password), hex. Never the plaintext.
|
|
std::string salt_hex;
|
|
std::string hash_hex;
|
|
};
|
|
|
|
struct Socks5Config {
|
|
std::string listen_address = "127.0.0.1";
|
|
uint16_t listen_port = 1080;
|
|
bool require_auth = true;
|
|
// Address handed back in the UDP ASSOCIATE reply. Must be reachable *by the
|
|
// client*, which is why it cannot just be the listen address when that is
|
|
// 0.0.0.0. Empty means "derive from the control connection's local address".
|
|
std::string advertise_address;
|
|
bool udp_associate_enabled = true;
|
|
size_t max_sessions = 1200;
|
|
Millis handshake_timeout{10000};
|
|
Millis connect_timeout{20000};
|
|
Millis idle_timeout{300000};
|
|
Millis udp_idle_timeout{60000};
|
|
size_t relay_buffer_size = 16384;
|
|
int io_threads = 0; // 0 = min(hardware_concurrency, 4)
|
|
std::string auth_file;
|
|
std::vector<Credential> users;
|
|
};
|
|
|
|
struct VpnGateConfig {
|
|
std::vector<std::string> api_urls{
|
|
"http://www.vpngate.net/api/iphone/",
|
|
"http://www.vpngate.net/api/iphone/", // retried; mirrors can be added
|
|
};
|
|
Millis refresh_interval{1800000}; // 30m
|
|
Millis http_timeout{30000};
|
|
std::string cache_path = "var/vpngate_cache.csv";
|
|
Millis cache_max_age{21600000}; // 6h -- stale cache still beats no nodes
|
|
size_t max_response_bytes = 32u * 1024 * 1024;
|
|
};
|
|
|
|
struct SelectorConfig {
|
|
std::vector<std::string> country_allow; // empty = all
|
|
std::vector<std::string> country_deny;
|
|
bool prefer_udp = true;
|
|
size_t probe_candidates = 12;
|
|
size_t probe_samples = 3;
|
|
Millis probe_timeout{3000};
|
|
size_t probe_concurrency = 8;
|
|
|
|
// Prior (API-derived) weights.
|
|
double w_score = 0.35;
|
|
double w_speed = 0.30;
|
|
double w_sessions = 0.20;
|
|
double w_uptime = 0.15;
|
|
|
|
// Final blend.
|
|
double w_rtt = 0.45;
|
|
double w_prior = 0.30;
|
|
double w_history = 0.25;
|
|
|
|
std::string history_path = "var/node_history.tsv";
|
|
Millis failure_backoff_initial{60000};
|
|
Millis failure_backoff_max{3600000};
|
|
};
|
|
|
|
struct SwitchConfig {
|
|
enum class Mode {
|
|
Graceful, // make-before-break with a bounded drain window
|
|
Hard, // promote and immediately close every old session
|
|
};
|
|
Mode mode = Mode::Graceful;
|
|
Millis drain_grace{120000};
|
|
size_t max_draining = 2;
|
|
Millis min_interval{60000};
|
|
double improvement_margin = 0.20; // candidate must beat current by this much
|
|
Millis backoff_initial{30000};
|
|
Millis backoff_max{480000};
|
|
bool retry_zero_progress = true; // transparently re-home untouched sessions
|
|
bool rehome_udp = true; // UDP associations survive a switch
|
|
|
|
// How often to go looking for a *better* node while the current one is
|
|
// perfectly healthy. Off by default, and deliberately so: a scan probes a
|
|
// dozen volunteer-run servers, and switching a healthy tunnel costs every
|
|
// session that has moved bytes. Degradation-driven switching (health/) is
|
|
// what the requirement actually asks for; this is the optional upgrade path.
|
|
Millis opportunistic_interval{0}; // 0 = disabled
|
|
};
|
|
|
|
struct HealthConfig {
|
|
Millis interval{15000};
|
|
int unhealthy_windows = 3;
|
|
Millis probe_timeout{5000};
|
|
// The probe dials this host:port *through the egress* and drops the stream
|
|
// as soon as it is up. A TCP handshake is used rather than a bare DNS lookup
|
|
// because a lookup can be answered from the resolver cache without a single
|
|
// byte crossing the tunnel -- which would report a dead tunnel as healthy.
|
|
std::string probe_domain = "www.google.com";
|
|
uint16_t probe_port = 80;
|
|
double min_score = 0.40;
|
|
double max_connect_failure_rate = 0.50;
|
|
Millis stall_threshold{45000};
|
|
};
|
|
|
|
struct OvpnConfig {
|
|
bool allow_legacy_algorithms = true; // VPNGate is AES-128-CBC / SHA1
|
|
std::string username = "vpn"; // fallback for nodes that demand it
|
|
std::string password = "vpn";
|
|
int connect_timeout_s = 30;
|
|
bool compression = true;
|
|
int tunnel_up_timeout_s = 45;
|
|
// SO_SNDBUF/SO_RCVBUF for the tun socketpair; too small drops IP packets
|
|
// under burst (recoverable, but hurts throughput).
|
|
int packet_socket_buffer = 2 * 1024 * 1024;
|
|
};
|
|
|
|
struct DnsConfig {
|
|
std::vector<std::string> fallback_servers{"1.1.1.1", "8.8.8.8"};
|
|
Millis timeout{5000};
|
|
size_t cache_entries = 4096;
|
|
Millis min_ttl{5000};
|
|
Millis max_ttl{3600000};
|
|
bool prefer_ipv4 = true;
|
|
};
|
|
|
|
struct AdminConfig {
|
|
bool enabled = true;
|
|
std::string listen_address = "127.0.0.1";
|
|
uint16_t listen_port = 9080;
|
|
};
|
|
|
|
struct LogConfig {
|
|
log::Level level = log::Level::Info;
|
|
std::string file = "-";
|
|
std::map<std::string, log::Level> module_levels;
|
|
};
|
|
|
|
struct Config {
|
|
Socks5Config socks5;
|
|
VpnGateConfig vpngate;
|
|
SelectorConfig selector;
|
|
SwitchConfig switching;
|
|
HealthConfig health;
|
|
OvpnConfig ovpn;
|
|
DnsConfig dns;
|
|
AdminConfig admin;
|
|
LogConfig logging;
|
|
|
|
// Egress backend. "tunnel" = OpenVPN+lwIP, "direct" = host sockets (testing).
|
|
std::string egress_mode = "tunnel";
|
|
|
|
// Loads and validates. Returns false and fills `err` on any problem; a
|
|
// partially-applied config is never returned.
|
|
static bool load_file(const std::string &path, Config *out, std::string *err);
|
|
static bool load_string(const std::string &text, Config *out,
|
|
std::string *err);
|
|
|
|
bool validate(std::string *err) const;
|
|
};
|
|
|
|
// Parses "250ms" / "30s" / "5m" / "2h"; bare numbers are seconds.
|
|
bool parse_duration(const std::string &text, Millis *out);
|
|
|
|
// Loads "user:password" or "user:sha256$salt$hash" lines.
|
|
bool load_auth_file(const std::string &path, std::vector<Credential> *out,
|
|
std::string *err);
|
|
|
|
// Builds a credential with a fresh random salt.
|
|
Credential make_credential(const std::string &user, const std::string &password);
|
|
|
|
// Constant-time verification.
|
|
bool verify_credential(const Credential &c, const std::string &password);
|
|
|
|
} // namespace ovg
|