racel 7171de7a12
build image / image (push) Canceled after 0s
CI: build and publish the container image to the Gitea registry
Gitea reads .gitea/workflows in preference to .github/workflows, so this
supersedes the ghcr.io workflow here without deleting it: that one stays
correct for github.com, and the two cannot race.

Registry cache rather than type=gha -- a self-hosted act_runner supplies
ACTIONS_CACHE_URL/ACTIONS_RUNTIME_TOKEN inconsistently, and without a
cache every run recompiles the openvpn3 core from scratch. Attestations
are off because they turn the push into a multi-manifest index that
Gitea's package view renders as junk entries.
2026-07-29 09:37:33 +00:00

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 动手前的可行性结论。需求中唯一不可能的部分在 §1UDP ASSOCIATE 的明确表态在 §5;1000 并发的真实天花板在 §4
docs/ARCHITECTURE.md 模块边界、线程模型、切换状态机、选点与健康检查的具体算法
etc/openvpngate.conf 全部配置项,每一项都带默认值和「为什么是这个默认值」
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):

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 skippedON 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:12131.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」,免得看起来像过滤器坏了。
S
Description
No description provided
Readme
602 KiB
Languages
C++ 96%
CMake 1.6%
C 1.5%
Dockerfile 0.9%