# 架构设计 前置阅读:[`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`,终生不变。** 这一条决定了整个切换机制的正确性——见 §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.* ```