Files
ovgate/docker-compose.yml
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

158 lines
6.0 KiB
YAML

# 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: