Files
ovgate/Dockerfile
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

156 lines
6.7 KiB
Docker

# 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"]