// 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 #include #include #include #include #include #include #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(~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 build_icmp_echo(const std::string &src, const std::string &dst, uint16_t id, uint16_t seq) { std::vector 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 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 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 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; }