From f37cd0a125f4fe9bbbc9e8cf4f7eb3104e6f1eea Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Tue, 28 Jul 2026 05:54:44 +0000 Subject: [PATCH] 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 --- .dockerignore | 32 +++++ CLAUDE.md | 7 ++ Dockerfile | 155 ++++++++++++++++++++++++ README.md | 15 +++ docker-compose.yml | 157 ++++++++++++++++++++++++ docker/openvpngate.conf | 71 +++++++++++ docker/socks5.auth.example | 32 +++++ docs/DOCKER.md | 237 +++++++++++++++++++++++++++++++++++++ 8 files changed, 706 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/openvpngate.conf create mode 100644 docker/socks5.auth.example create mode 100644 docs/DOCKER.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3c020a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +# The build context is COPYied wholesale into the builder stage. Without this +# the two local build trees (~1 GB with the openvpn3 objects) would be shipped +# to the daemon on every `docker build`, only to be overwritten by the +# container's own cmake run. +build/ +build-tunnel/ +build*/ + +# The image builds from the working tree, not from git history. +.git/ +.gitignore +.dockerignore + +# Runtime state. The node cache and the outcome history belong to whichever +# machine produced them; the container gets its own in a volume. +var/ + +# Local operator config and credentials. The container reads its config from a +# mount (see docker-compose.yml), never from something baked into the layer. +etc/ +# Patterns match the whole context-relative path, so a bare *.auth would only +# cover the top level -- and the credential file lives in docker/. +**/*.auth + +# Editor and OS noise +*.swp +*~ +.vscode/ +.idea/ +.DS_Store +.cache/ +compile_commands.json diff --git a/CLAUDE.md b/CLAUDE.md index a39a016..38ababb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,13 @@ one tunnel, pings through it, and exits — the first thing to run on a machine ./build/src/openvpngate -c etc/openvpngate.conf --egress direct --listen 127.0.0.1:1080 ``` +There is a container build too (`Dockerfile`, `docker-compose.yml`, `docs/DOCKER.md`). Two things +about it are load-bearing: it clones openvpn3/lwIP at pinned refs and passes them via +`OVG_OPENVPN3_DIR`/`OVG_LWIP_DIR`, because FetchContent's `GIT_TAG master` would otherwise make the +image a different VPN client every week; and `docker/openvpngate.conf` overrides the two host +defaults that are silently wrong in a container — loopback listen addresses (a published port then +reaches nothing) and relative state paths (the node history dies with the container). + `etc/openvpngate.conf` documents every key with its default and the reasoning. Admin HTTP (default `127.0.0.1:9080`, **no auth**) exposes `/status /nodes /sessions /health /metrics /healthz` and `POST /switch`; `/nodes` explains each node's score, which is the fastest way to understand a diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dc54f7c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,155 @@ +# syntax=docker/dockerfile:1 +# +# openvpngate as a container. +# +# The interesting property of this image is what it does *not* need: no +# --privileged, no --cap-add NET_ADMIN, no --device /dev/net/tun, no +# --sysctl. The tunnel is terminated in userspace by lwIP, so from the kernel's +# point of view this is an ordinary unprivileged process that listens on a +# socket. docker-compose.yml drops every capability to make that testable +# rather than merely claimed. +# +# See docs/DOCKER.md for the container-specific configuration traps -- the +# defaults in etc/openvpngate.conf are written for a host, and two of them +# (loopback listen addresses, relative state paths) are actively wrong here. + +# Pinned rather than :latest, and pinned to *trixie* specifically: the runtime +# package names below are release-specific (bookworm ships libssl3 and libfmt9, +# trixie ships libssl3t64 and libfmt10). Moving this tag means re-checking them. +ARG DEBIAN_TAG=trixie-slim + +# --------------------------------------------------------------------------- +# Stage 1 -- build +# --------------------------------------------------------------------------- +FROM debian:${DEBIAN_TAG} AS build + +# openvpn3 and lwIP are source dependencies. Left to itself, CMake's +# FetchContent pulls openvpn3 at GIT_TAG master, which makes this image +# un-reproducible: the same Dockerfile and the same commit of this repo would +# build a different VPN client next week. So clone them here at pinned refs and +# hand the checkouts to CMake through OVG_OPENVPN3_DIR / OVG_LWIP_DIR, the knob +# it already has for a pre-existing checkout. +# +# The openvpn3 default is the commit this tree was developed and tested against. +# Bump it deliberately, and re-run the suite when you do. +ARG OPENVPN3_REF=1512c16622288f3c01da09d3278ac61a86dca26d +ARG LWIP_REF=STABLE-2_2_1_RELEASE + +# OFF builds without openvpn3/lwIP: `direct` egress only, host sockets, no VPN. +# Useful for testing the SOCKS5 layer in isolation, useless as a gateway -- and +# the binary refuses to start in tunnel mode rather than proxying in the clear. +ARG OVG_WITH_TUNNEL=ON + +# The unit tests are the only thing between "it compiled" and "it works", and +# they cost seconds against a build measured in minutes. Turn off with +# --build-arg OVG_RUN_TESTS=0 on a builder without loopback networking: a dozen +# tests bind and connect on 127.0.0.1. +ARG OVG_RUN_TESTS=1 + +ARG BUILD_JOBS= + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + pkg-config \ + git \ + ca-certificates \ + libasio-dev \ + libssl-dev \ + liblz4-dev \ + libfmt-dev \ + && rm -rf /var/lib/apt/lists/* + +# Fetched before the source is copied, so editing a .cpp does not re-clone the +# dependencies. --filter=blob:none keeps the clone small while still allowing a +# checkout of an arbitrary ref -- which a --depth 1 clone cannot do for a bare +# commit id, and the openvpn3 pin above is one. +WORKDIR /deps +RUN git clone --filter=blob:none --no-checkout \ + https://github.com/OpenVPN/openvpn3.git openvpn3 \ + && git -C openvpn3 checkout --detach "${OPENVPN3_REF}" \ + && git clone --filter=blob:none --no-checkout \ + https://github.com/lwip-tcpip/lwip.git lwip \ + && git -C lwip checkout --detach "${LWIP_REF}" + +WORKDIR /src +COPY . . + +RUN cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DOVG_WITH_TUNNEL="${OVG_WITH_TUNNEL}" \ + -DOVG_BUILD_TESTS=ON \ + -DOVG_OPENVPN3_DIR=/deps/openvpn3 \ + -DOVG_LWIP_DIR=/deps/lwip \ + && cmake --build build -j"${BUILD_JOBS:-$(nproc)}" + +RUN if [ "${OVG_RUN_TESTS}" = "1" ]; then ./build/tests/ovg_tests; fi + +# --------------------------------------------------------------------------- +# Stage 2 -- runtime +# --------------------------------------------------------------------------- +FROM debian:${DEBIAN_TAG} AS runtime + +LABEL org.opencontainers.image.title="openvpngate" \ + org.opencontainers.image.description="OpenVPN client with an authenticated SOCKS5 front door, terminated in userspace" \ + org.opencontainers.image.version="0.1.0" \ + org.opencontainers.image.licenses="NOASSERTION" + +# Release-specific names -- see the DEBIAN_TAG comment at the top. +# curl is here only for the HEALTHCHECK below; drop both together if you run +# with admin.enabled = false. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libssl3t64 \ + liblz4-1 \ + libfmt10 \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# A fixed uid, not a distro-assigned one: a bind-mounted state directory has to +# be chown'd to a number the host knows in advance. +RUN groupadd --system --gid 10001 ovg \ + && useradd --system --uid 10001 --gid 10001 \ + --home-dir /var/lib/openvpngate --shell /usr/sbin/nologin ovg + +COPY --from=build /src/build/src/openvpngate /usr/local/bin/openvpngate +# Brings up one tunnel, pings through it, exits. The first thing worth running +# on a host with real network access, and the fastest way to tell "the image is +# broken" from "this network cannot reach VPNGate". +COPY --from=build /src/build/src/ovg_tunnel_smoke /usr/local/bin/ovg_tunnel_smoke + +# Baked in so `docker run` alone works; docker-compose.yml mounts over it so the +# config can be edited without a rebuild. +COPY docker/openvpngate.conf /etc/openvpngate/openvpngate.conf + +# The one writable path the service needs: the node cache and the per-node +# outcome history. An empty named volume mounted here inherits this ownership, +# which is what makes `read_only: true` on the rest of the rootfs work. +RUN install -d -o ovg -g ovg -m 0750 /var/lib/openvpngate + +# So that a relative path in a user-supplied config resolves somewhere writable +# instead of failing at the first cache write. +WORKDIR /var/lib/openvpngate + +# 1080 SOCKS5, 9080 admin. The admin endpoint has NO authentication; publish it +# to host loopback or not at all. +EXPOSE 1080 9080 + +USER ovg + +# Liveness only -- /healthz answers 200 as soon as the admin server is up and +# says nothing about whether a tunnel is established. Tunnel state is +# GET /status, and the service already reacts to a degraded tunnel by switching +# nodes on its own; see docs/DOCKER.md before wiring restart-on-unhealthy. +# start-period covers a cold start: fetch the directory, probe candidates, +# negotiate with a volunteer-run server on the other side of the world. +HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \ + CMD curl -fsS http://127.0.0.1:9080/healthz || exit 1 + +# Explicit because the second one means something different: SIGTERM starts a +# graceful shutdown, a second SIGTERM/SIGINT during it stops immediately. The +# binary runs as pid 1 (exec form, no shell wrapper), so it receives them. +STOPSIGNAL SIGTERM + +ENTRYPOINT ["/usr/local/bin/openvpngate"] +CMD ["-c", "/etc/openvpngate/openvpngate.conf"] diff --git a/README.md b/README.md index 40e30b4..791419e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ SOCKS5 客户端 ──► socks5::Server ──► egress::Egress ──► lwI | [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) | --- @@ -125,6 +126,20 @@ curl -sS --socks5-hostname alice:changeme@127.0.0.1:1080 https://example.com -o `--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. 管理接口 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6f85ecb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,157 @@ +# Example deployment. See docs/DOCKER.md for what each block is protecting +# against; the short version is that a userspace VPN client should be able to +# run with every capability dropped, and this file is where that gets proven +# rather than asserted. +# +# cp docker/socks5.auth.example docker/socks5.auth # then edit it +# docker compose up -d --build +# curl -x socks5h://alice:...@127.0.0.1:1080 https://ifconfig.me +# +name: openvpngate + +services: + openvpngate: + build: + context: . + args: + # OFF drops openvpn3 and lwIP: `direct` egress only, no VPN. Builds in + # seconds, useful for exercising the SOCKS5 layer, useless as a gateway. + OVG_WITH_TUNNEL: "ON" + image: openvpngate:0.1.0 + restart: unless-stopped + + # Both published to host loopback, and that is doing real work in each case: + # 1080 the proxy is only as private as who can reach it; + # 9080 the admin endpoint has NO authentication and includes POST /switch. + # Change these to 0.0.0.0 only after reading docs/DOCKER.md 4. + ports: + - "127.0.0.1:1080:1080" + - "127.0.0.1:9080:9080" + + volumes: + # Long syntax with create_host_path: false on purpose. Docker's default is + # to silently create a *directory* when a bind mount's source is missing, + # which for the auth file means the proxy starts and refuses every login, + # and for the config means it starts on defaults that listen on loopback + # inside the container and are reachable by nobody. Fail at `up` instead. + - type: bind + source: ./docker/openvpngate.conf + target: /etc/openvpngate/openvpngate.conf + read_only: true + bind: + create_host_path: false + + - type: bind + source: ./docker/socks5.auth + target: /etc/openvpngate/socks5.auth + read_only: true + bind: + create_host_path: false + + # The node cache and the per-node failure history. Worth persisting for + # more than startup speed: the history is the record of which volunteer + # nodes have already failed on you, and throwing it away on every `up` + # means walking back into the same one. + - ovg-state:/var/lib/openvpngate + + # ---- the point of the exercise ---------------------------------------- + # No NET_ADMIN, no /dev/net/tun, no --privileged, no sysctls, uid 10001. + # The tunnel is terminated by lwIP inside the process, so nothing here + # needs kernel networking privileges. If a change to this project ever + # makes one of these lines necessary, the change is wrong. + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + # Nothing is known to need it; it is here so that a library that decides + # to spool to /tmp fails loudly at write time rather than at connect time. + - /tmp:size=16m,mode=1777 + + # SIGTERM starts a graceful shutdown (stop accepting, drain, tear the tunnel + # down); it normally completes in well under a second. The window is wide + # because the alternative when it does not is SIGKILL in the middle of + # writing the node history. + stop_grace_period: 30s + + # socks5.max_sessions defaults to 1200 and each session costs a client-side + # fd (plus an egress-side one in direct mode). The Docker default is usually + # far higher than this, but it is not guaranteed to be. + ulimits: + nofile: + soft: 8192 + hard: 8192 + + # A gateway logs one line per session at info. Unrotated json-file logging + # is how a container quietly fills a host disk. + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + # The image's HEALTHCHECK probes admin /healthz. It is liveness only -- it + # says the process is answering, not that a tunnel is up. Do NOT wire an + # unhealthy-triggers-restart supervisor on top: the service already responds + # to a degrading tunnel by switching nodes, and a restart throws away the + # drain, the session, and the freshly-learned reason the node was bad. + + # ------------------------------------------------------------------------- + # Proxy without a VPN, for splitting "is the SOCKS5 implementation correct" + # from "is the tunnel up". Egress is a plain host socket -- traffic through + # this one is NOT tunnelled, which is why it is behind a profile and on its + # own port. + # + # docker compose --profile test up openvpngate-direct + # ------------------------------------------------------------------------- + openvpngate-direct: + profiles: [test] + # Same image and same build inputs as above, so this resolves to the one + # already built rather than trying to pull it from a registry. + build: + context: . + args: + OVG_WITH_TUNNEL: "ON" + image: openvpngate:0.1.0 + command: + - "-c" + - "/etc/openvpngate/openvpngate.conf" + - "--egress" + - "direct" + - "--no-admin" + ports: + - "127.0.0.1:1081:1080" + volumes: + - type: bind + source: ./docker/openvpngate.conf + target: /etc/openvpngate/openvpngate.conf + read_only: true + bind: + create_host_path: false + - type: bind + source: ./docker/socks5.auth + target: /etc/openvpngate/socks5.auth + read_only: true + bind: + create_host_path: false + # Inherited from the image, but --no-admin means there is nothing listening + # on 9080 to answer it; left armed it would report this container unhealthy + # forever. + healthcheck: + disable: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + # This one still fetches and caches the node directory even though it + # never dials a node, so it needs its state directory writable. Throwaway + # rather than a volume: nothing produced by a no-VPN test run is worth + # keeping, least of all a failure history for nodes it never contacted. + - /var/lib/openvpngate:size=64m,mode=1777 + +volumes: + ovg-state: diff --git a/docker/openvpngate.conf b/docker/openvpngate.conf new file mode 100644 index 0000000..5a3d86c --- /dev/null +++ b/docker/openvpngate.conf @@ -0,0 +1,71 @@ +# openvpngate -- configuration baked into the container image. +# +# This file sets ONLY the keys whose host default is wrong inside a container. +# Everything else is left absent and takes the compiled-in default, so this +# file cannot drift from the code the way a copied-and-edited full config does. +# +# etc/openvpngate.conf in the source tree is the annotated reference: every key, +# its default, and why that is the default. Read that one to change behaviour, +# then add the key here. + +# --------------------------------------------------------------------------- +[socks5] +# --------------------------------------------------------------------------- +# The default is 127.0.0.1, which inside a container means "reachable from this +# container only" -- a published port would connect to nothing. Bind everywhere +# and let the container runtime decide who gets to reach it: docker-compose.yml +# publishes this to host loopback, not to the LAN. +listen_address = 0.0.0.0 +listen_port = 1080 + +require_auth = true + +# Credentials stay out of the image. Mount a file here (docker-compose.yml +# does) -- SIGHUP re-reads it without dropping a live session: +# +# docker compose kill -s HUP openvpngate +# +# If this path is missing the process exits at startup with +# "config: cannot open auth file"; if it is a *directory* -- which is what +# Docker silently creates for a bind mount whose source does not exist -- the +# file parses as empty and startup warns "every login will be refused". The +# compose file uses create_host_path: false so that case fails loudly instead. +auth_file = /etc/openvpngate/socks5.auth + +# --------------------------------------------------------------------------- +[vpngate] +# --------------------------------------------------------------------------- +# Absolute. The default is relative (var/vpngate_cache.csv) and would resolve +# against the working directory; inside the image that is /var/lib/openvpngate, +# which happens to be right, but only by accident. Say it explicitly, because +# this is also the one path that survives a container replacement and the one +# directory a read-only rootfs still permits writing to. +cache_path = /var/lib/openvpngate/vpngate_cache.csv + +# --------------------------------------------------------------------------- +[selector] +# --------------------------------------------------------------------------- +# Same reasoning. Worth persisting for a different reason though: this is the +# record of which nodes have failed on you, and losing it on every `up` means +# walking back into the same broken node with a flattering API score. +history_path = /var/lib/openvpngate/node_history.tsv + +# --------------------------------------------------------------------------- +[admin] +# --------------------------------------------------------------------------- +# Bound everywhere for the same reason as socks5 above -- but this endpoint has +# NO authentication and includes POST /switch, so whoever can reach it can force +# your gateway onto another node. It is protected here by *publishing* rules, +# not by the bind address: keep it on 127.0.0.1 on the host side, or set +# enabled = false and drop the HEALTHCHECK from the Dockerfile, which probes it. +enabled = true +listen_address = 0.0.0.0 +listen_port = 9080 + +# --------------------------------------------------------------------------- +[log] +# --------------------------------------------------------------------------- +# stderr, so that `docker logs` and the compose log driver see it. Logging to a +# file inside a container puts the record on the layer that gets thrown away. +level = info +file = - diff --git a/docker/socks5.auth.example b/docker/socks5.auth.example new file mode 100644 index 0000000..ddd5f9e --- /dev/null +++ b/docker/socks5.auth.example @@ -0,0 +1,32 @@ +# SOCKS5 credentials, one per line: : +# +# cp docker/socks5.auth.example docker/socks5.auth +# $EDITOR docker/socks5.auth +# +# docker-compose.yml mounts docker/socks5.auth read-only into the container. +# Reloaded on SIGHUP without dropping a live session: +# +# docker compose kill -s HUP openvpngate +# +# Two accepted forms. +# +# 1. Plaintext. Hashed with a random salt when the file is read, so it is never +# held in memory in the clear -- but it is sitting in the clear right here, +# which is the part that matters on a shared host. +# +# alice:changeme +# +# 2. Pre-hashed: sha256$$. Note the +# salt is concatenated as its *hex text*, not as raw bytes. Generate one: +# +# salt=$(openssl rand -hex 16) +# printf 'alice:sha256$%s$%s\n' "$salt" \ +# "$(printf '%s' "${salt}${PASSWORD}" | sha256sum | cut -d' ' -f1)" +# +# Lines starting with '#' and blank lines are ignored. A malformed line is a +# hard startup error, not a skipped entry: half-loaded credentials are worse +# than none. +# +# The entry below is an example and will be rejected by anyone paying +# attention. Replace it. +alice:changeme diff --git a/docs/DOCKER.md b/docs/DOCKER.md new file mode 100644 index 0000000..38675dd --- /dev/null +++ b/docs/DOCKER.md @@ -0,0 +1,237 @@ +# 容器部署 + +这份文档存在的理由有两个,都不是「怎么打个包」: + +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`
`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,就是它在容器里的样子。