# openvpngate 一个 OpenVPN 客户端 + SOCKS5 代理网关:进程内建立到 VPNGate 节点的 OpenVPN 隧道, 对外提供一个带用户名/密码认证的 SOCKS5 服务,并在节点质量下降时自动换节点。 **全程不需要 root。** 没有 tun 设备、没有路由表改动、没有 capability——隧道在用户态 终结(lwIP),进程只是一个普通的监听程序。 ``` SOCKS5 客户端 ──► socks5::Server ──► egress::Egress ──► lwIP ──► openvpn3 ──► VPNGate 节点 (认证/CONNECT/UDP) (可热替换) (用户态 TCP/IP) ``` 先读哪一份: | 文档 | 内容 | |---|---| | [docs/FEASIBILITY.md](docs/FEASIBILITY.md) | 动手前的可行性结论。**需求中唯一不可能的部分在 §1**;UDP ASSOCIATE 的明确表态在 §5;1000 并发的真实天花板在 §4 | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 模块边界、线程模型、切换状态机、选点与健康检查的具体算法 | | [etc/openvpngate.conf](etc/openvpngate.conf) | 全部配置项,每一项都带默认值和「为什么是这个默认值」 | | [docs/DOCKER.md](docs/DOCKER.md) | 容器部署。**容器里有三处宿主机默认值是错的**(§3);`cap_drop: ALL` 为什么能成立(§5);GHCR 发布与 CI(§9) | --- ## 1. 需求对照 | 需求 | 状态 | 说明 | |---|---|---| | 基于 OpenVPN 3 Core | ✅ | 以 `USE_TUN_BUILDER` 编译,走 TunBuilder 路径拿裸 IP 包 | | 连接 VPNGate 节点 | ✅ | 自动抓取 `http://www.vpngate.net/api/iphone/` 并解析 | | 解析超长行 CSV | ✅ | 实测最长行 13529 字符(整个 .ovpn 以 base64 放在最后一列),解析器按流处理,不按行切分 | | 多指标自动选点 | ✅ | 延迟(**本地实测握手**)+ API 指标 + 本地历史成功率,见 ARCHITECTURE §6 | | 节点健康检查与自动重连 | ✅ | 两层:隧道内重连由 openvpn3 负责,换节点由 health + egress 负责 | | SOCKS5 + 用户名密码认证 | ✅ | RFC 1928 / RFC 1929 | | ~1000 并发、不每连接一线程 | ✅ | asio 异步 I/O,固定线程池(默认 ≤4),连接数只占内存不占线程 | | TCP CONNECT / DNS / 超时 / 半关闭 / 异常断开 | ✅ | DNS 在隧道内解析,不泄漏 | | **SOCKS5 UDP ASSOCIATE** | ✅ **支持**,但**不支持分片**(FRAG≠0 直接丢弃并计数) | 见 FEASIBILITY §5.1 | | 换节点后新连接走新隧道 | ✅ | make-before-break:新隧道完全就绪后才切换 | | **换节点后保持已建立的 TCP 连接** | ❌ **不可能** | 见下 | | 做不到就断开旧连接 | ✅ | 旧 egress 进入 drain,宽限期内继续服务,到期全部关闭 | ### 唯一不可能的部分 **已建立的 TCP 连接无法跨 VPN 节点迁移。** 换节点意味着换公网出口 IP,而对端 socket 的 四元组里写死了旧 IP;序列号、窗口、拥塞状态全在对端内核里,我们既读不到也搬不走。任何 声称做到的方案,要么是在对端也装了东西(MPTCP、QUIC 连接迁移),要么是没换出口 IP。 完整论证见 FEASIBILITY §1。 工程上能做到的三件事(都已实现): 1. **新连接**立刻走新隧道——这是 make-before-break 的意义; 2. **零进度连接透明重试**:还没搬运过任何字节的 TCP 连接不含对端状态,直接在新隧道上重 拨,客户端完全无感(`switch.retry_zero_progress`); 3. **UDP association 原地换巢**:UDP 无连接状态,只换出口 socket,客户端看到的中继端口 不变(`switch.rehome_udp`)——这是设计时的意外收获,FEASIBILITY §5.3; 其余(已搬过字节的 TCP)在宽限期 `switch.drain_grace` 内继续用旧隧道服务,到期关闭。 --- ## 2. 构建 系统依赖(Debian/Ubuntu): ```sh sudo apt install -y build-essential cmake pkg-config \ libasio-dev libssl-dev liblz4-dev libfmt-dev git ``` openvpn3 和 lwIP 是源码依赖,默认由 CMake `FetchContent` 拉取;已有 checkout 可以直接指过去 (`-DOVG_OPENVPN3_DIR=... -DOVG_LWIP_DIR=...`),避免每次配置都联网。 > 本工作副本的两个 build 目录正是这么配的,指向 `/tmp/ovpn3` 和 `/tmp/lwip`。`/tmp` 会在 > 重启后消失,届时重新 configure(不带这两个参数即可让 FetchContent 重新拉取)。 ```sh cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j ``` 两个开关: | 选项 | 默认 | 含义 | |---|---|---| | `OVG_WITH_TUNNEL` | `ON` | 编译 OpenVPN3 + lwIP 隧道出口。**关掉**则只有 `direct` 出口,用于在没有网络依赖的机器上开发和跑测试;此时二进制会拒绝以 `egress.mode = tunnel` 启动,而不是悄悄明文代理 | | `OVG_BUILD_TESTS` | `ON` | 单元测试 | 产物:`build/src/openvpngate`(主程序)、`build/tests/ovg_tests`(测试)、 `build/src/ovg_tunnel_smoke`(只建隧道、ping、退出的诊断工具,仅 `OVG_WITH_TUNNEL=ON`,见 §6)。 --- ## 3. 运行 ```sh cp etc/openvpngate.conf ./openvpngate.conf # 改 [users] 里的口令 ./build/src/openvpngate -c openvpngate.conf --check # 只校验配置并打印,不启动 ./build/src/openvpngate -c openvpngate.conf ``` 命令行参数只有六个,都用来覆盖配置文件里的同名项: ``` -c, --config PATH 配置文件 --check 校验并打印最终配置后退出 --listen ADDR:PORT 覆盖 socks5 监听地址 --egress tunnel|direct --log-level trace|debug|info|warn|error|off --no-admin 关掉管理接口 -V, --version -h, --help ``` 信号:`SIGHUP` 重新加载认证文件和节点列表(不断开任何在途连接);`SIGINT`/`SIGTERM` 优雅退出(停止 accept → 排空 → 拆隧道);退出过程中再来一次 `SIGINT` 立即退出。 ### 不带 VPN 先验证代理本身 `egress.mode = direct` 让所有流量走宿主机 socket,**不经过任何 VPN**。它存在的唯一理由 是把「SOCKS5 实现对不对」和「隧道通不通」两件事分开调试: ```sh ./build/src/openvpngate -c openvpngate.conf --egress direct --listen 127.0.0.1:1080 curl -sS --socks5-hostname alice:changeme@127.0.0.1:1080 https://example.com -o /dev/null -w '%{http_code}\n' ``` `--socks5-hostname` 让 curl 把域名交给代理解析(DNS 不泄漏路径);`--socks5` 则是本地解析。 两条路径都支持。 ### 用容器跑 ```sh cp docker/socks5.auth.example docker/socks5.auth # 改口令 docker compose up -d --build ``` 镜像以 uid 10001、`cap_drop: ALL`、只读 rootfs 运行——不需要 `NET_ADMIN`,不需要 `/dev/net/tun`,这正是用户态终结隧道换来的东西,而容器让这句话第一次可以当场验证。 **但宿主机的默认配置在容器里有两处是静默错误的**(监听环回口、状态文件用相对路径), 所以镜像自带一份 `docker/openvpngate.conf` 覆盖它们。细节、暴露面取舍、以及这套编排 **哪些部分没有被真正跑过**,见 [docs/DOCKER.md](docs/DOCKER.md)。 --- ## 4. 管理接口 默认 `127.0.0.1:9080`,**没有认证**,所以务必留在环回口上。 | 路由 | 用途 | |---|---| | `GET /status` | 当前节点、切换阶段、切换次数/失败次数、排空中的 egress | | `GET /nodes` | 最近一次选点排名,含每个节点的得分、实测 RTT 和「为什么是这个分」 | | `GET /sessions` | 在线会话,含各自挂在哪个 egress 上 | | `GET /health` | 最近若干次健康窗口的原始采样 | | `GET /metrics` | Prometheus 文本格式 | | `GET /healthz` | 存活探针,只有 200/503 | | `POST /switch` | 手动换节点。被拒绝时返回**具体原因**(四种:本构建没有隧道 / 已在切换中 / 防抖动窗口未到 / 上次失败正在退避),不是一句 `false` | ```sh curl -s localhost:9080/status | jq curl -s -XPOST localhost:9080/switch ``` --- ## 5. 测试 ```sh ./build/tests/ovg_tests # 全部 ./build/tests/ovg_tests socks5 # 子串过滤 ctest --test-dir build ``` 测试不碰公网:CSV 解析吃固定样本,选点探测打的是本文件自己起的环回监听,隧道出口在 `EgressManager` 上留了一个 factory 接缝,由可精确复现失败的 fake 顶替。接缝只有这一处 (「一个节点怎么变成一条隧道」),选点、评分、历史、探测器都是真的在跑。 当前:`OVG_WITH_TUNNEL=OFF` 155 passed / 2 skipped,`ON` 172 passed / 3 skipped。 重点覆盖的是最难在生产观察的那部分——切换状态机:make-before-break 不打扰既有会话、 宽限期到期关闭掉队者、hard 模式一次性关闭、候选逐个尝试、地址冲突降级为 hard 切换、 排空数量上限、防抖动与 force 的关系、机会性切换必须赢过在位节点、启动预算耗尽后如实报错、 以及**冷启动等待节点列表不能算作失败**。 有一类 bug 是这套测试**结构性看不见**的:测试用 `io.run_for(2ms)` 切片驱动事件循环, 而服务是 `io.run()` 一次然后等它返回。lwIP 的周期定时器会永久自我续约,于是它是一份 work guard 永远收不回的待办工作——测试全绿,服务却在打完一整套完美的优雅退出日志之后 永远不退出(且只在 tunnel 模式,direct 模式根本不建栈)。修复是 `Stack::stop()`,回归 测试 `stack_stop_lets_the_io_context_drain` 直接断言 `run()` 会返回。 --- ## 6. 在这台沙箱里验证到了什么、没验证到什么 诚实的部分: **已用真实网络验证(记录见 FEASIBILITY 附录)** - VPNGate API 实测抓取:1.29 MB / 96 个节点 / 最长行 13529 字符,解析通过; - `tools/tunnel_smoke` 对真实 VPNGate 节点建立隧道成功:拿到 `10.239.88.225/30`, 收到 6 个 ICMP echo reply,正常退出——即 openvpn3 + socketpair-as-tun 这条数据面通路 本身是成立的。 **已在 `direct` 模式下逐项运行验证** SOCKS5 CONNECT(远端解析与本地解析、明文与 TLS)、认证拒绝、连接被拒的带内回复(0x05)、 UDP ASSOCIATE 的真实 DNS 往返、分片丢弃、`/sessions` 实时反映会话、全部管理路由、未知路由 404、`SIGHUP` 重载(94 个节点)、带活跃会话的 `SIGTERM` 优雅退出。 **已在 `tunnel` 模式下验证的部分** 真实节点选点、隧道建立尝试、断线重连、以及**优雅退出**(对真实 VPNGate 节点、在隧道处于 重连状态时收到 `SIGTERM`,1 秒内完成拆隧道并退出)。数据面本身走不通,原因见下。 **没能验证的:完整二进制在 `tunnel` 模式下的端到端数据面。** 本沙箱存在**透明 TCP 拦截**:连 `192.0.2.1:1213`(TEST-NET-1,保证不可路由)、 `175.129.117.170:1213`、`1.1.1.1:443` 全部在 ~0.9ms「连接成功」然后一个字节都不回; UDP:53 通,UDP:1195 超时。这同时解释了两个现象——探测器报告「到日本 1ms」,以及 OpenVPN 握手在发出 client hello 后拿到 `NETWORK_EOF_ERROR`。这是环境限制,不是代码缺陷,但**没被 验证过就是没被验证过**:在一个出网正常的机器上跑 `ovg_tunnel_smoke` 是接手这份代码后 第一件该做的事。 --- ## 7. 已知的边界 - **1000 并发的天花板不在本进程。** 本进程扛得住(异步 I/O、固定线程池、内存是唯一线性 成本),lwIP 的 `MEMP_NUM_TCP_PCB` 等已按此调过;真正的瓶颈是 VPNGate 上那些志愿者跑的 免费节点,它们通常同时服务几十上百人。详见 FEASIBILITY §4。 - **UDP ASSOCIATE 不做分片重组**(FRAG≠0 丢弃并计数)。理由与取舍见 FEASIBILITY §5.1。 - **不支持 SOCKS5 BIND**,也不打算支持(FEASIBILITY §5.4)。 - **管理接口没有认证**,靠绑定环回口来保护。 - VPNGate 绝大多数节点只提供 UDP remote,而只有 TCP 能用一次 `connect()` 量出真实 RTT, 所以 top-K 里通常只有三四个能被实测到;其余沿用 API 报告的 ping 并加惩罚系数。日志里 会明说「top 10 中有 6 个没有可计时的 TCP remote」,免得看起来像过滤器坏了。