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

295 lines
14 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.
# 架构设计
前置阅读:[`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.*
```