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:
+17
-23
@@ -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"]
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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/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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+6
-7
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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