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