Files
ovgate/docs/DOCKER.md
T
iceBear67andClaude Opus 5 f37cd0a125 Add container build, compose example and deployment docs
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>
2026-07-28 05:54:44 +00:00

238 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.
# 容器部署
这份文档存在的理由有两个,都不是「怎么打个包」:
1. **`cap_drop: ALL` 在这里是可以成立的。** 全项目最核心的约束是「不需要 root、不需要 tun
设备、不需要改路由表」(README 开头那句),在宿主机上这句话只能靠读代码相信;在容器里
它变成一条可以当场验证的断言——把所有 capability 丢掉、rootfs 只读、非 root uid,服务
照常工作。`docker-compose.yml` 里那几行安全选项不是装饰,是这个设计的验收条件。
2. **宿主机的默认配置在容器里有两处是错的**,而且都是「静默地错」:监听环回口会让发布的
端口连不到任何东西,相对路径的状态文件会写到一个随容器一起消失的层里。
先读哪一份:镜像与编排的**为什么**在这里,全部配置项的含义在
[etc/openvpngate.conf](../etc/openvpngate.conf),设计本身在
[ARCHITECTURE.md](ARCHITECTURE.md)。
---
## 1. 快速开始
```sh
cp docker/socks5.auth.example docker/socks5.auth
$EDITOR docker/socks5.auth # 换掉 alice:changeme
docker compose up -d --build
docker compose logs -f
```
冷启动要花几十秒,这是正常的:抓 1.3 MB 的节点目录 → 对候选节点做真实 TCP 握手计时 →
和地球另一端一台志愿者跑的服务器完成 OpenVPN 握手。镜像里 `HEALTHCHECK`
`--start-period=90s` 就是按这个量级给的。日志里出现 `egress ready on <节点>` 才算真正可用。
验证:
```sh
curl -x socks5h://alice:口令@127.0.0.1:1080 https://ifconfig.me
```
`socks5h` 让 curl 把域名交给代理去解析(DNS 走隧道,不泄漏);`socks5` 是本地解析后只把
IP 交过来。两条路径都支持,但只有前者是你部署 VPN 网关想要的那条。
---
## 2. 镜像里有什么
| 路径 | 内容 |
|---|---|
| `/usr/local/bin/openvpngate` | 主程序,以 uid/gid `10001` 运行,ENTRYPOINT |
| `/usr/local/bin/ovg_tunnel_smoke` | 只建一条隧道、ping、退出的诊断工具。**接手一台新机器后第一个该跑的东西**:它能把「镜像坏了」和「这个网络到不了 VPNGate」分开 |
| `/etc/openvpngate/openvpngate.conf` | 容器默认配置,compose 会挂载覆盖它 |
| `/var/lib/openvpngate` | 唯一需要可写的目录:节点缓存 + 节点历史。工作目录也是这里 |
构建参数:
| `--build-arg` | 默认 | 说明 |
|---|---|---|
| `DEBIAN_TAG` | `trixie-slim` | **改它要同时改运行时包名**bookworm 是 `libssl3`/`libfmt9`trixie 是 `libssl3t64`/`libfmt10` |
| `OPENVPN3_REF` | `1512c166…` | openvpn3 的固定 commit |
| `LWIP_REF` | `STABLE-2_2_1_RELEASE` | lwIP 的 tag |
| `OVG_WITH_TUNNEL` | `ON` | `OFF` 只编 `direct` 出口,不拉 openvpn3/lwIP,几秒编完;此时二进制拒绝以 tunnel 模式启动 |
| `OVG_RUN_TESTS` | `1` | 在构建阶段跑完整单元测试。构建机没有环回网络时关掉(十几个测试要 bind 127.0.0.1 |
| `BUILD_JOBS` | `$(nproc)` | 编译并发度 |
### 为什么要钉住 openvpn3 的 commit
`cmake/Dependencies.cmake` 里 openvpn3 的 `FetchContent` 用的是 `GIT_TAG master`。放着不管的
话,同一个 Dockerfile、同一个本仓库 commit,下周构建出来的是一个不同的 VPN 客户端——这在
本地开发里只是有点烦,在镜像里等于没有可复现构建。所以 Dockerfile 自己按固定 ref clone
再通过 `OVG_OPENVPN3_DIR` / `OVG_LWIP_DIR` 交给 CMake,用的是它本来就有的那个开关。
默认那个 commit 就是本仓库开发和测试时用的那个。升级它是个需要**主动做**的决定,做完请重跑
测试套件。
---
## 3. 容器里必须改的三处配置
`docker/openvpngate.conf` 只写了这三类键,其余全部留空走编译内置默认值——这样它不会像
「复制一份完整配置再改几行」那样慢慢和代码脱节。
| 键 | 宿主机默认 | 容器里 | 为什么 |
|---|---|---|---|
| `socks5.listen_address` | `127.0.0.1` | `0.0.0.0` | 环回口在容器里的意思是「只有本容器能连」,`-p` 发布出去的端口会连到一个没人监听的地址。安全性改由**发布规则**提供:compose 把它发布到宿主机环回口 |
| `admin.listen_address` | `127.0.0.1` | `0.0.0.0` | 同上。但这个接口**没有认证**,见 §4 |
| `vpngate.cache_path`<br>`selector.history_path` | `var/…`(相对) | `/var/lib/…`(绝对) | 相对路径按工作目录解析,写进容器可写层就随容器一起没了。节点历史尤其不该丢:它是「哪些节点已经坑过你」的记录,丢掉就会顶着一个好看的 API 分数再走进同一个坑 |
| `socks5.auth_file` | 未设置 | `/etc/openvpngate/socks5.auth` | 凭据不进镜像层。挂载进来,`SIGHUP` 热重载 |
### 凭据文件那个 Docker 陷阱
bind mount 的源文件不存在时,Docker 的默认行为是**在宿主机上建一个同名目录**。之后:
- 配置文件被挂成目录 → 服务读到空配置,用内置默认值启动,监听容器内环回口,谁也连不上;
- 认证文件被挂成目录 → 解析出零条凭据,启动时打印
`warning: socks5.require_auth is on but no credentials are configured; every login will be refused`
然后拒绝所有人。
所以 `docker-compose.yml` 里这两个挂载用的是长语法加 `create_host_path: false`:宁可在
`up` 的时候直接失败。而如果 `auth_file` 指向的路径**完全不存在**,进程会在启动时以
`config: cannot open auth file: …` 退出——这是好事,是快速失败。
---
## 4. 端口与暴露面
| 端口 | 内容 | 建议 |
|---|---|---|
| 1080 | SOCKS5,需要用户名/口令 | 发布到宿主机环回口,或者放进一个只有客户端在的 docker 网络 |
| 9080 | 管理 HTTP | **没有任何认证**,而且包含 `POST /switch` |
把 9080 发布到 `0.0.0.0` 意味着:能访问到它的人可以读到你在线会话的列表,也可以随时强制
你的网关换节点(换节点会掐掉所有已经传过字节的连接)。这不是理论风险,是一个 HTTP POST。
不想要它的话,三处一起改:`docker/openvpngate.conf``admin.enabled = false`、命令行加
`--no-admin`、并把 Dockerfile 里的 `HEALTHCHECK` 去掉(它探的就是 `/healthz`)。
---
## 5. 权限:验证那句「不需要 root」
compose 里这几行是断言,也是测试:
```yaml
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
read_only: true
```
跑起来之后自己查:
```sh
docker compose exec openvpngate cat /proc/self/status | grep -E 'Cap(Eff|Prm)|^Uid'
# Uid: 10001 10001 10001 10001
# CapPrm: 0000000000000000
# CapEff: 0000000000000000
docker compose exec openvpngate ls /dev/net/tun # No such file or directory
```
零 capability、非 root、没有 tun 设备,SOCKS5 照常出流量。对照绝大多数 VPN 客户端容器
需要的 `--cap-add NET_ADMIN --device /dev/net/tun`——省掉它们的代价是进程内自带了一个
用户态 TCP/IP 栈,这笔账 [FEASIBILITY.md](FEASIBILITY.md) §2 算过。
**如果哪天某个改动让上面任何一行不得不放开,那个改动是错的。**
---
## 6. 日常运维
```sh
# 换了凭据 / 想立刻刷新节点列表;不断开任何在途连接
docker compose kill -s HUP openvpngate
# 手动换节点(被拒绝时返回具体原因,不是一句 false)
curl -s -XPOST 127.0.0.1:9080/switch
# 为什么选了这个节点
curl -s 127.0.0.1:9080/nodes | jq
curl -s 127.0.0.1:9080/status | jq
```
**退出语义。** `docker stop` 发 SIGTERM:停止 accept → 排空 → 拆隧道 → 退出,正常在一秒内
完成。宽限期给到 30s 不是因为它慢,而是因为超时之后 Docker 发的是 SIGKILL,而那有可能落在
写节点历史的中间。容器里手动再发一次 SIGTERM 会立即退出(第二次信号的语义就是「别排空了」)。
**healthcheck 是存活探针,不是就绪探针。** `/healthz` 只要管理服务在监听就回 200,它不表示
隧道是通的;隧道状态在 `/status`。特别地:**不要**在它上面接「unhealthy 就重启容器」的
监工。节点变差时服务自己会换节点,重启会把排空、在线会话、以及刚刚学到的「这个节点不行」
一起扔掉——恰好是在它已经在正确处理问题的时候。
**日志**走 stderr,由 compose 的 json-file driver 收,配了 10 MB × 5 的轮转。一个繁忙的网关
每条会话至少一行 info,不轮转就是在慢慢填满宿主机磁盘。
---
## 7. 卷与文件属主
状态目录用的是命名卷 `ovg-state`。空的命名卷会继承镜像里该目录的属主(Dockerfile 里
`install -d -o ovg -g ovg`),所以开箱即用。
换成 bind mount 就没这个待遇,宿主机目录的属主说了算:
```sh
mkdir -p ./state && sudo chown 10001:10001 ./state
```
uid 写死成 10001 而不是让发行版随便分配,就是为了这条命令里能有一个提前知道的数字。
`read_only: true` 之下,容器里唯一可写的是这个卷和 `/tmp`(16 MB tmpfs)。目前没有已知的
东西需要 `/tmp`,它在那里是为了让某个决定往 `/tmp` 写东西的库在写的时候就报错,而不是在
连接的时候才莫名其妙失败。
---
## 8. 这份编排验证到了什么、没验证到什么
延续 README §6 的规矩:**没跑过就是没跑过。**
开发这套文件的沙箱里**没有 docker daemon**,所以 `docker build``docker compose up`
**一次都没有真正执行过**。下面把「照着源码核对过的」和「没跑过的」分开列。
**已核对(对着本仓库源码或在本机实测)**
- 构建依赖与运行时共享库:`ldd` 实测二进制只依赖 `liblz4.so.1 / libfmt.so.10 /
libssl.so.3 / libcrypto.so.3` 加 libc 三件套,对应的 trixie 包名逐个 `apt-cache policy`
查过;
- 产物路径 `build/src/openvpngate`、`build/src/ovg_tunnel_smoke`、`build/tests/ovg_tests`
- `OVG_OPENVPN3_DIR` / `OVG_LWIP_DIR` 确实能跳过 FetchContent`cmake/Dependencies.cmake`),
钉住的两个 ref 就是本机构建通过的那两个;
- 配置键名、相对路径默认值、`auth_file` 缺失时的失败方式(`config: cannot open auth file`)、
空凭据时的警告文案,全部来自 `src/common/config.cpp` 与 `src/app/main.cpp`
- CLI 参数与信号语义来自 `src/app/main.cpp` 的 usage 与 `App::begin_shutdown`
- **预哈希凭据格式是实测的**:用文档里那条 `openssl rand -hex 16` + `sha256sum` 生成一条
`sha256$salt$hash`,起 direct 模式代理,正确口令拿到 200,错误口令和不存在的用户都被
`auth rejected` 挡下;
- 管理路由清单来自 `src/app/admin_server.cpp`
- `docker-compose.yml` 的 YAML 结构解析通过(挂载目标、profiles、tmpfs、healthcheck 覆盖)。
**没有验证**
- 镜像构建本身:apt 装包、两个 clone、CMake 配置与编译、构建阶段跑测试;
- `HEALTHCHECK` 是否真的能命中 `/healthz`
- `read_only: true` 下有没有哪个库偷偷要写别处(`/tmp` 的 tmpfs 是按这个可能性预留的);
- `create_host_path: false` 的报错行为;
- 容器里 `CapEff` 是否真是全零(按 Docker 语义应当如此,但没实跑)。
接手后按顺序跑一遍就能补齐:
```sh
docker compose build # 构建 + 构建阶段的单元测试
docker compose run --rm --entrypoint ovg_tunnel_smoke openvpngate # 数据面
docker compose up -d && docker compose ps # 看 healthy
curl -x socks5h://用户:口令@127.0.0.1:1080 https://ifconfig.me # 出口 IP 应是节点 IP
docker compose exec openvpngate cat /proc/self/status | grep CapEff # 应为全零
docker compose stop # 应在一秒内退出,不是等满 30s
```
最后一条尤其值得看:本项目已经被「优雅退出日志打得漂漂亮亮然后永远不退出」这类 bug 咬过
一次(README §5 末尾那段),`docker stop` 卡满宽限期然后被 SIGKILL,就是它在容器里的样子。