diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef0389629..cf48d1b6d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1161,7 +1161,7 @@ jobs: docker run -d --name trek-ci -p 127.0.0.1:3000:3000 \ --read-only --tmpfs /tmp:noexec,nosuid,size=128m \ --security-opt no-new-privileges:true \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ + --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add KILL \ -v "$RUNNER_TEMP/trek/data:/app/data" \ -v "$RUNNER_TEMP/trek/uploads:/app/uploads" \ -e ENCRYPTION_KEY="$(openssl rand -hex 32)" \ @@ -1194,6 +1194,17 @@ jobs: curl -sf http://127.0.0.1:3000/ | grep -q '
' \ || { echo "::error::/ does not serve the client"; exit 1; } + # Same capabilities as the shipped compose file: dumb-init has to be able + # to pass the stop signal on to node, or docker stop ends in SIGKILL (137). + - name: Stop gracefully + run: | + set -euo pipefail + docker stop -t 30 trek-ci >/dev/null + code="$(docker inspect -f '{{.State.ExitCode}}' trek-ci)" + [ "$code" = "0" ] || { echo "::error::docker stop ended with exit code $code, not a graceful shutdown"; exit 1; } + docker logs trek-ci 2>&1 | grep -q 'SIGTERM received' \ + || { echo "::error::the server never saw SIGTERM"; exit 1; } + - name: Container log if: always() run: docker logs trek-ci 2>&1 || true diff --git a/docker-compose.yml b/docker-compose.yml index ab879c62a..5c0c97f76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,10 @@ services: - CHOWN - SETUID - SETGID + # dumb-init runs as root and forwards SIGTERM to node, which runs as the + # node user; signalling another user's process needs KILL. Without it + # docker stop ends in SIGKILL and the graceful shutdown never runs. + - KILL tmpfs: - /tmp:noexec,nosuid,size=128m ports: diff --git a/wiki/Install-Docker-Compose.md b/wiki/Install-Docker-Compose.md index 1027650cc..1379a9a9b 100644 --- a/wiki/Install-Docker-Compose.md +++ b/wiki/Install-Docker-Compose.md @@ -15,7 +15,7 @@ The compose file ships with several hardening options enabled by default: | `read_only: true` | Mounts the container filesystem read-only; only the two mounted volumes (`/app/data`, `/app/uploads`) and `/tmp` are writable | | `security_opt: no-new-privileges:true` | Prevents the process from gaining additional Linux privileges via setuid/setgid executables | | `cap_drop: [ALL]` | Drops all Linux capabilities from the container | -| `cap_add: [CHOWN, SETUID, SETGID]` | Adds back only the capabilities needed for the entrypoint to drop privileges to the `node` user | +| `cap_add: [CHOWN, SETUID, SETGID, KILL]` | Adds back only what the container needs: the first three let the entrypoint drop privileges to the `node` user, `KILL` lets `dumb-init` pass the stop signal on to it, so `docker stop` shuts TREK down cleanly instead of killing it | | `tmpfs: /tmp:noexec,nosuid,size=128m` | Mounts a 128 MB in-memory `/tmp`; required because the container root is read-only | > **Note (Docker from snap):** If you installed Docker via `snap` (config under `/var/snap/docker/...`), `no-new-privileges:true` will prevent the container from starting with `exec /usr/bin/dumb-init: operation not permitted`. This is a [snap/AppArmor limitation](https://bugs.launchpad.net/snapd/+bug/1908448), not a TREK issue — install Docker from the [official apt repository](https://docs.docker.com/engine/install/ubuntu/) instead, or remove `no-new-privileges`. See [Troubleshooting](Troubleshooting#container-wont-start-exec-usrbindumb-init-operation-not-permitted).