forked from cloud/ovgate
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>
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
# 架构设计
|
||||
|
||||
前置阅读:[`FEASIBILITY.md`](FEASIBILITY.md)。本文假定读者已接受其中的结论,特别是
|
||||
「已建立的 TCP 连接无法跨节点迁移」和「必须自带用户态 TCP/IP 栈」。
|
||||
|
||||
---
|
||||
|
||||
## 1. 全局数据流
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Control Plane │
|
||||
│ VpnGateClient → NodeStore → Selector │
|
||||
│ HealthMonitor → SwitchController │
|
||||
└───────────────┬──────────────────────────────┘
|
||||
│ switch_to(node)
|
||||
▼
|
||||
SOCKS5 client ┌──────────────────────┐
|
||||
──────────────► ┌─────┤ EgressManager ├──── owns ────┐
|
||||
(TCP :1080) │ │ active / draining[] │ │
|
||||
│ └──────────────────────┘ │
|
||||
┌──────▼───────┐ │
|
||||
│ socks5::Server│ acquire() ┌────────────────────▼──────────────┐
|
||||
│ ├ Auth │────────────► │ Egress (tunnel #N) │
|
||||
│ ├ Session │ │ ┌──────────────────────────────┐ │
|
||||
│ └ UdpRelay │◄──TcpStream──┤ │ netstack::Stack (lwIP,NO_SYS)│ │
|
||||
└──────┬───────┘ │ │ tcp_new/tcp_connect/... │ │
|
||||
│ │ └──────────────┬───────────────┘ │
|
||||
│ relay │ raw IP pkts │ (socketpair) │
|
||||
▼ │ ┌──────────────▼───────────────┐ │
|
||||
app payload │ │ ovpn::TunnelClient │ │
|
||||
│ │ (ClientAPI::OpenVPNClient) │ │
|
||||
│ └──────────────┬───────────────┘ │
|
||||
└─────────────────┼──────────────────┘
|
||||
▼
|
||||
VPNGate node (TCP/443)
|
||||
```
|
||||
|
||||
关键约束:**每条 SOCKS5 会话在创建时绑定一个 `shared_ptr<Egress>`,终生不变。**
|
||||
这一条决定了整个切换机制的正确性——见 §5。
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块边界
|
||||
|
||||
每个模块一个目录,模块间只通过头文件里的接口交互,禁止跨模块引用实现细节。
|
||||
|
||||
| 模块 | 职责 | 不负责 |
|
||||
|---|---|---|
|
||||
| `common/` | 日志、配置、错误码、Endpoint、缓冲区、指标 | 任何业务逻辑 |
|
||||
| `vpngate/` | 抓 API、解析 CSV、节点模型、磁盘缓存 | 决定用哪个节点 |
|
||||
| `selector/` | 打分、探测、黑名单、选出候选节点 | 建立连接 |
|
||||
| `ovpn/` | 包装 openvpn3、profile 净化、socketpair 管理 | TCP/IP 语义 |
|
||||
| `netstack/` | lwIP 生命周期、netif、TcpStream/UdpSocket、DNS | 知道 VPN 的存在 |
|
||||
| `egress/` | 出口抽象、隧道组装、切换与排空 | SOCKS5 协议 |
|
||||
| `socks5/` | RFC 1928/1929、会话、中继、UDP relay | 出口怎么来的 |
|
||||
| `health/` | 健康评分、切换决策 | 执行切换(交给 EgressManager) |
|
||||
| `app/` | 装配、CLI、配置加载、admin 接口、信号 | 上述任何逻辑 |
|
||||
|
||||
依赖方向严格单向:`app → {health, socks5, egress, selector} → {netstack, ovpn, vpngate} → common`。
|
||||
`netstack` 不知道 `ovpn` 的存在(它只拿到一个 fd),`socks5` 不知道 `ovpn`/`netstack` 的存在
|
||||
(它只拿到 `Egress` 接口)。这是保证长期可维护的核心。
|
||||
|
||||
---
|
||||
|
||||
## 3. 线程模型
|
||||
|
||||
明确的线程边界,避免「哪个线程能调什么」变成口口相传的知识。
|
||||
|
||||
| 线程 | 数量 | 跑什么 | 规则 |
|
||||
|---|---|---|---|
|
||||
| **frontend io** | `min(hw_concurrency, 4)` | SOCKS5 accept / 中继 / admin HTTP | 每会话一个 `strand` 串行化 |
|
||||
| **stack** | 每个 Egress 1 个 | lwIP 全部调用 + 包收发 + 定时器 | **所有 lwIP API 只能在此线程调** |
|
||||
| **ovpn** | 每个 Egress 1 个 | `OpenVPNClient::connect()`(阻塞) | 只通过回调与外界交互 |
|
||||
| **control** | 1 | 抓 API、探测、健康检查、切换编排 | 不做任何阻塞 IO 之外的重活 |
|
||||
|
||||
稳态(1 个 Egress):4 + 1 + 1 + 1 = 7 线程。
|
||||
切换期间(2 个 Egress)峰值 9 线程。与连接数无关——这是「不能一连接一线程」的直接体现。
|
||||
|
||||
跨线程只用两种手段:`asio::post` 到目标 executor,或无锁原子。**不允许**跨线程持锁调用。
|
||||
|
||||
`netstack::Stack::post(fn)` 是唯一进入 lwIP 线程的入口;所有 `TcpStream` 的公开方法内部都
|
||||
自动 post,因此调用者可以从任意线程安全调用。完成回调则 post 回调用方的 executor。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键接口
|
||||
|
||||
### 4.1 `egress::Egress`
|
||||
|
||||
上层唯一看得见的出口抽象。
|
||||
|
||||
```cpp
|
||||
class Egress {
|
||||
virtual void async_connect_tcp(const Endpoint&, milliseconds timeout, ConnectHandler) = 0;
|
||||
virtual void async_bind_udp(UdpBindHandler) = 0;
|
||||
virtual void async_resolve(const std::string& host, ResolveHandler) = 0;
|
||||
virtual EgressState state() const = 0;
|
||||
virtual EgressStats stats() const = 0;
|
||||
};
|
||||
```
|
||||
|
||||
三个实现:
|
||||
|
||||
- `TunnelEgress` — lwIP + openvpn3,生产用。
|
||||
- `DirectEgress` — 直接用宿主 socket,用于本地测试 SOCKS5 逻辑而不需要 VPN。
|
||||
- (未来)`NetnsEgress` — 见 FEASIBILITY §7。
|
||||
|
||||
`socks5` 模块只依赖这个接口,因此可以完全脱离 VPN 做单元测试。
|
||||
|
||||
### 4.2 `netstack::TcpStream`
|
||||
|
||||
```cpp
|
||||
class TcpStream {
|
||||
virtual void async_read_some(MutableBuffer, ReadHandler) = 0; // cb(ec, n)
|
||||
virtual void async_write(ConstBuffer, WriteHandler) = 0; // 全量写
|
||||
virtual void shutdown_send() = 0; // 半关闭 → FIN
|
||||
virtual void close() = 0;
|
||||
};
|
||||
```
|
||||
|
||||
**流控是显式的**:lwIP 的 `recv` 回调收到数据后,我们只把它挂进 per-conn 队列,
|
||||
**不立刻调 `tcp_recved()`**。只有当上层真的把字节读走了,才按消费量调 `tcp_recved(pcb, n)`
|
||||
推进接收窗口。这样对端的发送速率会被 SOCKS5 客户端的消费速率自然反压,不会在代理里堆积。
|
||||
|
||||
写方向对称:`tcp_write()` 受 `tcp_sndbuf()` 限制,写不下的部分挂起,在 `sent` 回调里续写;
|
||||
`async_write` 的完成回调在**数据被 lwIP 发送缓冲接纳时**触发(而非收到 ACK 时)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 节点切换:make-before-break
|
||||
|
||||
这是本项目最核心的机制,对应 FEASIBILITY §1.3。
|
||||
|
||||
### 5.1 状态机
|
||||
|
||||
```
|
||||
┌────────┐ need_switch ┌───────────┐ picked ┌────────────┐
|
||||
│ Idle ├───────────────►│ Selecting ├──────────►│ Connecting │
|
||||
└────▲───┘ └─────┬─────┘ └──────┬─────┘
|
||||
│ │ no candidate │ tunnel up
|
||||
│ ▼ ▼
|
||||
│ (backoff) ┌────────────┐
|
||||
│ │ Promoting │ 原子换 active_
|
||||
│ └──────┬─────┘
|
||||
│ drain done / grace expired │
|
||||
└───────────────────┬───────────────────────────────┘
|
||||
▼
|
||||
┌────────────┐
|
||||
│ Draining │ 旧 Egress 引用计数归零即销毁
|
||||
└────────────┘
|
||||
Connecting 失败 → 保留旧 Egress,惩罚候选,退避重试
|
||||
```
|
||||
|
||||
**关键:`Promoting` 之前旧隧道一直在服务。** 新隧道建不起来对现有流量零影响。
|
||||
|
||||
### 5.2 引用计数即排空
|
||||
|
||||
```cpp
|
||||
// 会话创建时
|
||||
auto egress = manager.acquire(); // shared_ptr,持有到会话结束
|
||||
|
||||
// 切换时
|
||||
{
|
||||
std::lock_guard lk(mu_);
|
||||
draining_.push_back({std::move(active_), now + drain_grace});
|
||||
active_ = new_egress; // 之后 acquire() 返回新的
|
||||
}
|
||||
```
|
||||
|
||||
旧 Egress 的 `shared_ptr` 引用计数天然就是「还有多少会话在用它」。归零 → 析构 → 隧道关闭。
|
||||
不需要单独维护会话表,也不会漏。
|
||||
|
||||
`drain_grace` 到期时,对仍存活的会话调用 `force_close()`——这就是需求里「做不到就断开」的
|
||||
那部分,只是范围缩到了最小。
|
||||
|
||||
### 5.3 零进度连接透明重试
|
||||
|
||||
`Session` 记录 `bytes_up + bytes_down`。切换发生时若该会话仍为 0,说明还没有字节流状态:
|
||||
|
||||
```
|
||||
if (session.total_bytes() == 0 && session.state() == Established)
|
||||
→ 在新 Egress 上重连目标,替换 TcpStream,客户端无感
|
||||
else
|
||||
→ 留在旧 Egress 排空
|
||||
```
|
||||
|
||||
### 5.4 UDP association 重新归巢
|
||||
|
||||
对每个存活的 UDP association:保持面向客户端的 socket 不变,只在新 Egress 上重开出口 PCB。
|
||||
客户端完全无感(FEASIBILITY §5.3)。
|
||||
|
||||
### 5.5 防抖动
|
||||
|
||||
| 保护 | 默认值 |
|
||||
|---|---|
|
||||
| 最小切换间隔 | 60s |
|
||||
| 候选必须优于当前的幅度 | 分数高 20% 以上 |
|
||||
| 连续判定为不健康的窗口数 | 3 |
|
||||
| 同时 draining 的 Egress 上限 | 2 |
|
||||
| 切换失败后退避 | 指数,30s → 480s 封顶 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 选点策略
|
||||
|
||||
API 指标不可信(FEASIBILITY §3.3),所以分两阶段。
|
||||
|
||||
**阶段一:先验筛选(便宜)** — 用 API 字段过滤和粗排:
|
||||
|
||||
```
|
||||
prior = w1·norm(Score) + w2·norm(Speed) + w3·(1/(1+NumVpnSessions)) + w4·norm(Uptime)
|
||||
- country_penalty - protocol_penalty(tcp 比 udp 扣分)
|
||||
```
|
||||
|
||||
取 top-K(默认 12)进入阶段二。
|
||||
|
||||
**阶段二:本地实测(贵,但准)** — 对 K 个候选**并发**做 TCP 握手计时(连到节点的
|
||||
OpenVPN 端口,成功即断),取多次采样的中位数作为 `rtt_ms`:
|
||||
|
||||
```
|
||||
score = α·(1/rtt_ms) + β·prior + γ·history_success_rate - δ·recent_failure_penalty
|
||||
```
|
||||
|
||||
`history_*` 来自持久化的 `node_history.json`:每个节点记录成功/失败次数、
|
||||
历史平均吞吐、最近一次失败时间。**同一个节点连续失败会被指数退避拉黑**,避免反复撞墙。
|
||||
|
||||
分数最高者胜出。若与当前节点分数差距不足 20%,**不切换**(§5.5 迟滞)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 健康检查
|
||||
|
||||
两层,职责不同:
|
||||
|
||||
**L1 — 隧道内重连(openvpn3 自己做)**
|
||||
`ping` / `ping-restart` 触发的会话内重连。此时 socketpair fd 通过 `tunPersist=true` 保持不变,
|
||||
**lwIP 实例和所有连接都不受影响**。这是最廉价的自愈,覆盖绝大多数瞬时抖动。
|
||||
|
||||
**L2 — 换节点(我们做)**
|
||||
`HealthMonitor` 每 `interval`(默认 15s)对 active Egress 采样:
|
||||
|
||||
| 信号 | 采集方式 | 权重 |
|
||||
|---|---|---|
|
||||
| 隧道状态 | openvpn3 event(`DISCONNECTED`/`RECONNECTING`)| 一票否决 |
|
||||
| 隧道内 RTT | 通过 Egress 做一次 DNS 查询计时 | 高 |
|
||||
| 连接成功率 | 滑动窗口统计 SOCKS5 CONNECT 结果 | 高 |
|
||||
| 吞吐停滞 | 有活跃会话但零字节推进的持续时长 | 中 |
|
||||
| 丢包 | lwIP 重传计数 | 中 |
|
||||
|
||||
综合分低于阈值并**连续 3 个窗口**成立 → 通知 `SwitchController`。单次抖动不触发。
|
||||
|
||||
---
|
||||
|
||||
## 8. SOCKS5 实现要点
|
||||
|
||||
- **认证**:RFC 1929 用户名/密码。凭据从配置文件或 `--auth-file` 读取,
|
||||
口令存储为 `sha256(salt || password)`,比较用常量时间。支持仅 `NO_AUTH`(显式配置才允许)。
|
||||
- **握手超时**、**连接超时**、**空闲超时** 三个独立计时器,任何一个到期都干净关闭。
|
||||
- **半关闭**:一方 EOF → 对另一方 `shutdown_send()`,另一方向继续传输,直到双向都结束。
|
||||
这是很多代理实现的 bug 源头(收到 EOF 就整条关掉,会截断响应)。
|
||||
- **异常断开**:RST / 进程退出 / Egress 销毁,都通过 `Session::force_close()` 统一路径回收,
|
||||
保证 `TcpStream` 和 fd 不泄漏。
|
||||
- **准入控制**:`max_sessions` 在 accept 层生效,超限直接拒绝——这是 lwIP 用 malloc 之后
|
||||
防 OOM 的唯一闸门(FEASIBILITY §4.2)。
|
||||
- **UDP ASSOCIATE**:见 FEASIBILITY §5.1。`FRAG != 0` 丢弃并计数。
|
||||
|
||||
---
|
||||
|
||||
## 9. 可观测性
|
||||
|
||||
- **日志**:分级(trace/debug/info/warn/error),带模块标签和会话 ID。
|
||||
切换、选点、健康判定这三条链路是 info 级——出问题时必须能从日志还原决策过程。
|
||||
- **指标**:`/metrics`(Prometheus 文本格式)导出会话数、切换次数、各 Egress 的 RTT 与流量、
|
||||
DNS 缓存命中率、拒绝计数等。
|
||||
- **admin 接口**:`/status`(当前节点、Egress 状态、draining 列表)、
|
||||
`POST /switch`(手动触发切换,运维逃生口)、`/nodes`(当前候选池及分数)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── common/ logging.h config.h error.h endpoint.h buffer.h metrics.h
|
||||
├── vpngate/ api_client.* csv_parser.* node.h node_store.*
|
||||
├── selector/ prober.* scorer.* selector.*
|
||||
├── ovpn/ tunnel_client.* profile_sanitizer.* packet_pipe.*
|
||||
├── netstack/ lwip_stack.* lwip_tcp.* lwip_udp.* dns_resolver.* lwipopts.h
|
||||
├── egress/ egress.h tunnel_egress.* direct_egress.* egress_manager.*
|
||||
├── socks5/ protocol.* auth.* server.* session.* udp_relay.*
|
||||
├── health/ health_monitor.* switch_controller.*
|
||||
└── app/ main.cpp app.* admin_server.*
|
||||
```
|
||||
@@ -0,0 +1,431 @@
|
||||
# 技术可行性分析
|
||||
|
||||
本文档是动手写代码之前的结论。所有判断都基于对 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] ──► 交给 openvpn3(tun_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 reply,tx=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 MB,99 行(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 接收客户端数据报,
|
||||
同时在 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 栈
|
||||
```
|
||||
|
||||
优点很实在:内核栈的性能、成熟度、可观测性(`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/30;6 个 ICMP echo reply;exit 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 master(2026-07 快照)、lwIP 2.2.1、OpenSSL 3.5.6、lz4 1.10.0、
|
||||
fmt 10.1.1、ASIO 1.30(standalone)、GCC 14.2 / C++20。
|
||||
Reference in New Issue
Block a user