diff --git a/README.md b/README.md index a900c8e2..6f15b874 100644 --- a/README.md +++ b/README.md @@ -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 \ diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 3e005f41..f23f4076 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -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: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 63f0f1c9..d24698f0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 52ca9a8a..81d8a337 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/en/installation.md b/docs/en/installation.md index db87df23..2cded43d 100644 --- a/docs/en/installation.md +++ b/docs/en/installation.md @@ -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 \ diff --git a/lang/README.de.md b/lang/README.de.md index 63f6affb..119ecb27 100644 --- a/lang/README.de.md +++ b/lang/README.de.md @@ -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 \ diff --git a/lang/README.it.md b/lang/README.it.md index feb3cd48..22bffc6b 100644 --- a/lang/README.it.md +++ b/lang/README.it.md @@ -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 \ diff --git a/lang/README.ja.md b/lang/README.ja.md index 7715eea8..55f41d57 100644 --- a/lang/README.ja.md +++ b/lang/README.ja.md @@ -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 \ diff --git a/lang/README.ru.md b/lang/README.ru.md index b21d5946..c1e40991 100644 --- a/lang/README.ru.md +++ b/lang/README.ru.md @@ -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 \ diff --git a/lang/README.zh.md b/lang/README.zh.md index 85850856..4fa15afe 100644 --- a/lang/README.zh.md +++ b/lang/README.zh.md @@ -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 \ diff --git a/meshchatx.rsm b/meshchatx.rsm index 9d8d4296..28f488db 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/scripts/ci/docker-hardened-smoke.sh b/scripts/ci/docker-hardened-smoke.sh new file mode 100755 index 00000000..2c8809e5 --- /dev/null +++ b/scripts/ci/docker-hardened-smoke.sh @@ -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}" diff --git a/scripts/ci/docker-runtime-smoke.sh b/scripts/ci/docker-runtime-smoke.sh index 0011b85a..2937c9f2 100755 --- a/scripts/ci/docker-runtime-smoke.sh +++ b/scripts/ci/docker-runtime-smoke.sh @@ -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 diff --git a/tests/backend/test_docker_runtime_smoke.py b/tests/backend/test_docker_runtime_smoke.py index 9b42851f..190cb122 100644 --- a/tests/backend/test_docker_runtime_smoke.py +++ b/tests/backend/test_docker_runtime_smoke.py @@ -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",