Files
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

299 lines
15 KiB
Markdown
Raw Permalink 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 网关想要的那条。
不想在本机编译 openvpn3 的话,[§9](#9-ci-与-ghcr-发布) 说了怎么改成拉现成镜像。
---
## 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/openvpngate.conf` 本身是实测能解析的**:拿它配一个临时凭据文件跑
`openvpngate -c docker/openvpngate.conf --check`,输出 `config ok` 且
`socks5 0.0.0.0:1080 auth=required users=1`——即 §3 那三处覆盖确实生效了。CI 里对镜像跑的
就是同一条命令;
- `docker-compose.yml` 与 `.github/workflows/publish-image.yml` 的 YAML 结构解析通过。
**没有验证**
- 镜像构建本身:apt 装包、两个 clone、CMake 配置与编译、构建阶段跑测试;
- `HEALTHCHECK` 是否真的能命中 `/healthz`
- `read_only: true` 下有没有哪个库偷偷要写别处(`/tmp` 的 tmpfs 是按这个可能性预留的);
- `create_host_path: false` 的报错行为;
- 容器里 `CapEff` 是否真是全零(按 Docker 语义应当如此,但没实跑)。
上面前两条会在 CI 第一次跑通时自动补上(§9 的 smoke 步骤),剩下三条只能在真机上验。
接手后按顺序跑一遍就能补齐:
```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,就是它在容器里的样子。
---
## 9. CI 与 GHCR 发布
`.github/workflows/publish-image.yml` 构建镜像并推到 GitHub Container Registry。
**它没有单独的测试 job,这是故意的**:Dockerfile 的构建阶段最后一步就是跑完整单元测试
`OVG_RUN_TESTS=1`),测试挂了镜像就构建不出来,也就发布不了。再加一个跑同一套测试的 job
只是把同样的编译做第二遍。
推完之后还有一步 smoke:按 **digest**(不是 tag)把刚发布的那个镜像拉回来,跑
`--version`,再挂一个临时凭据文件跑 `--check`。这两下覆盖的正是「构建绿了也不代表能跑」的
两件事——运行时阶段有没有漏装共享库,以及镜像里烤进去的那份配置能不能解析。
产出的 tag
| 触发 | tag |
|---|---|
| push 到 `master` | `master`、`sha-<短 hash>` |
| push tag `v1.2.3` | `1.2.3`、`1.2`、`latest`、`sha-<短 hash>` |
| pull request | 照常构建并跑测试,**不推** |
`latest` 跟的是最新的 release tag,不是分支头。在打出第一个 `v*` 之前,要拉的是 `master`。
拉现成镜像而不是本地编译,把 `docker-compose.yml` 里的 `build:` 整块删掉,`image:` 改成:
```yaml
image: ghcr.io/<owner>/<repo>:master
```
`docker compose pull && docker compose up -d` 即可。注意 `docker/openvpngate.conf` 和
`docker/socks5.auth` 仍然要从本仓库挂进去(§3),镜像里那份只是兜底默认值。
### 这个仓库的 remote 不是 GitHub
`origin` 指向 `git.sfclub.cc`。`.github/workflows/` 只有在下面两种情况下会跑:
1. **镜像到 github.com**:什么都不用配。内置的 `GITHUB_TOKEN` 配合 workflow 里的
`permissions: packages: write` 就能推 ghcr.io,包会挂在镜像仓库名下。
2. **在 Gitea/Forgejo Actions 上跑**:它们认这个 workflow 语法,但它们发的 `GITHUB_TOKEN`
认证的是**自己那个 registry**,推不了 ghcr.io。需要在仓库里配:
| 名字 | 类型 | 值 |
|---|---|---|
| `GHCR_TOKEN` | secret | GitHub PAT,勾 `write:packages` |
| `GHCR_USER` | variable | 那个 PAT 对应的 GitHub 用户名 |
| `GHCR_IMAGE` | variable | 目标镜像名,如 `icybear/openvpngate`。这里的仓库名和 GitHub 上想要的名字不一定一样 |
三个都是「有就用、没有就退回内置值」,所以在 github.com 上不配也不会碍事。
首次推送后包默认是私有的。要让别人 `docker pull` 得先在 GitHub 的 Package settings 里改成
public,或者让对方 `docker login ghcr.io`。