forked from cloud/ovgate
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>
295 lines
14 KiB
Markdown
295 lines
14 KiB
Markdown
# 架构设计
|
||
|
||
前置阅读:[`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.*
|
||
```
|