feat: harden Docker configurations and commands

This commit is contained in:
Ivan 2026-07-26 06:35:03 -05:00
parent ff4e4488d1
commit f55079c5e9
No known key found for this signature in database
14 changed files with 244 additions and 18 deletions

View file

@ -85,7 +85,13 @@ docker compose up -d
```bash
docker run -d --name reticulum-meshchatx \
--restart unless-stopped \
--init \
--user 1000:1000 \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=256m \
--tmpfs /home/meshchat:nosuid,size=64m \
--cpus=2.0 \
--memory=1g \
--memory-reservation=256m \

View file

@ -1,24 +1,35 @@
# Coolify Docker Compose deployment for MeshChatX.
# https://coolify.io/docs/knowledge-base/docker/compose
#
# In Coolify, assign a domain with container port 8000, for example:
# Assign a domain in Coolify with container port 8000, for example:
# https://meshchatx.example.com:8000
# Coolify terminates TLS at the proxy. This stack serves plain HTTP inside the
# container (MESHCHAT_NO_HTTPS=1) so Traefik can reach it without a self-signed
# backend cert. Do not publish host ports unless you intend to bypass the proxy.
# The :8000 suffix is the container port for the proxy, not the public URL port.
#
# Coolify terminates TLS at the proxy. Plain HTTP inside the container
# (MESHCHAT_NO_HTTPS=1) avoids a self-signed HTTPS backend. Do not add host
# ports unless you intend to bypass the proxy.
services:
reticulum-meshchatx:
meshchatx:
image: ${MESHCHAT_IMAGE:-quad4io/meshchatx:latest}
restart: unless-stopped
init: true
user: "1000:1000"
security_opt:
- no-new-privileges:true
expose:
- "8000"
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=256m
- /home/meshchat:nosuid,size=64m
environment:
- SERVICE_URL_MESHCHATX_8000
- MESHCHAT_HOST=${MESHCHAT_HOST:-0.0.0.0}
- MESHCHAT_PORT=${MESHCHAT_PORT:-8000}
- MESHCHAT_NO_HTTPS=${MESHCHAT_NO_HTTPS:-1}
- MESHCHAT_AUTH=${MESHCHAT_AUTH:-1}
- MESHCHAT_PORT=${MESHCHAT_PORT:-8000}
- MESHCHAT_HEADLESS=${MESHCHAT_HEADLESS:-1}
volumes:
- meshchatx-config:/config
healthcheck:
@ -33,15 +44,10 @@ services:
timeout: 5s
retries: 3
start_period: 90s
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
pids: 512
reservations:
cpus: "0.25"
memory: 256M
cpus: 2.0
mem_limit: 1g
mem_reservation: 256m
pids_limit: 512
volumes:
meshchatx-config:

View file

@ -6,12 +6,32 @@ services:
container_name: reticulum-meshchatx
image: reticulum-meshchatx:local
restart: unless-stopped
init: true
user: "1000:1000"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=256m
- /home/meshchat:nosuid,size=64m
ports:
- 127.0.0.1:8000:8000
volumes:
- meshchatx-config:/config
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import ssl, urllib.request; urllib.request.urlopen('https://127.0.0.1:8000/api/v1/status', context=ssl._create_unverified_context())",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 90s
deploy:
resources:
limits:

View file

@ -3,12 +3,32 @@ services:
container_name: reticulum-meshchatx
image: ${MESHCHAT_IMAGE:-quad4io/meshchatx:latest}
restart: unless-stopped
init: true
user: "1000:1000"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=256m
- /home/meshchat:nosuid,size=64m
ports:
- 127.0.0.1:8000:8000
volumes:
- meshchatx-config:/config
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import ssl, urllib.request; urllib.request.urlopen('https://127.0.0.1:8000/api/v1/status', context=ssl._create_unverified_context())",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 90s
deploy:
resources:
limits:

View file

@ -41,7 +41,13 @@ Manual run with a named volume for persistence:
```bash
docker run -d --name reticulum-meshchatx \
--restart unless-stopped \
--init \
--user 1000:1000 \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=256m \
--tmpfs /home/meshchat:nosuid,size=64m \
--cpus=2.0 \
--memory=1g \
--memory-reservation=256m \

View file

@ -88,7 +88,13 @@ docker compose up -d
```bash
docker run -d --name reticulum-meshchatx \
--restart unless-stopped \
--init \
--user 1000:1000 \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=256m \
--tmpfs /home/meshchat:nosuid,size=64m \
--cpus=2.0 \
--memory=1g \
--memory-reservation=256m \

View file

@ -88,7 +88,13 @@ docker compose up -d
```bash
docker run -d --name reticulum-meshchatx \
--restart unless-stopped \
--init \
--user 1000:1000 \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=256m \
--tmpfs /home/meshchat:nosuid,size=64m \
--cpus=2.0 \
--memory=1g \
--memory-reservation=256m \

View file

@ -88,7 +88,13 @@ docker compose up -d
```bash
docker run -d --name reticulum-meshchatx \
--restart unless-stopped \
--init \
--user 1000:1000 \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=256m \
--tmpfs /home/meshchat:nosuid,size=64m \
--cpus=2.0 \
--memory=1g \
--memory-reservation=256m \

View file

@ -88,7 +88,13 @@ docker compose up -d
```bash
docker run -d --name reticulum-meshchatx \
--restart unless-stopped \
--init \
--user 1000:1000 \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=256m \
--tmpfs /home/meshchat:nosuid,size=64m \
--cpus=2.0 \
--memory=1g \
--memory-reservation=256m \

View file

@ -88,7 +88,13 @@ docker compose up -d
```bash
docker run -d --name reticulum-meshchatx \
--restart unless-stopped \
--init \
--user 1000:1000 \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=256m \
--tmpfs /home/meshchat:nosuid,size=64m \
--cpus=2.0 \
--memory=1g \
--memory-reservation=256m \

Binary file not shown.

View file

@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Build the standard image and verify hardened docker compose stacks and docker run.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
IMAGE="${MESHCHAT_DOCKER_SMOKE_IMAGE:-reticulum-meshchatx:local}"
RUN_CONTAINER="${MESHCHAT_DOCKER_RUN_SMOKE_CONTAINER:-meshchatx-hardened-run-test}"
RUN_PORT="${MESHCHAT_DOCKER_RUN_SMOKE_PORT:-18081}"
TIMEOUT_SEC="${MESHCHAT_DOCKER_SMOKE_TIMEOUT:-180}"
CONFIG_DIR="${MESHCHAT_DOCKER_SMOKE_CONFIG:-$(mktemp -d)}"
cleanup() {
docker compose -f docker-compose.yml down >/dev/null 2>&1 || true
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
docker compose -f docker-compose.coolify.yml down >/dev/null 2>&1 || true
docker rm -f "$RUN_CONTAINER" >/dev/null 2>&1 || true
if [ "${MESHCHAT_DOCKER_SMOKE_KEEP_CONFIG:-0}" != "1" ] && [ -n "${TMP_CONFIG_CREATED:-}" ]; then
rm -rf "$CONFIG_DIR"
fi
}
trap cleanup EXIT
if [ -z "${MESHCHAT_DOCKER_SMOKE_CONFIG:-}" ]; then
TMP_CONFIG_CREATED=1
fi
chmod 777 "$CONFIG_DIR"
wait_https() {
local port="$1"
local deadline=$((SECONDS + TIMEOUT_SEC))
while [ "$SECONDS" -lt "$deadline" ]; do
if out=$(curl -fsSk "https://127.0.0.1:${port}/api/v1/status" 2>/dev/null) &&
printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("status")=="ok" else 1)'; then
echo "$out"
return 0
fi
sleep 3
done
return 1
}
wait_container_healthy() {
local name="$1"
local deadline=$((SECONDS + TIMEOUT_SEC))
local state=""
while [ "$SECONDS" -lt "$deadline" ]; do
state=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name" 2>/dev/null || echo missing)
if [ "$state" = "healthy" ]; then
return 0
fi
if [ "$state" = "unhealthy" ]; then
docker logs "$name" >&2 || true
return 1
fi
sleep 3
done
echo "Timed out waiting for container health (last state: ${state})" >&2
docker logs "$name" >&2 || true
return 1
}
echo "Building Docker image ${IMAGE}..."
docker build -f Dockerfile -t "$IMAGE" .
echo "=== docker-compose.yml (hardened) ==="
MESHCHAT_IMAGE="$IMAGE" docker compose -f docker-compose.yml up -d --pull never
wait_container_healthy reticulum-meshchatx
wait_https 8000 >/dev/null
docker compose -f docker-compose.yml down
echo "=== docker-compose.dev.yml (hardened) ==="
docker compose -f docker-compose.dev.yml up -d --pull never
wait_container_healthy reticulum-meshchatx
wait_https 8000 >/dev/null
docker compose -f docker-compose.dev.yml down
echo "=== docker-compose.coolify.yml (hardened) ==="
MESHCHAT_IMAGE="$IMAGE" docker compose -f docker-compose.coolify.yml up -d --pull never
coolify_cid=$(docker compose -f docker-compose.coolify.yml ps -q meshchatx)
wait_container_healthy "$coolify_cid"
docker exec "$coolify_cid" python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/api/v1/status').read().decode())" >/dev/null
docker compose -f docker-compose.coolify.yml down
echo "=== docker run (hardened) ==="
docker run -d --name "$RUN_CONTAINER" \
--restart unless-stopped \
--init \
--user 1000:1000 \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=256m \
--tmpfs /home/meshchat:nosuid,size=64m \
--cpus=2.0 \
--memory=1g \
--memory-reservation=256m \
--pids-limit=512 \
-p "127.0.0.1:${RUN_PORT}:8000" \
-v "${CONFIG_DIR}:/config" \
"$IMAGE" >/dev/null
status_json=$(wait_https "$RUN_PORT")
echo "Docker hardened smoke passed: ${status_json}"

View file

@ -35,11 +35,18 @@ mkdir -p "$CONFIG_DIR"
echo "Starting container on host port ${HOST_PORT}..."
docker run -d \
--name "$CONTAINER" \
--init \
--user 1000:1000 \
--security-opt no-new-privileges:true \
--cap-drop ALL \
--read-only \
--tmpfs /tmp:noexec,nosuid,size=256m \
--tmpfs /home/meshchat:nosuid,size=64m \
--cpus=2.0 \
--memory=1g \
--memory-reservation=256m \
--pids-limit=512 \
-p "${HOST_PORT}:8000" \
-p "127.0.0.1:${HOST_PORT}:8000" \
-v "${CONFIG_DIR}:/config" \
"$IMAGE" >/dev/null

View file

@ -13,6 +13,7 @@ import pytest
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SMOKE_SCRIPT = _REPO_ROOT / "scripts" / "ci" / "docker-runtime-smoke.sh"
_HARDENED_SMOKE_SCRIPT = _REPO_ROOT / "scripts" / "ci" / "docker-hardened-smoke.sh"
def _docker_available() -> bool:
@ -56,6 +57,31 @@ def test_docker_image_serves_status():
assert '"status": "ok"' in result.stdout or '"status":"ok"' in result.stdout
@pytest.mark.integration
@pytest.mark.skipif(
os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",
reason="Set MESHCHAT_DOCKER_SMOKE=1 to run Docker hardened compose + run smoke",
)
@pytest.mark.skipif(not _docker_available(), reason="Docker is not available")
def test_docker_hardened_compose_and_run_serve_status():
env = os.environ.copy()
env.setdefault("MESHCHAT_DOCKER_SMOKE_TIMEOUT", "240")
result = subprocess.run(
["bash", str(_HARDENED_SMOKE_SCRIPT)],
cwd=_REPO_ROOT,
env=env,
capture_output=True,
text=True,
timeout=int(env["MESHCHAT_DOCKER_SMOKE_TIMEOUT"]) + 600,
check=False,
)
assert result.returncode == 0, (
f"Docker hardened smoke failed (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
assert '"status": "ok"' in result.stdout or '"status":"ok"' in result.stdout
@pytest.mark.integration
@pytest.mark.skipif(
os.environ.get("MESHCHAT_DOCKER_SMOKE") != "1",