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