Files
ovgate/docs/FEASIBILITY.md
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

22 KiB
Raw Permalink Blame History

技术可行性分析

本文档是动手写代码之前的结论。所有判断都基于对 openvpn3 源码、VPNGate API 实测响应、 以及目标运行环境的实际验证,而不是推测。验证记录见文末「附录:验证方法」。


0. 结论速览

需求 判定 说明
OpenVPN 3 Core 作为协议实现 可行 需以 USE_TUN_BUILDER 编译,走 TunBuilder 路径
无 root 用户态处理流量 可行 socketpair + 用户态 TCP/IP 栈(lwIP
VPNGate 节点列表抓取与解析 可行 实测 1.29 MB / 96 节点 / 最长行 13529 字符
多指标自动选节点 可行 API 指标不可信,必须以本地实测为主
节点健康检查与自动重连 可行 分两层:隧道内重连 + 换节点
SOCKS5 + 用户名密码认证 可行 RFC 1928 / RFC 1929
1000 并发连接 ⚠️ 有条件可行 本进程能扛住;VPNGate 免费节点扛不住
TCP CONNECT / 超时 / 半关闭 / 异常断开 可行
DNS 不泄漏 可行 域名在隧道内解析
SOCKS5 UDP ASSOCIATE 支持(不支持 FRAG 分片) 见 §5
换节点后连接走新隧道 可行 make-before-break
换节点后保持已建立的 TCP 连接 不可能 见 §1,这是本需求中唯一真正不可能的部分
换节点后保持 UDP association 可行(意外收获) 见 §5.3

1. 不可能的部分:已建立的 TCP 连接无法跨节点迁移

这是需求里唯一物理上做不到的事情,必须说清楚为什么,否则后面的设计无从谈起。

1.1 为什么不可能

一条 TCP 连接由四元组 (src_ip, src_port, dst_ip, dst_port) 唯一标识,且这个四元组在 连接生命周期内不可变。当我们从 VPN 节点 A 切换到节点 B:

  1. 隧道内网 IP 变了。节点 A 通过 push ifconfig 给我们 10.211.1.6,节点 B 会给一个完全 不同的地址。我们协议栈的 src_ip 随之改变。
  2. 出口公网 IP 变了。对端服务器看到的来源地址从 A 的公网 IP 变成 B 的公网 IP。
  3. 节点 A 上的 NAT 会话表项随隧道断开而销毁。即便我们伪造原 src_ip,B 也不会、也无法把 包按 A 的映射发出去。

于是切换后我们发出的报文,在对端 TCP 看来来自一个陌生的四元组。对端的反应是 丢弃(不匹配任何 TCB)或回 RST,绝不会当作原连接的续传。这是 TCP 的设计本身,不是实现缺陷。

1.2 那些「能迁移」的协议为什么能

协议 迁移机制 为什么救不了我们
MPTCP (RFC 8684) 在新路径上加 subflow,用 token 关联到同一连接 需要对端也支持。公网上绝大多数服务器没开。且需要内核 MPTCP 栈
QUIC (RFC 9000 §9) 用 Connection ID 而非四元组标识连接,支持路径迁移 QUIC 跑在 UDP 上。见 §5.3——这部分我们确实能救
SCTP multihoming 连接绑定到地址集合 公网部署几乎为零

结论:对普通 TCP,无论在应用层做什么,都无法把一条已建立的连接搬到另一个出口 IP 上。 任何声称能做到的方案,要么是重新建连(破坏字节流语义),要么是没真正换出口。

1.3 我们实际能做到什么(工程解法)

需求里写了「如果做不到,那么断开所有旧连接」。直接全断是可以的,但太粗暴——正常网页浏览 场景下会造成明显可见的中断。本项目实现的是优雅降级的三段式方案

A. Make-before-break(先建后拆)

新旧两条隧道并存一段时间。每条隧道是一个独立的 Egress(独立的 OpenVPN 会话 + 独立的 lwIP 协议栈实例)。

  • 新的 SOCKS5 连接 → 绑定到新 Egress。
  • 已存在的连接 → 继续留在旧 Egress 上,完全不受影响,直到自然结束。

旧 Egress 进入 Draining 状态:不再接受新连接,引用计数归零即销毁。

B. 有界的排空窗口

排空不能无限期,否则一条长连接(SSH、WebSocket)会让旧隧道永远留着。设 drain_grace (默认 120s)。窗口内自然结束的连接是零感知的;窗口到期仍存活的连接被强制关闭——这就是需求 里说的「做不到就断开」,只是把「全部立刻断」缩小成了「少数超时的才断」。

同时并存的 draining Egress 数量有上限(默认 2),避免连续切换导致隧道堆积。

C. 零进度连接可透明重试

如果一条 SOCKS5 连接在切换发生时,CONNECT 还没完成、或者完成了但双向都还没传输过任何 应用字节,那么重建它对客户端是完全无感的——因为还没有任何字节流状态需要保留。这类连接 我们直接在新 Egress 上重连,客户端完全察觉不到。

实测中这能覆盖相当一部分场景(浏览器的预连接、连接池里的空闲连接)。

代价(必须诚实说明):并存期间内存和 CPU 翻倍(两个 lwIP 实例、两个 OpenVPN 会话), 出口 IP 在窗口内不唯一(旧连接走旧 IP)。对于「出口 IP 必须唯一」的场景,配置 switch.mode = hard 退化为「立即全断」。


2. 无 root:为什么必须自带 TCP/IP 栈

2.1 环境事实

目标环境 /dev/net/tun 不存在,进程 uid=1000。即便设备节点存在,TUNSETIFF 也需要 CAP_NET_ADMIN。所以:拿不到 tun 设备,也无权改路由表

(备选路径 unprivileged userns + netns 见 §7,本项目不作为默认。)

2.2 openvpn3 能否把裸 IP 包交给我们

能,而且是官方支持的路径。关键证据在 openvpn/tun/builder/client.hpp:59

Base::stream = new openvpn_io::posix::stream_descriptor(io_context, socket);

tun_builder_establish() 返回的 int fd 被直接包进 ASIO 的 POSIX stream_descriptor 之后按裸 IP 包做 async_read_some / async_write。这条路径上没有任何 tun 专属的 ioctl openvpn3 不关心 fd 究竟是不是 tun 设备——它只要一个能异步读写的 fd。

于是:

socketpair(AF_UNIX, SOCK_DGRAM, 0, sv)
  sv[0] ──► 交给 openvpn3tun_builder_establish 返回它)
  sv[1] ──► 我们自己持有,接到 lwIP netif

SOCK_DGRAM 而非 SOCK_STREAM 是刻意的:数据报边界天然等于 IP 包边界,不需要任何 额外的长度前缀或分帧逻辑,也不会出现半个包的情况。

这条路径由 CMake 选项 CLI_TUNBUILDER=ON(即 -DUSE_TUN_BUILDER)启用,openvpn3 自带的 test/ovpncli 就是这么用的,在 Linux 上是被官方 CI 覆盖的。

已对真实节点实测通过(非推断)。用 tools/tunnel_smoke.cpppublic-vpn-113@219.100.37.100:443/tcp 跑了一次完整会话,uid=1000、无 root、无 /dev/net/tun

tunnel up: ip=10.239.88.225/30 gw=10.239.88.226 mtu=1500
           dns=[10.239.254.254, 8.8.8.8] redirect_gw=true routes=0
tx: 手工构造的 IPv4+ICMP echo → 8.8.8.8
rx #1: IPv4 8.8.8.8 -> 10.239.88.225 ICMP len=44 (wire 44)
共 6 个 echo replytx=7 pkts/308 B (dropped 0),退出码 0

三个结论就此从「设计假设」变成「已验证事实」:

  1. openvpn3 接受 socketpair 的 fd,全程没有任何 tun 专属 ioctl;
  2. 收到的是裸 IPv4,没有 4 字节 tun_prefix 框架头——len=44 (wire 44) 即 IP 首部里的 total length 与数据报长度逐字节相等,多一个前缀就会是 48/44;
  3. 数据报边界确实等于 IP 包边界,读侧不需要任何分帧逻辑。

失败路径同样验证过:已死的节点走 RECONNECTING → RECONNECTING → down: CONNECTION_TIMEOUT,状态机与 fd 回收都干净。

UDP 传输未能在本沙箱内实测。 4 个 VPNGate UDP 节点全部 CONNECTION_TIMEOUT; 但同环境下向三台公网 NTP 服务器(UDP/123)发包也全部超时,而 DNS(UDP/53)正常。 判定为沙箱只放行 UDP/53,而不是 UDP 代码路径有问题——两者在本机无法区分。 影响有限:样本 96 个节点里仅 8 个提供 UDP,TCP/443 是绝大多数节点的唯一入口。

2.3 由此带来的简化

因为我们从不碰宿主机路由表:

  • tun_builder_add_route / tun_builder_reroute_gw 全部记录日志后返回 true,不做实际动作。 隧道内的「默认路由」由我们自己的 lwIP 实例定义——它只有一个 netif,天然全部流量走隧道。
  • socket_protect() 直接返回 true。它的用途是防止 VPN 自己的传输 socket 被路由进隧道造成环路; 我们没改宿主路由,不存在环路。
  • 不产生 DNS 泄漏风险的系统级配置改动(/etc/resolv.conf 一律不碰)。

副作用:本进程是一个纯粹的 SOCKS5 网关,不会把宿主机其它程序的流量导入 VPN。这符合 需求(对外提供 SOCKS5 服务),但要明确它不是一个系统级 VPN 客户端。

2.4 为什么是 lwIP

需要一个用户态 TCP/IP 栈。候选:

方案 语言 判断
lwIP C 选用。成熟、BSD 协议、可嵌入 C++、被 tun2socks/hev-socks5-tunnel 大规模验证
gVisor netstack Go 性能更好,但引入 Go 运行时和跨语言边界,与 C++ 的 openvpn3 拼接成本高
smoltcp Rust 同上,且 no_std 取向,功能面偏窄
自己写 不予考虑

lwIP 的已知短板(§4 展开):它是嵌入式栈,默认配置扛不住 1000 连接,必须重新配参数。

关键点:我们是连接的发起方,不是 tun2socks 那种「拦截别人的连接」。所以不需要 LWIP_HOOK_IP4_INPUT 之类的劫持技巧,直接用 raw API 的 tcp_new / tcp_connect 即可, 复杂度显著低于典型 tun2socks 实现。


3. VPNGate 的真实情况

3.1 API 格式(实测)

http://www.vpngate.net/api/iphone/ 返回:

*vpn_servers
#HostName,IP,Score,Ping,Speed,CountryLong,CountryShort,NumVpnSessions,Uptime,TotalUsers,TotalTraffic,LogType,Operator,Message,OpenVPN_ConfigData_Base64
public-vpn-113,219.100.37.100,3008408,10,287135107,Japan,JP,113,...,IyMjIyMj...
...
*

实测数据:响应 1.29 MB99 行(1 行 magic + 1 行表头 + 96 行数据 + 1 行 * 结束符), 最长行 13529 字符

「每行可能非常长」的根因:第 15 列是整个 .ovpn 配置文件的 base64,含内联 CA 证书、 客户端证书和私钥,单行轻松过 10 KB。

对解析器的硬性要求:

  • 不得使用固定大小的行缓冲区。必须流式或动态增长。
  • MessageOperator 列包含自由文本,实测有内容为 Daiyuu Nobori_ Japan. Academic Use Only. 的行——注意其中的 _ 是 VPNGate 对逗号的转义 替换,但不能假定所有行都被正确转义,必须按 RFC 4180 处理引号,并对列数不足的行直接跳过 而不是崩溃。
  • 必须容忍 Ping / Speed 为空字符串。

3.2 节点配置的真实内容(实测解码第一个节点)

dev tun
proto tcp
remote 219.100.37.100 443
cipher AES-128-CBC
data-ciphers AES-128-CBC
auth SHA1
resolv-retry infinite
nobind
persist-key
persist-tun
client
verb 3
<ca>...</ca> <cert>...</cert> <key>...</key>

重要结论:

  1. 不需要用户名密码。配置内联了客户端证书和私钥,是纯证书认证。(部分老节点需要 vpn/vpn,代码里保留了 fallback。)
  2. proto tcp + 443 端口。这意味着 TCP-over-TCP——隧道内的 TCP 跑在隧道外的 TCP 上。 两层拥塞控制叠加,丢包时会互相放大重传,即所谓 TCP meltdown。缓解手段:把 lwIP 的 TCP_MSS 调低避免分片、开 SACK、限制内层窗口不要过分激进。这是免费 VPNGate 的固有 特性,不是我们能修复的,只能缓解。同一节点若同时提供 UDP 入口应优先选 UDP。
  3. AES-128-CBC + SHA1。都是弱算法。openvpn3 在较新版本里默认拒绝部分遗留算法, 需要显式开 enableLegacyAlgorithms / tlsCertProfileOverride=legacy,否则握手直接失败。 较新的 VPNGate 配置已经带了 data-ciphers,所以 NCP 协商能正常工作。
  4. 节点跑的是 SoftEther 的 OpenVPN 兼容层,不是原版 openvpn 服务端。行为上有偏差, profile 需要做净化/重写(去掉 openvpn3 不认识的指令,补上必需的指令)。

3.3 API 指标可信度

Score / Ping / SpeedVPNGate 中心服务器到节点测出来的,不是我们到节点。 地理位置一变,排序完全失效。Speed 那个 287135107(≈287 Mbps)是节点自报的线路带宽, 不代表你能分到多少——NumVpnSessions=113 意味着 113 个人在抢。

因此选点策略必须以本地实测为主:我们自己对候选节点的 OpenVPN 端口做 TCP 握手计时, API 指标只用于初筛和排序前的先验权重。详见 ARCHITECTURE.md §选点。


4. 1000 并发:能到什么程度

拆成三个独立的瓶颈来看。

4.1 本进程(可控)

  • 不能一连接一线程。1000 线程 × 8 MB 默认栈 = 8 GB 虚拟内存,上下文切换开销也不可接受。 本项目用 ASIO 的 proactor 模型,min(hardware_concurrency, N) 个 io 线程跑事件循环, 每条连接是一组 handler + 一个 strand零专属线程
  • fd 消耗:每条 SOCKS5 连接占 1 个客户端 fd。出口侧走 lwIP,不消耗 fd(这是用户态栈 的一个实在好处)。1000 连接 ≈ 1000 + 少量固定 fd,默认 ulimit -n 1024 不够,启动时程序 会自己把 RLIMIT_NOFILE 提到 soft=hard 并在不足时告警。
  • 内存:每连接两个方向的中继缓冲(默认 32 KB 合计)+ lwIP 的 PCB 与重组队列。 1000 连接 ≈ 32 MB 中继缓冲 + lwIP 开销,量级在 100–200 MB。可接受。

4.2 lwIP(需要重新配置,且是真实风险)

lwIP 的默认配置是给 MCU 用的,MEMP_NUM_TCP_PCB 默认 5。直接用必然崩。本项目的 lwipopts.h 做了如下调整:

  • MEM_LIBC_MALLOC=1 + MEMP_MEM_MALLOC=1:所有 memp 池改走 libc malloc 彻底绕开静态池容量规划问题。代价是失去池化的确定性和一点性能,换来的是不会因为某个 池耗尽而莫名其妙丢包。对我们这种「不是硬实时、但要求鲁棒」的场景是正确的取舍。
  • 既然内存不再有硬上界,准入控制必须由我们自己做socks5.max_sessions(默认 1200) 在 accept 层直接拒绝超限连接,这是防 OOM 的唯一闸门。
  • LWIP_WND_SCALE=1 + TCP_RCV_SCALE=2LWIP_TCP_SACK_OUT=1:VPN 链路 BDP 大且有丢包, 没有窗口缩放和 SACK 会严重拖垮吞吐。
  • TCP_MSS=1360:保守值,避开隧道 MTU 导致的分片。

诚实的风险提示:lwIP 是单线程栈,所有连接的协议处理串行在一个线程上。1000 条高吞吐 连接会让这个线程成为瓶颈。1000 条普通连接(大部分时间空闲,如浏览器场景)没有问题。 如果需求真是 1000 条满速并发,lwIP 会先于 VPN 节点成为瓶颈,届时应考虑换 gVisor netstack 或多 Egress 分片。架构上 Egress 是接口,替换栈实现不影响上层。

4.3 VPNGate 节点(不可控,且是真正的天花板)

这是必须直说的部分:一个免费的 VPNGate 节点几乎不可能支撑 1000 条并发连接

  • 节点是志愿者用家用带宽跑的 SoftEther,实测样本里单节点已有 113 个并发会话在共享带宽。
  • SoftEther 对单会话的连接数和 NAT 表项有限制。
  • proto tcp 意味着我们所有流量还要挤在一条到节点的 TCP 连接里(openvpn3 单传输连接), 这条连接的拥塞窗口是全局共享的。

所以「1000 并发」的合理解读是:本网关的架构和实现能处理 1000 条并发连接而不劣化, 至于端到端能跑多少,取决于当时选中的节点。程序会导出 active_sessionsconnect_failure_rateegress_rtt_ms 等指标,节点扛不住时健康检查会触发换节点。


5. SOCKS5 相关的明确表态

5.1 UDP ASSOCIATE支持

明确回答需求里的问题:支持 SOCKS5 UDP ASSOCIATERFC 1928 §7)。实现要点:

  • UDP ASSOCIATE 请求到达后,在本地绑定一个 UDP socket 接收客户端数据报, 同时在 Egress(lwIP)里开一个 UDP PCB 作为出口。
  • 回复的 BND.ADDR/BND.PORT 使用可配置的 socks5.advertise_addr——不能想当然填 0.0.0.0,客户端需要一个它能到达的地址。这在容器/NAT 部署里是个常见坑。
  • association 的生命周期绑定到那条 TCP 控制连接。控制连接一断,UDP 立即回收(RFC 要求)。
  • 空闲超时独立计时(默认 60s),防止客户端不关控制连接导致 PCB 泄漏。

不支持的部分(明确声明)FRAG != 0 的分片数据报直接丢弃。RFC 1928 允许实现不支持 分片。理由:分片重组需要维护跨数据报状态且极易被用于放大攻击,而现实中几乎没有客户端使用 (curl、Chrome、SSH -D 都不用)。丢弃时会打 warn 日志并计数,不会静默。

5.2 DNS

域名解析在隧道内完成,不泄漏:

  • CONNECT 请求的 ATYP=DOMAINNAME 不在本地 getaddrinfo,而是走 Egress::async_resolve 即通过 lwIP 的 UDP 向 VPN 服务器 push 下来的 DNS 服务器发查询。
  • 自带解析器而非用 lwIP 内置 dns_gethostbyname:需要 TTL 感知的缓存、并发去重、 UDP 截断后回落 TCP、以及跨 Egress 实现复用(DirectEgress 也要能用)。lwIP 内置 DNS 这些都没有。
  • 若 VPN 未 push DNS,回落到配置的 dns.fallback_servers(默认 1.1.1.1 / 8.8.8.8), 仍然走隧道发出,不走宿主机。
  • 走 UDP ASSOCIATE 的 53 端口流量天然也在隧道内,无需特殊处理。

5.3 意外收获:UDP association 可以跨节点迁移

§1 说 TCP 不能迁移。但 UDP 可以,因为它无连接——没有需要保留的序列号状态。

节点切换时,对于每个存活的 UDP association,我们只需在新 Egress 上重新 bind 一个 UDP PCB 保持面向客户端的那个 socket 和端口不变。客户端完全无感。

对于 QUIC 这类自带连接迁移(RFC 9000 §9,用 Connection ID 而非四元组标识连接)的协议, 它会自己完成路径验证并继续跑——也就是说,通过我们的 SOCKS5 代理的 QUIC/HTTP3 连接 能够真正做到跨 VPN 节点切换而不中断。这是 TCP 拿不到的待遇。

代价:出口 IP 变化会让部分服务端(把 UDP 会话绑定到源 IP 的)判定为新会话。这个由应用层 自己处理,代理层无能为力。

5.4 不实现的部分

  • BIND 命令:明确回 X'07' Command not supported。它需要在出口侧监听端口等待入站连接, 而 VPNGate 节点在 NAT 后面,根本收不到入站连接。实现了也不能用。
  • GSSAPI 认证(RFC 1961):不实现,回 X'FF'(无可接受方法)。
  • SOCKS4/4a:不实现。

6. 其它已识别的工程风险

风险 影响 缓解
openvpn3 connect() 是阻塞调用 每条隧道需独占一个线程 每个 Egress 一个 ovpn 线程;切换期间峰值 2 个。可接受
节点 profile 含 openvpn3 不识别的指令 直接 option_error 连不上 ProfileSanitizer 白名单重写,见 ovpn/profile_sanitizer
弱算法被新版 openvpn3 拒绝 握手失败 显式 enableLegacyAlgorithms=true + tlsCertProfileOverride="legacy"
VPNGate API 限流 / 被墙 / 返回 HTML 拿不到节点列表 磁盘缓存 + 多镜像 + 指数退避;缓存可用时不阻塞启动
隧道内 MTU 与实际路径不符 大包黑洞 TCP_MSS 保守取值 + 采纳 tun_builder_set_mtu
切换风暴(反复横跳) 连接持续被打断 迟滞判定 + 最小切换间隔 + 候选必须显著更优才切
socketpair 数据报队列满 丢 IP 包 放大 SO_SNDBUF/SO_RCVBUF;丢包由 TCP 重传兜底(这是 IP 层,允许丢)
lwIP 单线程瓶颈 高吞吐时吞吐受限 见 §4.2;架构上可替换

7. 备选方案:unprivileged userns + netns(未采用)

有 root 或允许非特权 user namespace 时,还有一条路:

unshare -Ur -n  →  在新 netns 里拥有 CAP_NET_ADMIN  →  创建真 tun 设备  →  用内核 TCP/IP 栈

优点很实在:内核栈的性能、成熟度、可观测性(sstcpdump)都远超 lwIP,1000 并发毫无压力。

不采用的原因:

  1. 目标环境 /dev/net/tun 不存在,这条路直接堵死。
  2. 需要 mount namespace 配合 bind-mount /dev/net/tun,部署复杂度陡增。
  3. SOCKS5 监听端口在宿主 netns、出口 socket 在 VPN netns,需要跨 netns 传 fdSCM_RIGHTS), 引入一个 helper 进程。
  4. 很多容器环境(无 CAP_SYS_ADMIN、seccomp 限制 unshare)里不可用。

架构上 Egress 是接口,将来要加 NetnsEgress 不影响任何上层代码。这条路留了口子但不修


附录:验证方法

本文所有事实性断言的来源:

# 环境
uname -a; id; ls -l /dev/net/tun          # → 无 tun 设备,uid=1000

# VPNGate API 实测
curl -s -o /tmp/v.csv -w '%{size_download}' http://www.vpngate.net/api/iphone/
#   → 1293288 bytes
awk '{if(length($0)>m)m=length($0)}END{print m}' /tmp/v.csv
#   → 13529
# 解码第 1 个节点的 base64 配置 → 见 §3.2

# openvpn3 tun builder 路径
git clone --depth 1 https://github.com/OpenVPN/openvpn3
grep -n 'stream_descriptor' openvpn/tun/builder/client.hpp
#   → :59  Base::stream = new openvpn_io::posix::stream_descriptor(io_context, socket);
cmake -S . -B build -G Ninja -DCLI_TUNBUILDER=ON && cmake --build build --target ovpncli
#   → 编译成功,0 warning

# socketpair-as-tun:对真实节点的端到端验证(§2.2)
cmake -S . -B build -DOVG_WITH_TUNNEL=ON && cmake --build build --target ovg_tunnel_smoke
./build/src/ovg_tunnel_smoke --node public-vpn-113 --ping 8.8.8.8 --seconds 15
#   → tunnel up 10.239.88.225/306 个 ICMP echo replyexit 0

# 沙箱 UDP 出网能力(解释为何 UDP 节点无法实测)
#   DNS  8.8.8.8:53   → 56 字节应答,正常
#   NTP  216.239.35.0 / 129.6.15.28 / 162.159.200.1 :123 → 三个全部超时

依赖版本:openvpn3 master2026-07 快照)、lwIP 2.2.1、OpenSSL 3.5.6、lz4 1.10.0、 fmt 10.1.1、ASIO 1.30standalone)、GCC 14.2 / C++20。