Files
ovgate/README.md
T
iceBear67andClaude Opus 5 40b47a8f66
publish image / image (push) Canceled after 0s
CI: build and publish the container image to GHCR
The Dockerfile's builder stage already runs the unit suite, so the workflow
deliberately has no separate test job -- a red test cannot produce an image.

After pushing, the published artifact is smoke-tested by digest: `--version`
covers a runtime stage missing a shared library, and `--check` against a
throwaway credentials file covers the config baked into the image. Both were
failure modes a green build would not have caught. The `--check` invocation is
verified locally against docker/openvpngate.conf.

`latest` follows the newest v* tag rather than the branch head; master head is
published as `master`. Registry paths are lowercased explicitly rather than
relying on metadata-action, since the same value is reused for the smoke test.

GHCR_TOKEN / GHCR_USER / GHCR_IMAGE override the built-ins so this still works
from a mirror or a Gitea/Forgejo runner, where the ambient token authenticates
to the wrong registry. On github.com none of them need to be set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 06:04:51 +00:00

233 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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」,免得看起来像过滤器坏了。