Files
ovgate/src/netstack/lwip_stack.h
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

312 lines
12 KiB
C++

// The userspace TCP/IP stack: lwIP lifecycle, one netif per live tunnel.
//
// ---------------------------------------------------------------------------
// Threading
// ---------------------------------------------------------------------------
// lwIP built with NO_SYS=1 has no locking whatsoever. Every call into it and
// every callback out of it must be serialized, so all of it runs on one asio
// strand -- Stack::strand(). Handlers the caller supplies run on the executor
// the caller passed in, never on the strand, so nothing above this module ever
// touches lwIP state.
//
// A strand rather than a dedicated thread: lwIP requires "no concurrency", not
// "the same thread", and a strand gives that plus the happens-before edges,
// without adding a thread or a second io_context to reason about.
//
// ---------------------------------------------------------------------------
// Why there can only be one Stack
// ---------------------------------------------------------------------------
// lwIP's state is file-scope globals: the netif list, tcp_active_pcbs, the
// timeout wheel, the memp pools. There is exactly one lwIP per process and no
// amount of wrapping changes that. The constructor enforces it rather than
// letting a second instance silently corrupt the first.
//
// This matters because make-before-break (docs/ARCHITECTURE.md 5) needs two
// tunnels alive at once. That works: one Stack, two Netifs, and every PCB
// pinned to its netif with tcp_bind_netif()/udp_bind_netif() so routing cannot
// send an old session's packets out the new tunnel.
//
// It works with one exception, and it is worth stating plainly. Attribution of
// *inbound* packets is by destination address, so two tunnels that push the
// same address are indistinguishable to lwIP. VPNGate servers hand out private
// addresses from a small set of ranges, so this is not hypothetical. Netif
// creation therefore rejects an address already in use, and the switch
// controller treats that rejection as "graceful drain is impossible here" and
// falls back to a hard switch -- which is the behaviour the requirements ask
// for when connections cannot be preserved.
//
// ---------------------------------------------------------------------------
// IPv6
// ---------------------------------------------------------------------------
// Not supported. lwIP is compiled IPv4-only (see lwip_port/lwipopts.h): VPNGate
// nodes essentially never push a usable IPv6 prefix, and carrying a second
// address family through the netif, the resolver and the SOCKS5 layer would
// roughly double the surface for something no node exercises. An IPv6 target
// arriving over SOCKS5 is refused with "address type not supported" rather than
// being quietly resolved to something else.
#pragma once
#include <asio.hpp>
#include <cstdint>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "common/config.h"
#include "common/endpoint.h"
#include "common/strand_deleter.h"
#include "netstack/packet_link.h"
#include "netstack/stream.h"
struct pbuf;
struct netif;
namespace ovg::netstack {
using ovg::Strand;
class Netif;
class LwipTcpStream;
class LwipUdpSocket;
class DnsResolver;
// Implemented by everything a Netif can own. The netif holds these weakly, so
// dropping the caller's reference still tears the connection down; this is only
// the handle it needs to reach survivors when the tunnel goes away underneath
// them. Called on the stack's strand.
class Closable {
public:
virtual ~Closable() = default;
virtual void abort_from_netif(const std::error_code &reason) = 0;
};
// Everything a netif needs, extracted from what the VPN server pushed.
struct NetifConfig {
IpAddress address; // our address inside the tunnel
int prefix = 0; // 1..32
IpAddress gateway; // may be unset on a point-to-point link
int mtu = 1500;
std::vector<IpAddress> dns; // in server priority order
std::string label; // node id; appears in every log line
};
class Stack {
public:
// Throws std::logic_error if another Stack is alive (see the header comment).
explicit Stack(asio::io_context &io, DnsConfig dns_cfg = {});
~Stack();
Stack(const Stack &) = delete;
Stack &operator=(const Stack &) = delete;
// Stops the periodic timer, and with it the last thing keeping the
// io_context busy.
//
// The timer re-arms itself for as long as the stack is alive, which makes it
// outstanding io work that releasing a work guard cannot retire. The
// destructor cancels it -- but the destructor cannot run until run() returns,
// and run() will not return while the timer is pending. A process that has
// logged a picture-perfect graceful shutdown then sits there forever. So
// shutdown has to say so explicitly, here.
//
// Call it only once every netif is gone: lwIP's timers drive TCP
// retransmission and reassembly expiry, so a stack that has stopped ticking
// under a live stream stops retransmitting on it. Idempotent.
void stop();
const Strand &strand() const { return strand_; }
asio::io_context &io() { return io_; }
const DnsConfig &dns_config() const { return dns_cfg_; }
using AttachHandler =
std::function<void(const std::error_code &, std::shared_ptr<Netif>)>;
// Brings up a netif over `link` and starts its receive loop. `link` must
// outlive the returned Netif; in practice the same object owns both.
//
// Fails with ovg::Error::ResourceExhausted if `cfg.address` is already in use
// by a live netif -- see the address-collision note above.
void async_attach(PacketLink &link, NetifConfig cfg,
const asio::any_io_executor &cb_ex, AttachHandler h);
// lwIP's global counters. Not per-netif -- lwIP does not track them that way
// -- so with two tunnels up these are the sum. The health monitor uses the
// delta over a window, where that is still a usable signal.
//
// There is no retransmit counter here because lwIP does not keep one: it
// increments tcp.xmit for original and retransmitted segments alike. The
// usable proxies for a degrading link are tcp_segments_sent rising while
// tcp_segments_received does not, and tcp_memory_errors climbing at all.
struct GlobalStats {
uint64_t tcp_segments_sent = 0;
uint64_t tcp_segments_received = 0;
uint64_t tcp_drops = 0;
uint64_t tcp_checksum_errors = 0;
uint64_t tcp_memory_errors = 0;
uint64_t ip_drops = 0;
uint64_t ip_checksum_errors = 0;
uint64_t reassembly_failures = 0;
size_t netifs = 0;
};
GlobalStats global_stats() const;
private:
friend class Netif;
void schedule_timer();
void on_timer(const std::error_code &ec);
// Address bookkeeping, so collisions are caught before lwIP sees them.
// Guarded by mu_ because the admin endpoint reads it off-strand.
bool claim_address(const IpAddress &a);
void release_address(const IpAddress &a);
asio::io_context &io_;
DnsConfig dns_cfg_;
Strand strand_;
asio::steady_timer timer_;
bool stopping_ = false;
mutable std::mutex mu_;
std::vector<IpAddress> claimed_;
};
// One tunnel's network interface, and the factory for everything that runs on
// it. Destroying it removes the lwIP netif and aborts every stream and socket
// still bound to it -- which is exactly what has to happen when a drained
// egress goes away.
class Netif : public std::enable_shared_from_this<Netif> {
public:
~Netif();
Netif(const Netif &) = delete;
Netif &operator=(const Netif &) = delete;
using ConnectHandler =
std::function<void(const std::error_code &, TcpStreamPtr)>;
using OpenUdpHandler =
std::function<void(const std::error_code &, UdpSocketPtr)>;
// `addr` must be a literal IPv4 address; names are the resolver's job.
void async_connect_tcp(const IpAddress &addr, uint16_t port, Millis timeout,
const asio::any_io_executor &cb_ex, ConnectHandler h);
// Binds an ephemeral port on this netif's address.
void async_open_udp(const asio::any_io_executor &cb_ex, OpenUdpHandler h);
// Shared, lazily created, backed by this netif's pushed DNS servers with the
// configured fallbacks appended. Safe to call from any thread.
//
// Held here weakly, so the caller owns it -- in practice the egress, for the
// tunnel's lifetime. That is not an ownership nicety: the resolver keeps a UDP
// socket, the socket keeps this netif alive, and a strong pointer here would
// close a reference cycle that no shutdown path could break. Dropping every
// reference costs a rebuilt cache on the next call and nothing else.
std::shared_ptr<Resolver> resolver();
// Refuses new streams and sockets, aborts the existing ones, removes the lwIP
// netif and stops the receive loop. Idempotent. `on_done` runs on the
// executor given at attach time once the netif is gone.
void shutdown(std::function<void()> on_done = {});
bool is_up() const;
const IpAddress &address() const { return cfg_.address; }
const IpAddress &gateway() const { return cfg_.gateway; }
const std::vector<IpAddress> &dns_servers() const { return cfg_.dns; }
int mtu() const { return cfg_.mtu; }
const std::string &label() const { return cfg_.label; }
Stack &stack() const { return stack_; }
struct Stats {
uint64_t rx_packets = 0;
uint64_t rx_bytes = 0;
uint64_t rx_malformed = 0; // not IPv4, or shorter than an IP header
uint64_t rx_dropped = 0; // lwIP refused it (no buffer, bad checksum)
uint64_t tx_packets = 0;
uint64_t tx_bytes = 0;
uint64_t tx_dropped = 0; // link's transmit queue was full
uint64_t tcp_opened = 0;
uint64_t tcp_failed = 0;
int64_t tcp_active = 0;
int64_t udp_active = 0;
};
Stats stats() const;
// netif->output. Public only because lwIP reaches it through a C function
// pointer, which cannot be a friend; not part of the interface callers use.
// Returns false when the link dropped the packet, which lwIP sees as ERR_MEM
// and retries -- the right answer, since a full transmit queue is congestion,
// not failure.
bool transmit_from_lwip(pbuf *p);
private:
friend class Stack;
friend class LwipTcpStream;
friend class LwipUdpSocket;
friend class DnsResolver;
struct Impl; // holds the lwIP netif; keeps lwip headers out of this file
Netif(Stack &stack, PacketLink &link, NetifConfig cfg,
asio::any_io_executor cb_ex);
// The lwIP netif, for the PCB pinning in lwip_tcp.cpp / lwip_udp.cpp. Only
// valid while up_; nullptr afterwards. Strand-only. Declared here rather than
// exposing Impl so that lwIP headers stay out of this file.
struct netif *lwip_netif();
static std::shared_ptr<Netif> create(Stack &stack, PacketLink &link,
NetifConfig cfg,
asio::any_io_executor cb_ex);
// Strand-only.
bool bring_up(std::string *err);
void tear_down();
void arm_receive();
void on_packet(const std::error_code &ec, size_t n);
void register_child(const std::shared_ptr<Closable> &c);
void note_tcp_opened(bool ok);
void note_tcp_closed();
void note_udp(int delta);
Stack &stack_;
PacketLink &link_;
NetifConfig cfg_;
asio::any_io_executor cb_ex_;
std::unique_ptr<Impl> impl_;
std::vector<uint8_t> rx_buf_;
bool up_ = false;
bool receiving_ = false;
// Live streams and sockets, weakly held so that dropping the caller's
// reference still tears the connection down (see stream.h). Used only to
// reach them at shutdown.
std::vector<std::weak_ptr<Closable>> children_;
mutable std::mutex stats_mu_;
Stats stats_;
std::mutex resolver_mu_;
std::weak_ptr<Resolver> resolver_; // weak on purpose -- see resolver()
};
// lwIP err_t -> std::error_code, in one place so the mapping is consistent
// between the TCP, UDP and DNS paths.
std::error_code lwip_error(int8_t err);
namespace detail {
// Objects holding lwIP state must be destroyed on the strand, and never from
// inside an lwIP callback -- which is exactly what lets those callbacks carry a
// raw `this` pointer as their argument. See common/strand_deleter.h; the direct
// egress needs the same trick for the same shape of reason.
using ovg::StrandDeleter;
} // namespace detail
} // namespace ovg::netstack