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>
14 KiB
架构设计
前置阅读: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
上层唯一看得见的出口抽象。
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
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 引用计数即排空
// 会话创建时
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.*