From f4b8d5c4f7e2e98d6b4c06e81d7aff92e7e58eee Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Wed, 10 Jun 2026 14:07:54 -0400 Subject: [PATCH] harden containers: drop WG privileged, slim images, digest pins; fix WG path + empty chrony.conf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/Dockerfile | 42 ++++++++++------------ api/caddy_manager.py | 2 +- api/ip_utils.py | 6 ++-- docker-compose.yml | 19 ++++------ ntp/Dockerfile | 7 ++++ scripts/setup_cell.py | 9 +++-- tests/test_caddy_manager.py | 6 ++-- tests/test_optional_services_feature.py | 2 +- tests/test_peer_dashboard_services.py | 2 +- webui/Dockerfile | 27 +++++++------- webui/nginx.conf | 2 +- wireguard/Dockerfile | 17 +++++++++ wireguard/entrypoint.sh | 47 +++++++++++++++++++++++++ 13 files changed, 125 insertions(+), 63 deletions(-) create mode 100644 ntp/Dockerfile create mode 100644 wireguard/Dockerfile create mode 100644 wireguard/entrypoint.sh diff --git a/api/Dockerfile b/api/Dockerfile index 83d8ac7..5f3559a 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -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 -# Install system dependencies -RUN apt-get update && apt-get install -y \ - wireguard-tools \ - iptables \ - iproute2 \ - util-linux \ - curl \ - ca-certificates \ - gnupg \ - lsb-release \ - && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \ - && 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 \ - && apt-get update \ - && apt-get install -y docker-ce-cli \ - && rm -rf /var/lib/apt/lists/* +# The API runs as root by design: it drives iptables, the docker socket, and +# docker-execs into sibling containers. Non-root is not feasible here. +COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + wireguard-tools \ + iptables \ + iproute2 \ + util-linux \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /app/data /app/config -# Copy requirements first for better caching COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copy all application code into /app/api COPY . . -# Create necessary directories -RUN mkdir -p /app/data /app/config - -# Expose port EXPOSE 3000 -# Run the application -CMD ["python", "app.py"] \ No newline at end of file +CMD ["python", "app.py"] diff --git a/api/caddy_manager.py b/api/caddy_manager.py index 9113e90..a203f43 100644 --- a/api/caddy_manager.py +++ b/api/caddy_manager.py @@ -145,7 +145,7 @@ class CaddyManager(BaseServiceManager): " reverse_proxy cell-api:3000\n" " }\n" " handle {\n" - " reverse_proxy cell-webui:80\n" + " reverse_proxy cell-webui:8080\n" " }" ) diff --git a/api/ip_utils.py b/api/ip_utils.py index 0b1dacb..b76e72c 100644 --- a/api/ip_utils.py +++ b/api/ip_utils.py @@ -164,7 +164,7 @@ http://{cell_name}.{domain}, http://{caddy_ip}:80 {{ reverse_proxy cell-rainloop:8888 }} handle {{ - reverse_proxy cell-webui:80 + reverse_proxy cell-webui:8080 }} }} @@ -190,7 +190,7 @@ http://api.{domain} {{ }} http://webui.{domain} {{ - reverse_proxy cell-webui:80 + reverse_proxy cell-webui:8080 }} # Catch-all for direct IP / localhost @@ -199,7 +199,7 @@ http://webui.{domain} {{ reverse_proxy cell-api:3000 }} handle {{ - reverse_proxy cell-webui:80 + reverse_proxy cell-webui:8080 }} }} """ diff --git a/docker-compose.yml b/docker-compose.yml index e12aa96..1c92bba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: # DNS Server - CoreDNS for .cell TLD resolution dns: - image: coredns/coredns:latest + image: coredns/coredns:1.11.3@sha256:9caabbf6238b189a65d0d6e6ac138de60d6a1c419e5a341fbbb7c78382559c6e container_name: cell-dns profiles: ["core", "full"] command: ["-conf", "/etc/coredns/Corefile"] @@ -53,7 +53,7 @@ services: # NTP Server - chrony for time synchronization ntp: - image: alpine:latest + build: ./ntp container_name: cell-ntp profiles: ["core", "full"] ports: @@ -69,7 +69,6 @@ services: ipv4_address: ${NTP_IP:-172.20.0.5} cap_add: - 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: driver: json-file options: @@ -78,18 +77,13 @@ services: # WireGuard VPN wireguard: - image: linuxserver/wireguard:latest + build: ./wireguard container_name: cell-wireguard profiles: ["core", "full"] - environment: - - SERVERMODE=true - - PUID=${PUID:-1000} - - PGID=${PGID:-1000} ports: - "${WG_PORT:-51820}:${WG_PORT:-51820}/udp" volumes: - ./config/wireguard:/config - - /lib/modules:/lib/modules restart: unless-stopped mem_limit: 256m cpus: 0.5 @@ -99,8 +93,9 @@ services: ipv4_address: ${WG_IP:-172.20.0.9} cap_add: - NET_ADMIN - - SYS_MODULE - privileged: true + # FALLBACK for kernels lacking builtin WireGuard: re-add `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: - net.ipv4.conf.all.src_valid_mark=1 - net.ipv4.ip_forward=1 @@ -157,7 +152,7 @@ services: container_name: cell-webui profiles: ["core", "full"] ports: - - "${WEBUI_PORT:-8081}:80" + - "${WEBUI_PORT:-8081}:8080" restart: unless-stopped mem_limit: 256m cpus: 0.5 diff --git a/ntp/Dockerfile b/ntp/Dockerfile new file mode 100644 index 0000000..01a80b8 --- /dev/null +++ b/ntp/Dockerfile @@ -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"] diff --git a/scripts/setup_cell.py b/scripts/setup_cell.py index 600204b..ca2a5f8 100644 --- a/scripts/setup_cell.py +++ b/scripts/setup_cell.py @@ -19,6 +19,7 @@ REQUIRED_DIRS = [ 'config/dns', 'config/ntp', 'config/wireguard', + 'config/wireguard/wg_confs', 'config/api', 'data/caddy', 'data/dns', @@ -133,9 +134,11 @@ def generate_wg_keys(): 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): - print('[EXISTS] config/wireguard/wg0.conf') + print('[EXISTS] config/wireguard/wg_confs/wg0.conf') return server_ip = address.split('/')[0] content = ( @@ -153,7 +156,7 @@ def write_wg0_conf(private_key: str, address: str, port: int): with open(wg_conf, 'w') as f: f.write(content) 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, diff --git a/tests/test_caddy_manager.py b/tests/test_caddy_manager.py index 6f439d9..9437d63 100644 --- a/tests/test_caddy_manager.py +++ b/tests/test_caddy_manager.py @@ -209,7 +209,7 @@ class TestServiceRoutesIncluded(unittest.TestCase): self.assertIn('reverse_proxy cell-filegator:8080', out) # Core routes still emitted 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): @@ -218,7 +218,7 @@ class TestReloadCaddyAdminAPI(unittest.TestCase): # Point at a tmp Caddyfile so we can read it back during reload. import tempfile 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() mgr.caddyfile_path = tmp.name @@ -232,7 +232,7 @@ class TestReloadCaddyAdminAPI(unittest.TestCase): # First positional arg is the URL self.assertEqual(args[0], 'http://cell-caddy:2019/load') 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) diff --git a/tests/test_optional_services_feature.py b/tests/test_optional_services_feature.py index 7c854a8..c63ed05 100644 --- a/tests/test_optional_services_feature.py +++ b/tests/test_optional_services_feature.py @@ -613,7 +613,7 @@ class TestCaddyManagerEmptyActiveRegistry(unittest.TestCase): identity = {'cell_name': 'alpha', 'domain_mode': 'pic_ngo'} caddyfile = mgr.generate_caddyfile(identity, []) self.assertIn('cell-api:3000', caddyfile) - self.assertIn('cell-webui:80', caddyfile) + self.assertIn('cell-webui:8080', caddyfile) # --------------------------------------------------------------------------- diff --git a/tests/test_peer_dashboard_services.py b/tests/test_peer_dashboard_services.py index c03fe08..6151e8a 100644 --- a/tests/test_peer_dashboard_services.py +++ b/tests/test_peer_dashboard_services.py @@ -624,4 +624,4 @@ class TestCaddyfileGeneration: """Container-internal routing must use service names not IPs.""" assert 'cell-api:3000' in caddyfile assert 'cell-radicale:5232' in caddyfile - assert 'cell-webui:80' in caddyfile + assert 'cell-webui:8080' in caddyfile diff --git a/webui/Dockerfile b/webui/Dockerfile index fd7665c..f67f026 100644 --- a/webui/Dockerfile +++ b/webui/Dockerfile @@ -1,14 +1,13 @@ -# Stage 1: Build -FROM node:18-alpine AS builder -WORKDIR /app -COPY package.json ./ -RUN npm install -COPY . . -RUN npm run build - -# Stage 2: Serve with nginx -FROM nginx:alpine -COPY --from=builder /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +# Stage 1: Build +FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci || npm install +COPY . . +RUN npm run build + +# Stage 2: Serve with non-root nginx (listens on 8080 as an unprivileged user) +FROM nginxinc/nginx-unprivileged:alpine@sha256:85bcbc6b2edd325462560c597d784ecee415024f1c6a004e53ac5f202b8ca561 +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8080 diff --git a/webui/nginx.conf b/webui/nginx.conf index 8db4e38..f3704f0 100644 --- a/webui/nginx.conf +++ b/webui/nginx.conf @@ -1,5 +1,5 @@ server { - listen 80; + listen 8080; server_name localhost; root /usr/share/nginx/html; index index.html; diff --git a/wireguard/Dockerfile b/wireguard/Dockerfile new file mode 100644 index 0000000..9a88c54 --- /dev/null +++ b/wireguard/Dockerfile @@ -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"] diff --git a/wireguard/entrypoint.sh b/wireguard/entrypoint.sh new file mode 100644 index 0000000..91888b2 --- /dev/null +++ b/wireguard/entrypoint.sh @@ -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