Multi-stage Dockerfile on debian:trixie-slim. openvpn3 and lwIP are cloned at pinned refs and handed to CMake through OVG_OPENVPN3_DIR/OVG_LWIP_DIR rather than left to FetchContent, whose GIT_TAG master would make the same Dockerfile build a different VPN client each week. The unit suite runs in the builder stage. docker/openvpngate.conf overrides only the keys whose host default is wrong inside a container -- loopback listen addresses, which make a published port reach nothing, and relative state paths, which put the node failure history on a layer that gets thrown away. Everything else stays absent and takes the compiled-in default so the file cannot drift from the code. The compose example drops every capability, runs read-only as uid 10001 and publishes both ports to host loopback: the admin endpoint has no auth and includes POST /switch. That configuration is the design constraint of this project (no root, no tun device) turned into something testable. docs/DOCKER.md 8 records what was checked against the source and what was not: this sandbox has no docker daemon, so neither the image build nor the compose file has actually been run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
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 | 动手前的可行性结论。需求中唯一不可能的部分在 §1;UDP ASSOCIATE 的明确表态在 §5;1000 并发的真实天花板在 §4 |
| docs/ARCHITECTURE.md | 模块边界、线程模型、切换状态机、选点与健康检查的具体算法 |
| etc/openvpngate.conf | 全部配置项,每一项都带默认值和「为什么是这个默认值」 |
| docs/DOCKER.md | 容器部署。容器里有三处宿主机默认值是错的(§3);cap_drop: ALL 为什么能成立(§5) |
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。
工程上能做到的三件事(都已实现):
- 新连接立刻走新隧道——这是 make-before-break 的意义;
- 零进度连接透明重试:还没搬运过任何字节的 TCP 连接不含对端状态,直接在新隧道上重
拨,客户端完全无感(
switch.retry_zero_progress); - UDP association 原地换巢:UDP 无连接状态,只换出口 socket,客户端看到的中继端口
不变(
switch.rehome_udp)——这是设计时的意外收获,FEASIBILITY §5.3;
其余(已搬过字节的 TCP)在宽限期 switch.drain_grace 内继续用旧隧道服务,到期关闭。
2. 构建
系统依赖(Debian/Ubuntu):
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 重新拉取)。
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. 运行
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 实现对不对」和「隧道通不通」两件事分开调试:
./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 则是本地解析。
两条路径都支持。
用容器跑
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。
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 |
curl -s localhost:9080/status | jq
curl -s -XPOST localhost:9080/switch
5. 测试
./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」,免得看起来像过滤器坏了。