mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: harden Docker configurations and commands
This commit is contained in:
parent
ff4e4488d1
commit
f55079c5e9
14 changed files with 244 additions and 18 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
105
scripts/ci/docker-hardened-smoke.sh
Executable file
105
scripts/ci/docker-hardened-smoke.sh
Executable 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}"
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue