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
+18 -24
View File
@@ -1,35 +1,29 @@
FROM python:3.11-slim FROM docker:27-cli@sha256:851f91d241214e7c6db86513b270d58776379aacc5eb9c4a87e5b47115e3065c AS dockercli
FROM python:3.11-slim@sha256:a3ab0b966bc4e91546a033e22093cb840908979487a9fc0e6e38295747e49ac0
WORKDIR /app/api WORKDIR /app/api
# Install system dependencies # The API runs as root by design: it drives iptables, the docker socket, and
RUN apt-get update && apt-get install -y \ # docker-execs into sibling containers. Non-root is not feasible here.
wireguard-tools \ COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker
iptables \
iproute2 \ RUN apt-get update \
util-linux \ && apt-get install -y --no-install-recommends \
curl \ wireguard-tools \
ca-certificates \ iptables \
gnupg \ iproute2 \
lsb-release \ util-linux \
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \ curl \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \ ca-certificates \
&& apt-get update \ && rm -rf /var/lib/apt/lists/* \
&& apt-get install -y docker-ce-cli \ && mkdir -p /app/data /app/config
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy all application code into /app/api
COPY . . COPY . .
# Create necessary directories
RUN mkdir -p /app/data /app/config
# Expose port
EXPOSE 3000 EXPOSE 3000
# Run the application CMD ["python", "app.py"]
CMD ["python", "app.py"]
+1 -1
View File
@@ -145,7 +145,7 @@ class CaddyManager(BaseServiceManager):
" reverse_proxy cell-api:3000\n" " reverse_proxy cell-api:3000\n"
" }\n" " }\n"
" handle {\n" " handle {\n"
" reverse_proxy cell-webui:80\n" " reverse_proxy cell-webui:8080\n"
" }" " }"
) )
+3 -3
View File
@@ -164,7 +164,7 @@ http://{cell_name}.{domain}, http://{caddy_ip}:80 {{
reverse_proxy cell-rainloop:8888 reverse_proxy cell-rainloop:8888
}} }}
handle {{ handle {{
reverse_proxy cell-webui:80 reverse_proxy cell-webui:8080
}} }}
}} }}
@@ -190,7 +190,7 @@ http://api.{domain} {{
}} }}
http://webui.{domain} {{ http://webui.{domain} {{
reverse_proxy cell-webui:80 reverse_proxy cell-webui:8080
}} }}
# Catch-all for direct IP / localhost # Catch-all for direct IP / localhost
@@ -199,7 +199,7 @@ http://webui.{domain} {{
reverse_proxy cell-api:3000 reverse_proxy cell-api:3000
}} }}
handle {{ handle {{
reverse_proxy cell-webui:80 reverse_proxy cell-webui:8080
}} }}
}} }}
""" """
+7 -12
View File
@@ -28,7 +28,7 @@ services:
# DNS Server - CoreDNS for .cell TLD resolution # DNS Server - CoreDNS for .cell TLD resolution
dns: dns:
image: coredns/coredns:latest image: coredns/coredns:1.11.3@sha256:9caabbf6238b189a65d0d6e6ac138de60d6a1c419e5a341fbbb7c78382559c6e
container_name: cell-dns container_name: cell-dns
profiles: ["core", "full"] profiles: ["core", "full"]
command: ["-conf", "/etc/coredns/Corefile"] command: ["-conf", "/etc/coredns/Corefile"]
@@ -53,7 +53,7 @@ services:
# NTP Server - chrony for time synchronization # NTP Server - chrony for time synchronization
ntp: ntp:
image: alpine:latest build: ./ntp
container_name: cell-ntp container_name: cell-ntp
profiles: ["core", "full"] profiles: ["core", "full"]
ports: ports:
@@ -69,7 +69,6 @@ services:
ipv4_address: ${NTP_IP:-172.20.0.5} ipv4_address: ${NTP_IP:-172.20.0.5}
cap_add: cap_add:
- SYS_TIME - SYS_TIME
command: ["/bin/sh", "-c", "apk add --no-cache chrony && rm -f /var/run/chrony/chronyd.pid && exec chronyd -d -f /etc/chrony/chrony.conf -n"]
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -78,18 +77,13 @@ services:
# WireGuard VPN # WireGuard VPN
wireguard: wireguard:
image: linuxserver/wireguard:latest build: ./wireguard
container_name: cell-wireguard container_name: cell-wireguard
profiles: ["core", "full"] profiles: ["core", "full"]
environment:
- SERVERMODE=true
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
ports: ports:
- "${WG_PORT:-51820}:${WG_PORT:-51820}/udp" - "${WG_PORT:-51820}:${WG_PORT:-51820}/udp"
volumes: volumes:
- ./config/wireguard:/config - ./config/wireguard:/config
- /lib/modules:/lib/modules
restart: unless-stopped restart: unless-stopped
mem_limit: 256m mem_limit: 256m
cpus: 0.5 cpus: 0.5
@@ -99,8 +93,9 @@ services:
ipv4_address: ${WG_IP:-172.20.0.9} ipv4_address: ${WG_IP:-172.20.0.9}
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- SYS_MODULE # FALLBACK for kernels lacking builtin WireGuard: re-add `privileged: true`,
privileged: true # `- SYS_MODULE` under cap_add, and the `- /lib/modules:/lib/modules` volume.
# Default assumes a modern kernel (>= 5.6) with WireGuard compiled in.
sysctls: sysctls:
- net.ipv4.conf.all.src_valid_mark=1 - net.ipv4.conf.all.src_valid_mark=1
- net.ipv4.ip_forward=1 - net.ipv4.ip_forward=1
@@ -157,7 +152,7 @@ services:
container_name: cell-webui container_name: cell-webui
profiles: ["core", "full"] profiles: ["core", "full"]
ports: ports:
- "${WEBUI_PORT:-8081}:80" - "${WEBUI_PORT:-8081}:8080"
restart: unless-stopped restart: unless-stopped
mem_limit: 256m mem_limit: 256m
cpus: 0.5 cpus: 0.5
+7
View File
@@ -0,0 +1,7 @@
FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
RUN apk add --no-cache chrony \
&& mkdir -p /var/run/chrony /var/lib/chrony /var/log/chrony
# chrony.conf is mounted at /etc/chrony/chrony.conf by compose.
ENTRYPOINT ["chronyd", "-d", "-n", "-f", "/etc/chrony/chrony.conf"]
+6 -3
View File
@@ -19,6 +19,7 @@ REQUIRED_DIRS = [
'config/dns', 'config/dns',
'config/ntp', 'config/ntp',
'config/wireguard', 'config/wireguard',
'config/wireguard/wg_confs',
'config/api', 'config/api',
'data/caddy', 'data/caddy',
'data/dns', 'data/dns',
@@ -133,9 +134,11 @@ def generate_wg_keys():
def write_wg0_conf(private_key: str, address: str, port: int): def write_wg0_conf(private_key: str, address: str, port: int):
wg_conf = os.path.join(ROOT, 'config', 'wireguard', 'wg0.conf') wg_confs_dir = os.path.join(ROOT, 'config', 'wireguard', 'wg_confs')
os.makedirs(wg_confs_dir, exist_ok=True)
wg_conf = os.path.join(wg_confs_dir, 'wg0.conf')
if os.path.exists(wg_conf): if os.path.exists(wg_conf):
print('[EXISTS] config/wireguard/wg0.conf') print('[EXISTS] config/wireguard/wg_confs/wg0.conf')
return return
server_ip = address.split('/')[0] server_ip = address.split('/')[0]
content = ( content = (
@@ -153,7 +156,7 @@ def write_wg0_conf(private_key: str, address: str, port: int):
with open(wg_conf, 'w') as f: with open(wg_conf, 'w') as f:
f.write(content) f.write(content)
os.chmod(wg_conf, 0o600) os.chmod(wg_conf, 0o600)
print(f'[CREATED] config/wireguard/wg0.conf address={address} port={port}') print(f'[CREATED] config/wireguard/wg_confs/wg0.conf address={address} port={port}')
def write_cell_config(cell_name: str, domain: str, port: int, def write_cell_config(cell_name: str, domain: str, port: int,
+3 -3
View File
@@ -209,7 +209,7 @@ class TestServiceRoutesIncluded(unittest.TestCase):
self.assertIn('reverse_proxy cell-filegator:8080', out) self.assertIn('reverse_proxy cell-filegator:8080', out)
# Core routes still emitted # Core routes still emitted
self.assertIn('reverse_proxy cell-api:3000', out) self.assertIn('reverse_proxy cell-api:3000', out)
self.assertIn('reverse_proxy cell-webui:80', out) self.assertIn('reverse_proxy cell-webui:8080', out)
class TestReloadCaddyAdminAPI(unittest.TestCase): class TestReloadCaddyAdminAPI(unittest.TestCase):
@@ -218,7 +218,7 @@ class TestReloadCaddyAdminAPI(unittest.TestCase):
# Point at a tmp Caddyfile so we can read it back during reload. # Point at a tmp Caddyfile so we can read it back during reload.
import tempfile import tempfile
tmp = tempfile.NamedTemporaryFile('w', delete=False, suffix='.caddyfile') tmp = tempfile.NamedTemporaryFile('w', delete=False, suffix='.caddyfile')
tmp.write(":80 { reverse_proxy cell-webui:80 }\n") tmp.write(":80 { reverse_proxy cell-webui:8080 }\n")
tmp.close() tmp.close()
mgr.caddyfile_path = tmp.name mgr.caddyfile_path = tmp.name
@@ -232,7 +232,7 @@ class TestReloadCaddyAdminAPI(unittest.TestCase):
# First positional arg is the URL # First positional arg is the URL
self.assertEqual(args[0], 'http://cell-caddy:2019/load') self.assertEqual(args[0], 'http://cell-caddy:2019/load')
self.assertEqual(kwargs['headers']['Content-Type'], 'text/caddyfile') self.assertEqual(kwargs['headers']['Content-Type'], 'text/caddyfile')
self.assertIn('cell-webui:80', kwargs['data']) self.assertIn('cell-webui:8080', kwargs['data'])
os.unlink(tmp.name) os.unlink(tmp.name)
+1 -1
View File
@@ -613,7 +613,7 @@ class TestCaddyManagerEmptyActiveRegistry(unittest.TestCase):
identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'} identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'}
caddyfile = mgr.generate_caddyfile(identity, []) caddyfile = mgr.generate_caddyfile(identity, [])
self.assertIn('cell-api:3000', caddyfile) self.assertIn('cell-api:3000', caddyfile)
self.assertIn('cell-webui:80', caddyfile) self.assertIn('cell-webui:8080', caddyfile)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -624,4 +624,4 @@ class TestCaddyfileGeneration:
"""Container-internal routing must use service names not IPs.""" """Container-internal routing must use service names not IPs."""
assert 'cell-api:3000' in caddyfile assert 'cell-api:3000' in caddyfile
assert 'cell-radicale:5232' in caddyfile assert 'cell-radicale:5232' in caddyfile
assert 'cell-webui:80' in caddyfile assert 'cell-webui:8080' in caddyfile
+13 -14
View File
@@ -1,14 +1,13 @@
# Stage 1: Build # Stage 1: Build
FROM node:18-alpine AS builder FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS builder
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json package-lock.json* ./
RUN npm install RUN npm ci || npm install
COPY . . COPY . .
RUN npm run build RUN npm run build
# Stage 2: Serve with nginx # Stage 2: Serve with non-root nginx (listens on 8080 as an unprivileged user)
FROM nginx:alpine FROM nginxinc/nginx-unprivileged:alpine@sha256:85bcbc6b2edd325462560c597d784ecee415024f1c6a004e53ac5f202b8ca561
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
+1 -1
View File
@@ -1,5 +1,5 @@
server { server {
listen 80; listen 8080;
server_name localhost; server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
+17
View File
@@ -0,0 +1,17 @@
FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
RUN apk add --no-cache wireguard-tools iptables ip6tables iproute2
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# This image uses the host kernel's builtin WireGuard module (default on modern
# kernels >= 5.6). It needs only CAP_NET_ADMIN — no privileged mode, no
# SYS_MODULE, no /lib/modules mount.
#
# FALLBACK for old kernels lacking builtin WireGuard: re-add to the compose
# service: privileged: true / cap_add: SYS_MODULE / volume /lib/modules:/lib/modules
# and `apk add wireguard-tools` ships the kmod loader path. The slim/unprivileged
# default below assumes a builtin module.
ENTRYPOINT ["/entrypoint.sh"]
+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