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

432 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术可行性分析
本文档是动手写代码之前的结论。所有判断都基于对 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`
```cpp
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.cpp`
`public-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)发包也全部超时,而 DNSUDP/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。
对解析器的硬性要求:
- 不得使用固定大小的行缓冲区。必须流式或动态增长。
- `Message``Operator` 列包含**自由文本**,实测有内容为
`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` / `Speed` 是 **VPNGate 中心服务器**到节点测出来的,不是**我们**到节点。
地理位置一变,排序完全失效。`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=2``LWIP_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_sessions`
`connect_failure_rate``egress_rtt_ms` 等指标,节点扛不住时健康检查会触发换节点。
---
## 5. SOCKS5 相关的明确表态
### 5.1 UDP ASSOCIATE**支持**
明确回答需求里的问题:**支持 SOCKS5 UDP ASSOCIATE**RFC 1928 §7)。实现要点:
- `UDP ASSOCIATE` 请求到达后,在本地绑定一个 UDP socket 接收客户端数据报,
同时在 EgresslwIP)里开一个 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 栈
```
优点很实在:内核栈的性能、成熟度、可观测性(`ss``tcpdump`)都远超 lwIP,1000 并发毫无压力。
不采用的原因:
1. 目标环境 `/dev/net/tun` **不存在**,这条路直接堵死。
2. 需要 mount namespace 配合 bind-mount `/dev/net/tun`,部署复杂度陡增。
3. SOCKS5 监听端口在宿主 netns、出口 socket 在 VPN netns,需要跨 netns 传 fd`SCM_RIGHTS`),
引入一个 helper 进程。
4. 很多容器环境(无 `CAP_SYS_ADMIN`、seccomp 限制 `unshare`)里不可用。
架构上 `Egress` 是接口,将来要加 `NetnsEgress` 不影响任何上层代码。这条路**留了口子但不修**。
---
## 附录:验证方法
本文所有事实性断言的来源:
```bash
# 环境
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。