Files
ovgate/tools/tunnel_smoke.cpp
T
iceBear67andClaude Opus 5 b2ba45c9f8 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>
2026-07-28 04:38:39 +00:00

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;
}