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,401 @@
|
||||
// Brings up one OpenVPN tunnel and reports what came back. Nothing above the
|
||||
// ovpn module is involved -- no netstack, no SOCKS5.
|
||||
//
|
||||
// This exists because the design rests on one assumption that cannot be
|
||||
// unit-tested: that openvpn3 is happy to treat a SOCK_DGRAM socketpair
|
||||
// descriptor as its tun device, and that what arrives on our end is bare IP
|
||||
// packets with no framing of its own. This tool proves or disproves that
|
||||
// against a real server in about thirty seconds, and stays in the tree because
|
||||
// the same question comes up again on every openvpn3 bump.
|
||||
//
|
||||
// ovg_tunnel_smoke [--csv FILE] [--node HOSTNAME] [--udp] [--seconds N]
|
||||
//
|
||||
// With no --csv it fetches the live VPNGate list. Exit status is 0 only if the
|
||||
// tunnel came up and at least one IP packet arrived.
|
||||
#include <asio.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "common/config.h"
|
||||
#include "common/http_get.h"
|
||||
#include "common/logging.h"
|
||||
#include "ovpn/tunnel_client.h"
|
||||
#include "vpngate/csv_parser.h"
|
||||
|
||||
using namespace ovg;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kMod = "smoke";
|
||||
|
||||
std::string read_file(const std::string &path) {
|
||||
std::string out;
|
||||
std::FILE *f = std::fopen(path.c_str(), "rb");
|
||||
if (!f) return out;
|
||||
char buf[65536];
|
||||
size_t n;
|
||||
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n);
|
||||
std::fclose(f);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Enough of an IP header decode to prove the framing assumption: if these
|
||||
// fields are sane, what we are being handed really is a bare IP packet.
|
||||
std::string describe_ip_packet(const uint8_t *p, size_t len) {
|
||||
if (len < 1) return "empty";
|
||||
const int version = p[0] >> 4;
|
||||
if (version == 4) {
|
||||
if (len < 20) return fmt::format("truncated IPv4 ({} bytes)", len);
|
||||
const size_t ihl = (p[0] & 0x0f) * 4;
|
||||
const size_t total = (size_t(p[2]) << 8) | p[3];
|
||||
const int proto = p[9];
|
||||
const auto addr = [](const uint8_t *a) {
|
||||
return fmt::format("{}.{}.{}.{}", a[0], a[1], a[2], a[3]);
|
||||
};
|
||||
const char *pname = proto == 6 ? "TCP"
|
||||
: proto == 17 ? "UDP"
|
||||
: proto == 1 ? "ICMP"
|
||||
: "?";
|
||||
std::string s = fmt::format("IPv4 {} -> {} {} len={} (wire {})",
|
||||
addr(p + 12), addr(p + 16), pname, total, len);
|
||||
if (total != len) s += " <-- length mismatch!";
|
||||
if (ihl < 20 || ihl > len) s += " <-- bad IHL";
|
||||
return s;
|
||||
}
|
||||
if (version == 6) {
|
||||
if (len < 40) return fmt::format("truncated IPv6 ({} bytes)", len);
|
||||
return fmt::format("IPv6 next-header={} len={}", p[6], len);
|
||||
}
|
||||
// The interesting failure mode: a 4-byte tun_prefix would put a small
|
||||
// integer here instead of an IP version nibble.
|
||||
return fmt::format("NOT an IP packet: first bytes {:02x} {:02x} {:02x} {:02x} "
|
||||
"(len {}) -- framing assumption is wrong",
|
||||
len > 0 ? p[0] : 0, len > 1 ? p[1] : 0, len > 2 ? p[2] : 0,
|
||||
len > 3 ? p[3] : 0, len);
|
||||
}
|
||||
|
||||
uint16_t inet_checksum(const uint8_t *p, size_t len) {
|
||||
uint32_t sum = 0;
|
||||
for (size_t i = 0; i + 1 < len; i += 2) sum += (uint32_t(p[i]) << 8) | p[i + 1];
|
||||
if (len & 1) sum += uint32_t(p[len - 1]) << 8;
|
||||
while (sum >> 16) sum = (sum & 0xffff) + (sum >> 16);
|
||||
return static_cast<uint16_t>(~sum);
|
||||
}
|
||||
|
||||
bool parse_ipv4(const std::string &s, uint8_t out[4]) {
|
||||
unsigned a, b, c, d;
|
||||
if (std::sscanf(s.c_str(), "%u.%u.%u.%u", &a, &b, &c, &d) != 4) return false;
|
||||
if (a > 255 || b > 255 || c > 255 || d > 255) return false;
|
||||
out[0] = uint8_t(a); out[1] = uint8_t(b); out[2] = uint8_t(c); out[3] = uint8_t(d);
|
||||
return true;
|
||||
}
|
||||
|
||||
// A complete IPv4 + ICMP echo request. Built by hand because the whole point
|
||||
// is to put a real IP packet on the pipe without a netstack in the way: if the
|
||||
// reply comes back, both directions of the framing assumption hold.
|
||||
std::vector<uint8_t> build_icmp_echo(const std::string &src,
|
||||
const std::string &dst, uint16_t id,
|
||||
uint16_t seq) {
|
||||
std::vector<uint8_t> pkt(20 + 8 + 16, 0);
|
||||
uint8_t *ip = pkt.data();
|
||||
ip[0] = 0x45; // IPv4, IHL 5
|
||||
ip[2] = uint8_t(pkt.size() >> 8); // total length
|
||||
ip[3] = uint8_t(pkt.size() & 0xff);
|
||||
ip[4] = uint8_t(id >> 8); // identification
|
||||
ip[5] = uint8_t(id & 0xff);
|
||||
ip[6] = 0x40; // don't fragment
|
||||
ip[8] = 64; // TTL
|
||||
ip[9] = 1; // ICMP
|
||||
if (!parse_ipv4(src, ip + 12) || !parse_ipv4(dst, ip + 16)) return {};
|
||||
const uint16_t ipsum = inet_checksum(ip, 20);
|
||||
ip[10] = uint8_t(ipsum >> 8);
|
||||
ip[11] = uint8_t(ipsum & 0xff);
|
||||
|
||||
uint8_t *icmp = pkt.data() + 20;
|
||||
icmp[0] = 8; // echo request
|
||||
icmp[4] = uint8_t(id >> 8);
|
||||
icmp[5] = uint8_t(id & 0xff);
|
||||
icmp[6] = uint8_t(seq >> 8);
|
||||
icmp[7] = uint8_t(seq & 0xff);
|
||||
for (size_t i = 0; i < 16; ++i) icmp[8 + i] = uint8_t('a' + i);
|
||||
const uint16_t icsum = inet_checksum(icmp, 8 + 16);
|
||||
icmp[2] = uint8_t(icsum >> 8);
|
||||
icmp[3] = uint8_t(icsum & 0xff);
|
||||
return pkt;
|
||||
}
|
||||
|
||||
struct Options {
|
||||
std::string csv;
|
||||
std::string node;
|
||||
std::string ping = "8.8.8.8";
|
||||
bool udp = false;
|
||||
int seconds = 40;
|
||||
};
|
||||
|
||||
bool parse_args(int argc, char **argv, Options *o) {
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string a = argv[i];
|
||||
const auto next = [&](std::string *dst) {
|
||||
if (i + 1 >= argc) return false;
|
||||
*dst = argv[++i];
|
||||
return true;
|
||||
};
|
||||
if (a == "--csv") {
|
||||
if (!next(&o->csv)) return false;
|
||||
} else if (a == "--node") {
|
||||
if (!next(&o->node)) return false;
|
||||
} else if (a == "--ping") {
|
||||
if (!next(&o->ping)) return false;
|
||||
} else if (a == "--udp") {
|
||||
o->udp = true;
|
||||
} else if (a == "--seconds") {
|
||||
std::string s;
|
||||
if (!next(&s)) return false;
|
||||
o->seconds = std::atoi(s.c_str());
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
"usage: %s [--csv FILE] [--node HOSTNAME] [--udp] "
|
||||
"[--seconds N]\n",
|
||||
argv[0]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
Options opt;
|
||||
if (!parse_args(argc, argv, &opt)) return 2;
|
||||
|
||||
log::set_level(log::Level::Debug);
|
||||
asio::io_context io;
|
||||
|
||||
// ---- node list ----------------------------------------------------------
|
||||
std::string body;
|
||||
if (!opt.csv.empty()) {
|
||||
body = read_file(opt.csv);
|
||||
if (body.empty()) {
|
||||
LOG_ERROR(kMod, "cannot read {}", opt.csv);
|
||||
return 2;
|
||||
}
|
||||
} else {
|
||||
http::Options ho;
|
||||
ho.timeout = std::chrono::seconds(30);
|
||||
std::error_code fetch_ec;
|
||||
http::async_get(io, "http://www.vpngate.net/api/iphone/", ho,
|
||||
[&](std::error_code ec, http::Response resp) {
|
||||
fetch_ec = ec;
|
||||
body = std::move(resp.body);
|
||||
});
|
||||
io.run();
|
||||
io.restart();
|
||||
if (fetch_ec) {
|
||||
LOG_ERROR(kMod, "fetching the node list failed: {}", fetch_ec.message());
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
vpngate::ParseResult pr;
|
||||
std::string err;
|
||||
if (!vpngate::parse_node_list(body, &pr, &err)) {
|
||||
LOG_ERROR(kMod, "node list is not parseable: {}", err);
|
||||
return 2;
|
||||
}
|
||||
LOG_INFO(kMod, "{} nodes parsed -- {}", pr.nodes.size(), pr.stats.summary());
|
||||
|
||||
// ---- pick one -----------------------------------------------------------
|
||||
// Deliberately not the selector: this tool is about the tunnel, and mixing
|
||||
// in the scoring logic would make a failure ambiguous.
|
||||
const vpngate::Node *chosen = nullptr;
|
||||
const vpngate::Remote *remote = nullptr;
|
||||
if (!opt.node.empty()) {
|
||||
for (const auto &n : pr.nodes) {
|
||||
if (n.host_name == opt.node || n.ip == opt.node) {
|
||||
chosen = &n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!chosen) {
|
||||
LOG_ERROR(kMod, "no node matching '{}'", opt.node);
|
||||
return 2;
|
||||
}
|
||||
remote = chosen->pick_remote(opt.udp);
|
||||
} else {
|
||||
// Highest VPNGate score that offers the protocol we want.
|
||||
int64_t best = -1;
|
||||
for (const auto &n : pr.nodes) {
|
||||
const vpngate::Remote *r = n.pick_remote(opt.udp);
|
||||
if (!r) continue;
|
||||
if (opt.udp && r->proto != vpngate::Proto::Udp) continue;
|
||||
if (n.api.score > best) {
|
||||
best = n.api.score;
|
||||
chosen = &n;
|
||||
remote = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!chosen || !remote) {
|
||||
LOG_ERROR(kMod, "no usable node found");
|
||||
return 2;
|
||||
}
|
||||
LOG_INFO(kMod, "trying {} ({}, score {}) via {}:{}/{}", chosen->id(),
|
||||
chosen->country_short, chosen->api.score, remote->host, remote->port,
|
||||
vpngate::proto_name(remote->proto));
|
||||
|
||||
// ---- connect ------------------------------------------------------------
|
||||
OvpnConfig cfg;
|
||||
cfg.connect_timeout_s = 25;
|
||||
cfg.tunnel_up_timeout_s = 35;
|
||||
|
||||
auto tc = ovpn::TunnelClient::create(io, cfg);
|
||||
if (!ovpn::TunnelClient::supported()) {
|
||||
LOG_ERROR(kMod, "this build has no openvpn3 (-DOVG_WITH_TUNNEL=ON)");
|
||||
return 2;
|
||||
}
|
||||
|
||||
bool came_up = false;
|
||||
bool finished = false;
|
||||
size_t packets = 0;
|
||||
size_t echo_replies = 0;
|
||||
std::vector<uint8_t> buf(ovpn::kMaxPacketSize);
|
||||
|
||||
// Reads whatever the tunnel delivers and decodes just enough of each packet
|
||||
// to show that the framing is what we assumed.
|
||||
std::function<void()> read_one = [&] {
|
||||
tc->pipe().socket().async_receive(
|
||||
asio::buffer(buf), [&](std::error_code ec, size_t n) {
|
||||
if (ec) {
|
||||
if (ec != asio::error::operation_aborted)
|
||||
LOG_INFO(kMod, "tun read ended: {}", ec.message());
|
||||
return;
|
||||
}
|
||||
tc->pipe().note_received(n);
|
||||
// ICMP echo reply: type 0 at the start of the payload.
|
||||
if (n >= 28 && (buf[0] >> 4) == 4 && buf[9] == 1 &&
|
||||
buf[(buf[0] & 0x0f) * 4] == 0)
|
||||
++echo_replies;
|
||||
if (++packets <= 12)
|
||||
LOG_INFO(kMod, "rx #{}: {}", packets,
|
||||
describe_ip_packet(buf.data(), n));
|
||||
else if (packets % 200 == 0)
|
||||
LOG_INFO(kMod, "rx {} packets", packets);
|
||||
read_one();
|
||||
});
|
||||
};
|
||||
|
||||
asio::steady_timer pinger(io);
|
||||
uint16_t seq = 0;
|
||||
std::string tun_ip;
|
||||
std::function<void()> ping_once = [&] {
|
||||
const auto pkt = build_icmp_echo(tun_ip, opt.ping, 0x4f56, ++seq);
|
||||
if (pkt.empty()) {
|
||||
LOG_ERROR(kMod, "cannot build an echo request for {} -> {}", tun_ip,
|
||||
opt.ping);
|
||||
return;
|
||||
}
|
||||
const auto st = tc->pipe().send_packet(pkt.data(), pkt.size());
|
||||
LOG_INFO(kMod, "tx echo request #{} {} -> {} ({} bytes): {}", seq, tun_ip,
|
||||
opt.ping, pkt.size(),
|
||||
st == ovpn::PacketPipe::SendStatus::Ok ? "queued"
|
||||
: st == ovpn::PacketPipe::SendStatus::Dropped ? "DROPPED"
|
||||
: "PIPE CLOSED");
|
||||
pinger.expires_after(std::chrono::seconds(2));
|
||||
pinger.async_wait([&](std::error_code ec) {
|
||||
if (!ec) ping_once();
|
||||
});
|
||||
};
|
||||
|
||||
asio::steady_timer deadline(io);
|
||||
|
||||
// Everything that keeps the io_context alive has to be taken down together,
|
||||
// the pending tun read included: leaving it armed means the final drain
|
||||
// never returns.
|
||||
auto shutdown = [&] {
|
||||
deadline.cancel();
|
||||
pinger.cancel();
|
||||
if (tc->pipe().is_open()) {
|
||||
std::error_code ignored;
|
||||
tc->pipe().socket().cancel(ignored);
|
||||
}
|
||||
tc->stop([&] { finished = true; });
|
||||
};
|
||||
|
||||
deadline.expires_after(std::chrono::seconds(opt.seconds));
|
||||
deadline.async_wait([&](std::error_code ec) {
|
||||
if (ec) return;
|
||||
LOG_INFO(kMod, "{}s elapsed, shutting down", opt.seconds);
|
||||
shutdown();
|
||||
});
|
||||
|
||||
if (!tc->start(*chosen, *remote, [&](ovpn::TunnelState st,
|
||||
const ovpn::TunnelInfo &info,
|
||||
const std::string &detail) {
|
||||
LOG_INFO(kMod, "state -> {}{}{}", ovpn::tunnel_state_name(st),
|
||||
detail.empty() ? "" : ": ", detail);
|
||||
if (st == ovpn::TunnelState::Up) {
|
||||
came_up = true;
|
||||
LOG_INFO(kMod,
|
||||
"pushed: ip={}/{} gw={} mtu={} dns=[{}] redirect_gw={} "
|
||||
"routes={} server={}",
|
||||
info.ipv4, info.prefix4, info.gateway4, info.mtu,
|
||||
fmt::join(info.dns, ","), info.redirect_gateway,
|
||||
info.routes.size(), info.server_ip);
|
||||
// A tunnel that comes up but carries nothing is the failure this
|
||||
// tool is really looking for. Nothing else generates traffic here,
|
||||
// so send something that has to be answered.
|
||||
tun_ip = info.ipv4;
|
||||
ping_once();
|
||||
} else if (st == ovpn::TunnelState::Down) {
|
||||
shutdown();
|
||||
}
|
||||
},
|
||||
&err)) {
|
||||
LOG_ERROR(kMod, "start failed: {}", err);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Only now: pipe() is guaranteed valid from a successful start(), not before.
|
||||
read_one();
|
||||
|
||||
while (!finished && !io.stopped()) {
|
||||
if (io.run_one() == 0) break;
|
||||
}
|
||||
io.run(); // drain the stop callback
|
||||
|
||||
const auto ctr = tc->pipe().counters();
|
||||
LOG_INFO(kMod,
|
||||
"result: up={} tx={} pkts/{} B (dropped {}) rx={} pkts/{} B, "
|
||||
"{} echo replies",
|
||||
came_up, ctr.tx_packets, ctr.tx_bytes, ctr.tx_dropped,
|
||||
ctr.rx_packets, ctr.rx_bytes, echo_replies);
|
||||
|
||||
if (!came_up) {
|
||||
LOG_ERROR(kMod, "tunnel never came up");
|
||||
return 1;
|
||||
}
|
||||
if (ctr.rx_packets == 0) {
|
||||
LOG_ERROR(kMod,
|
||||
"tunnel came up but no IP packet ever arrived on the pipe -- "
|
||||
"the socketpair-as-tun assumption needs re-checking");
|
||||
return 1;
|
||||
}
|
||||
if (echo_replies == 0) {
|
||||
LOG_ERROR(kMod,
|
||||
"packets arrive but none of them answered our echo requests; "
|
||||
"the node may be filtering ICMP, so this is inconclusive rather "
|
||||
"than a verdict on the tun plumbing");
|
||||
return 1;
|
||||
}
|
||||
LOG_INFO(kMod,
|
||||
"OK: openvpn3 took the socketpair as its tun, our hand-built IP "
|
||||
"packet reached {} and the reply came back unframed",
|
||||
opt.ping);
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user