harden containers: drop WG privileged, slim images, digest pins; fix WG path + empty chrony.conf
Unit Tests / test (push) Successful in 12m16s

Security — WireGuard:
- Replace linuxserver/wireguard (privileged + SYS_MODULE + /lib/modules) with a
  bespoke alpine image (wireguard/Dockerfile + entrypoint.sh): CAP_NET_ADMIN only,
  119 MB → 14.7 MB. Modern kernels (≥5.6) have WireGuard built in; no module
  loading required. Kernel-fallback comment left in compose for rare old kernels.

Security — supply-chain digest pins:
- CoreDNS image pinned by SHA-256 digest in docker-compose.yml.
- api/Dockerfile: python:3.11-slim and docker:27-cli pinned by digest.
- webui/Dockerfile: node:20-alpine and nginxinc/nginx-unprivileged:alpine pinned.
- ntp/Dockerfile: alpine:3.20 pinned by digest.
- wireguard/Dockerfile: alpine:3.20 pinned by digest.

Security — webui non-root:
- Switch from nginx:alpine (root, port 80) to nginxinc/nginx-unprivileged:alpine
  (port 8080, runs as nginx uid 101). Compose port mapping and all Caddy upstream
  references updated: cell-webui:80 → cell-webui:8080 everywhere.

API layer reduction (561 MB → 245 MB):
- Multi-stage api/Dockerfile: docker CLI copied from docker:27-cli stage instead
  of being installed via apt from Docker's external repo (removes GPG key fetch,
  lsb-release, gnupg, two apt-get update rounds). --no-install-recommends on
  remaining apt install. mkdir folded into the same RUN layer.

Bug fix — WireGuard config path mismatch:
- setup_cell.py wrote wg0.conf to config/wireguard/wg0.conf but wireguard_manager
  and the new entrypoint expect config/wireguard/wg_confs/wg0.conf (the standard
  wg-quick sub-directory). Fixed by creating the wg_confs/ sub-dir and writing
  there; REQUIRED_DIRS updated to pre-create it.

Bug fix — empty chrony.conf:
- config/ntp/chrony.conf was 0 bytes (pre-existing gap); added a real config
  (pool.ntp.org + Cloudflare, allow 172.20/10.0, local stratum 10, driftfile,
  makestep, rtcsync). NTP compose service now builds from ./ntp instead of
  pulling alpine:latest and running apk at every container start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:07:54 -04:00
parent fb257c50b3
commit f4b8d5c4f7
13 changed files with 125 additions and 63 deletions
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# Slim WireGuard entrypoint for PIC.
#
# Brings up the SAME wg0.conf the API manages live (config/wireguard/wg_confs/wg0.conf,
# mounted at /config inside the container). The API does `docker exec cell-wireguard
# wg set wg0 ...` for live peer sync, so the interface MUST be named wg0 and stay up
# for the whole container lifetime.
#
# Requires CAP_NET_ADMIN + iptables (the conf's PostUp installs DNAT/MASQUERADE/FORWARD
# rules). No s6, no PUID/PGID — those linuxserver env vars (if still passed) are ignored.
set -eu
CONF="/config/wg_confs/wg0.conf"
IFACE="wg0"
cleanup() {
echo "[wireguard] caught signal, bringing $IFACE down"
wg-quick down "$CONF" 2>/dev/null || true
exit 0
}
trap cleanup TERM INT
# Wait for the API/setup to write a usable [Interface] block. On a clean install
# setup_cell.py seeds it before the stack starts, so this normally passes immediately.
echo "[wireguard] waiting for $CONF with an [Interface] block..."
i=0
while true; do
if [ -f "$CONF" ] && grep -q '^\[Interface\]' "$CONF"; then
break
fi
i=$((i + 1))
if [ "$i" -ge 120 ]; then
echo "[wireguard] timed out waiting for $CONF — exiting" >&2
exit 1
fi
sleep 1
done
echo "[wireguard] bringing up $IFACE from $CONF"
wg-quick up "$CONF"
echo "[wireguard] $IFACE is up; holding container open"
# Stay alive in the foreground forever so the API can docker-exec into us.
while true; do
sleep 3600 &
wait $!
done