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

15 KiB
Raw Permalink Blame History

容器部署

这份文档存在的理由有两个,都不是「怎么打个包」:

  1. cap_drop: ALL 在这里是可以成立的。 全项目最核心的约束是「不需要 root、不需要 tun 设备、不需要改路由表」(README 开头那句),在宿主机上这句话只能靠读代码相信;在容器里 它变成一条可以当场验证的断言——把所有 capability 丢掉、rootfs 只读、非 root uid,服务 照常工作。docker-compose.yml 里那几行安全选项不是装饰,是这个设计的验收条件。
  2. 宿主机的默认配置在容器里有两处是错的,而且都是「静默地错」:监听环回口会让发布的 端口连不到任何东西,相对路径的状态文件会写到一个随容器一起消失的层里。

先读哪一份:镜像与编排的为什么在这里,全部配置项的含义在 etc/openvpngate.conf,设计本身在 ARCHITECTURE.md


1. 快速开始

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 <节点> 才算真正可用。

验证:

curl -x socks5h://alice:口令@127.0.0.1:1080 https://ifconfig.me

socks5h 让 curl 把域名交给代理去解析(DNS 走隧道,不泄漏);socks5 是本地解析后只把 IP 交过来。两条路径都支持,但只有前者是你部署 VPN 网关想要的那条。

不想在本机编译 openvpn3 的话,§9 说了怎么改成拉现成镜像。


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/libfmt9trixie 是 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
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.confadmin.enabled = false、命令行加 --no-admin、并把 Dockerfile 里的 HEALTHCHECK 去掉(它探的就是 /healthz)。


5. 权限:验证那句「不需要 root」

compose 里这几行是断言,也是测试:

cap_drop: [ALL]
security_opt: [no-new-privileges:true]
read_only: true

跑起来之后自己查:

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 §2 算过。

如果哪天某个改动让上面任何一行不得不放开,那个改动是错的。


6. 日常运维

# 换了凭据 / 想立刻刷新节点列表;不断开任何在途连接
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 就没这个待遇,宿主机目录的属主说了算:

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 builddocker compose up 一次都没有真正执行过。下面把「照着源码核对过的」和「没跑过的」分开列。

已核对(对着本仓库源码或在本机实测)

  • 构建依赖与运行时共享库:ldd 实测二进制只依赖 liblz4.so.1 / libfmt.so.10 / libssl.so.3 / libcrypto.so.3 加 libc 三件套,对应的 trixie 包名逐个 apt-cache policy 查过;
  • 产物路径 build/src/openvpngatebuild/src/ovg_tunnel_smokebuild/tests/ovg_tests
  • OVG_OPENVPN3_DIR / OVG_LWIP_DIR 确实能跳过 FetchContentcmake/Dependencies.cmake), 钉住的两个 ref 就是本机构建通过的那两个;
  • 配置键名、相对路径默认值、auth_file 缺失时的失败方式(config: cannot open auth file)、 空凭据时的警告文案,全部来自 src/common/config.cppsrc/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 oksocks5 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 步骤),剩下三条只能在真机上验。

接手后按顺序跑一遍就能补齐:

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 mastersha-<短 hash>
push tag v1.2.3 1.2.31.2latestsha-<短 hash>
pull request 照常构建并跑测试,不推

latest 跟的是最新的 release tag,不是分支头。在打出第一个 v* 之前,要拉的是 master

拉现成镜像而不是本地编译,把 docker-compose.yml 里的 build: 整块删掉,image: 改成:

    image: ghcr.io/<owner>/<repo>:master

docker compose pull && docker compose up -d 即可。注意 docker/openvpngate.confdocker/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