From f55079c5e9af4817e64af69457775e7934b67b3e Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 26 Jul 2026 06:35:03 -0500 Subject: [PATCH] feat: harden Docker configurations and commands --- README.md | 6 ++ docker-compose.coolify.yml | 40 ++++---- docker-compose.dev.yml | 20 ++++ docker-compose.yml | 20 ++++ docs/en/installation.md | 6 ++ lang/README.de.md | 6 ++ lang/README.it.md | 6 ++ lang/README.ja.md | 6 ++ lang/README.ru.md | 6 ++ lang/README.zh.md | 6 ++ meshchatx.rsm | Bin 183386 -> 183488 bytes scripts/ci/docker-hardened-smoke.sh | 105 +++++++++++++++++++++ scripts/ci/docker-runtime-smoke.sh | 9 +- tests/backend/test_docker_runtime_smoke.py | 26 +++++ 14 files changed, 244 insertions(+), 18 deletions(-) create mode 100755 scripts/ci/docker-hardened-smoke.sh 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 9d8d4296a92e8d33dac94a7be9284460f8941f0a..28f488dbd70feaea4152b8bb49a270013a330714 100644 GIT binary patch delta 1131 zcmY+DJ!qa)6vzAOB8gN*D}K^15)i>r9VBW%L|haHrwW1=T`VOCqKk_{@kCrYUG91AbD#e?zu)uI_Via*rbpj= zc71a5xf9c`Z9notIdtv)H{UvT^u)~ajZdGN+?e_B>V;=6KKS$Tqi25n@{|1SyL9DL zx#zn-=N?;+AARt{_}QJ=+b@qE81CHr_ol1da1)D{vqkSx7s*Bjf3 zvU}uiof-A!i&eA>_bkke#Eq#yC-g+*Yss;+4n(1QD$WyAz}y4?opW2CKk?{h##Czw z&_mDcL&s2hQLR-;DK>lLqpF0EfP*XK2pxu8eGp1OFdWJ%pVjqQUR_yRo#>qC%X6=; zoSW*^SpX-slh;b@3#Z;&=+!H$cdbFqF~_LB zdQ7T0^_*jm)C2mOZBPu@ERqMzA!{R%7$o=HA~#}myQdfLIK0VLYCTqMxnzcx6sdX_ zqZbBFk;N)in&XO;qPGg>lq$MZxkon~p2PDm&Od*0;pxSb*5cfXOy!n1O9>c)muMS? z8kr4aLWo@@=N1ed_BswNbM(Q!1ZpAKVM==IrQ?l*OUVT^;@&4srK)S5xusk+i0fL4 zhNg~FX+^XXMBh8xqAcpYvE3Ri-M9IlF}~Q)tO=CnJw(^C^;Cou$f8Oq4mzX7OajU9 zOBJDR?K9sRZ2hH!V2rVN#}Fua0+5=pIsq&O*=JV~u}LM@6CN=&dJROFTx_OE)fD{2 z{iVJArNN+WLMSCSS8A(4q*OE>P0b1>?nu$X0wZUKF+!85u~Mv^&EF0^4~G+8AFpjp z=6O8-zkJKME_z;NcCxa1R_7+mQ+85C>>V}b)%5~C88LGCm~yaHoBeNQeMgao7*i-D zfix0U0>r)JZx7Baj*4{Kga*LUjfp~uv77zQkq-xEh89d*f^6Nhp)p~c^xz?w;PB4E OFN6E{uKqfBeg9v;YF#b> delta 968 zcmZ9LJ!s!$5Xb$4IT7;rE{gM&yw?w)&|7;KtQqtrno z=wOBlwa}NP)ycsw5;Wo@7912DghF+2C>Ek1SRB-$=w;O3^uX`AFL$5MJzrir_~XTc z(_gM0y`pz--}nBlH#e?6yzs}3)7LMbS$g){=Wkv-a{c&A@7y_dV>bML=Jt0#y>;?< zdt~zA3%}kvyve(l|C!&uwEoOJXQvO&-u2n~`9I&))o(uk{?nt^RzBLj`PE4-=X}yg`W`R>rnMRd{03*ket&5>@BsllQ<43ll zO6Zvcghg8@{sr%4)I^jzc5G=tY+bu_$z9_J8IamYsin*?J8SFh<$mh1w!XHp{z{*- z{@UE@YbOpETFshNvqT}}!lN;>dM346LJz$)dk9+v0SgeVQ!dgGlLd?kcQ&5*;Qk9A zZ%&@tSz5gJ(3WFq4SJ0RY+f?5RtsGV_TW56qT#=y^X!9%Kw#k-lZ-lYXh^fOC*Hs{vtK=Lc4RCY*}iL4uBip3S}E@53G^UD}ov_X8CXE z>gIlEi>N)ZqznN#n@iTp0##uEC}$rS(r7g>_T&Ra$uNc|LJW#{Zo2%yR;VUeArBY@ z9X$4C5v*58QgIZ;z{NPk#>ycfHYH^tHQyfM|21~%)&0hNOjL?d&02B+F1{u9Gmuh2 zb=zb^Gh3KZ*1;*@aK}wpHTmVY_m+3}mM4=s@5s8943;B%QMb+>laXkE`xEsIAy>*? zOYS}v@UXhifG~vJ_s&nATbxQHi6cptoC?I;hNt8LzAc(!_JrQtu{vni>Td;Xe^LsS TQS*=sx4)ju?9$JZ?Y(~iQ-3Z! 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",