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

14 KiB
Raw Permalink Blame History

架构设计

前置阅读: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} → commonnetstack 不知道 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 — 换节点(我们做) HealthMonitorinterval(默认 15s)对 active Egress 采样:

信号 采集方式 权重
隧道状态 openvpn3 eventDISCONNECTED/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 级——出问题时必须能从日志还原决策过程。
  • 指标/metricsPrometheus 文本格式)导出会话数、切换次数、各 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.*