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>
402 lines
14 KiB
C++
402 lines
14 KiB
C++
// 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;
|
|
}
|