harden containers: drop WG privileged, slim images, digest pins; fix WG path + empty chrony.conf
Unit Tests / test (push) Successful in 12m16s
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:
+18
-24
@@ -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"]
|
||||
CMD ["python", "app.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"
|
||||
" }"
|
||||
)
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
+7
-12
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-14
@@ -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;"]
|
||||
# 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
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user