From 3f90d0d8ddb69e103a3b65b9f2d933a5cd8c4dcd Mon Sep 17 00:00:00 2001 From: "Luis Gustavo S. Barreto" Date: Fri, 2 Oct 2026 16:53:21 -0300 Subject: [PATCH] feat: run the dev stack under rootless Docker The server mounts the Docker socket for testcontainers. Under a rootless daemon that socket lives at $XDG_RUNTIME_DIR/docker.sock, so in development, when `docker info` reports rootless, the wrapper asks the CLI where the daemon is (DOCKER_HOST, else the active context) and hands the path to the dev overlay. Only then: Docker Desktop and Colima also report a unix:// endpoint under the user's home, but the daemon resolves bind-mount sources inside its VM, where only /var/run/docker.sock exists. The container side stays /var/run/docker.sock so nothing in the tests changes. TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE carries the host path because testcontainers passes it to the daemon when it bind-mounts the socket into Ryuk; the default there is /var/run/docker.sock again, which under rootless is the wrong daemon or nothing. Rootless cannot publish ports below net.ipv4.ip_unprivileged_port_start unless rootlesskit carries cap_net_bind_service. The dev defaults are 80 and 22, so in development the wrapper checks both and aborts with the setcap command instead of leaving Compose to fail on the bind. The 1024 fallback is the kernel default for that sysctl. sysctl and getcap sit in /usr/sbin on Debian, outside a user's PATH, so the wrapper adds the sbin directories when calling them; a getcap that is still missing (exit 127) turns the abort into a warning, since the wrapper then cannot tell. SHELLHUB_SSH_PORT may carry a bind address, 127.0.0.1:22, so the comparison strips everything up to the last colon. --- bin/docker-compose | 44 ++++++++++++++++ docker-compose.dev.yml | 3 +- tests/compose/helpers.bash | 35 ++++++++++--- tests/compose/wrapper.bats | 100 +++++++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 9 deletions(-) diff --git a/bin/docker-compose b/bin/docker-compose index 4111c803827..ef450980ddf 100755 --- a/bin/docker-compose +++ b/bin/docker-compose @@ -49,6 +49,50 @@ SHELLHUB_POSTGRES_EXTERNAL=$(env_var SHELLHUB_POSTGRES_EXTERNAL false) SHELLHUB_BILLING=$(env_var SHELLHUB_BILLING "") EXTRA_COMPOSE_FILE=${EXTRA_COMPOSE_FILE:-$(env_var EXTRA_COMPOSE_FILE "")} +sbin_path="$PATH:/usr/sbin:/sbin" + +daemon_is_rootless() { + docker info --format '{{.SecurityOptions}}' 2>/dev/null | grep -q 'name=rootless' +} + +daemon_socket() { + docker_host=${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true)} + case "$docker_host" in + unix://*) echo "${docker_host#unix://}" ;; + esac +} + +rootlesskit_capabilities() { + rootlesskit=$(command -v rootlesskit 2>/dev/null || true) + PATH="$sbin_path" getcap "$rootlesskit" 2>/dev/null +} + +lowest_published_port() { + http_port=$(env_var SHELLHUB_HTTP_PORT 80) + ssh_port=$(env_var SHELLHUB_SSH_PORT 22) + ssh_port=${ssh_port##*:} + [ "$ssh_port" -lt "$http_port" ] && echo "$ssh_port" || echo "$http_port" +} + +if [ "$SHELLHUB_ENV" = "development" ] && daemon_is_rootless; then + socket=$(daemon_socket) + [ -z "$socket" ] || export SHELLHUB_DEV_DOCKER_SOCKET="$socket" + + port=$(lowest_published_port) + unprivileged_start=$(PATH="$sbin_path" sysctl -n net.ipv4.ip_unprivileged_port_start 2>/dev/null || echo 1024) + if [ "$port" -lt "$unprivileged_start" ]; then + capabilities=$(rootlesskit_capabilities) && getcap_status=0 || getcap_status=$? + if [ "$getcap_status" -eq 127 ]; then + echo "⚠️ WARNING: getcap not found, cannot tell whether rootless Docker may publish port $port." >&2 + elif ! echo "$capabilities" | grep -q 'cap_net_bind_service'; then + echo "🚫 ERROR: rootless Docker cannot publish port $port. Either grant rootlesskit the capability:" >&2 + echo " sudo setcap cap_net_bind_service=ep \"\$(command -v rootlesskit)\" && systemctl --user restart docker" >&2 + echo " or set SHELLHUB_HTTP_PORT and SHELLHUB_SSH_PORT to $unprivileged_start or above in .env.override." >&2 + exit 1 + fi + fi +fi + # COMPOSE_ENV_FILES — last file wins. Order chosen so that: # - CE defaults are the base # - enterprise defaults augment when edition is enterprise or cloud diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2adb73805ea..bc5a22bb01c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,13 +13,14 @@ services: - ./pkg:/go/src/github.com/shellhub-io/shellhub/pkg - ./openapi:/go/src/github.com/shellhub-io/shellhub/openapi - ./tests:/go/src/github.com/shellhub-io/shellhub/tests - - /var/run/docker.sock:/var/run/docker.sock + - ${SHELLHUB_DEV_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock - ./.golangci.yaml:/.golangci.yaml - ./.mockery.yaml:/go/src/github.com/shellhub-io/shellhub/.mockery.yaml - ./go.sum:/go/src/github.com/shellhub-io/shellhub/go.sum - ./install.sh:/go/src/github.com/shellhub-io/shellhub/install.sh environment: - TESTCONTAINERS_DOCKER_NETWORK=${SHELLHUB_NETWORK} + - TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=${SHELLHUB_DEV_DOCKER_SOCKET:-/var/run/docker.sock} - SHELLHUB_EDITION=${SHELLHUB_EDITION} - SHELLHUB_ENV=${SHELLHUB_ENV} ui: diff --git a/tests/compose/helpers.bash b/tests/compose/helpers.bash index b060a6f0e0c..6b5f7f52aed 100644 --- a/tests/compose/helpers.bash +++ b/tests/compose/helpers.bash @@ -8,11 +8,8 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" -unset EXTRA_COMPOSE_FILE +unset EXTRA_COMPOSE_FILE DOCKER_HOST SHELLHUB_DEV_DOCKER_SOCKET -# Run the wrapper with the given override variables written to a tmpfile, -# while replacing `docker` in PATH with a stub that just echoes the env -# the wrapper exported. # Usage: capture_with VAR1=value VAR2=value ... capture_with() { local stub_dir="$BATS_TEST_TMPDIR/stub" @@ -20,11 +17,33 @@ capture_with() { mkdir -p "$stub_dir" cat > "$stub_dir/docker" <<'EOF' #!/bin/sh -echo "COMPOSE_FILE=$COMPOSE_FILE" -echo "COMPOSE_ENV_FILES=$COMPOSE_ENV_FILES" -echo "COMPOSE_PROFILES=$COMPOSE_PROFILES" +case "$1" in + context) + echo "${STUB_DOCKER_ENDPOINT:-unix:///var/run/docker.sock}" + ;; + info) + [ -z "${STUB_DOCKER_INFO_FAILS:-}" ] || exit 1 + echo "[${STUB_DOCKER_SECURITY:-name=seccomp,profile=builtin name=cgroupns}]" + ;; + *) + echo "COMPOSE_FILE=$COMPOSE_FILE" + echo "COMPOSE_ENV_FILES=$COMPOSE_ENV_FILES" + echo "COMPOSE_PROFILES=$COMPOSE_PROFILES" + echo "SHELLHUB_DEV_DOCKER_SOCKET=$SHELLHUB_DEV_DOCKER_SOCKET" + ;; +esac EOF - chmod +x "$stub_dir/docker" + cat > "$stub_dir/getcap" <<'EOF' +#!/bin/sh +[ -z "${STUB_NO_GETCAP:-}" ] || exit 127 +[ -z "${STUB_GETCAP:-}" ] || echo "$1 $STUB_GETCAP" +EOF + cat > "$stub_dir/sysctl" <<'EOF' +#!/bin/sh +echo "${STUB_SYSCTL_UNPRIVILEGED_PORT_START:-1024}" +EOF + : > "$stub_dir/rootlesskit" + chmod +x "$stub_dir"/* fi local tmp diff --git a/tests/compose/wrapper.bats b/tests/compose/wrapper.bats index 2a16b7f0c51..902c7032037 100644 --- a/tests/compose/wrapper.bats +++ b/tests/compose/wrapper.bats @@ -97,3 +97,103 @@ load helpers out=$(capture_with SHELLHUB_POSTGRES_EXTERNAL=false) [[ "$out" == *"docker-compose.postgres.yml"* ]] } + +@test "docker socket: a rootful daemon leaves the variable unset" { + export STUB_DOCKER_ENDPOINT=unix:///home/dev/.docker/run/docker.sock + out=$(capture_with SHELLHUB_ENV=development) + grep -qx 'SHELLHUB_DEV_DOCKER_SOCKET=' <<< "$out" +} + +@test "docker socket: a rootless daemon resolves to the context's socket" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_DOCKER_ENDPOINT=unix:///run/user/1000/docker.sock + out=$(capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=8080 SHELLHUB_SSH_PORT=2222) + [[ "$out" == *"SHELLHUB_DEV_DOCKER_SOCKET=/run/user/1000/docker.sock"* ]] +} + +@test "docker socket: DOCKER_HOST with a unix socket wins over the context" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_DOCKER_ENDPOINT=unix:///run/user/1000/docker.sock + out=$(DOCKER_HOST=unix:///tmp/other.sock capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=8080 SHELLHUB_SSH_PORT=2222) + [[ "$out" == *"SHELLHUB_DEV_DOCKER_SOCKET=/tmp/other.sock"* ]] +} + +@test "docker socket: DOCKER_HOST with tcp leaves the variable unset" { + export STUB_DOCKER_SECURITY="name=rootless" + out=$(DOCKER_HOST=tcp://10.0.0.1:2375 capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=8080 SHELLHUB_SSH_PORT=2222) + grep -qx 'SHELLHUB_DEV_DOCKER_SOCKET=' <<< "$out" +} + +@test "docker socket: not resolved outside development" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_DOCKER_ENDPOINT=unix:///run/user/1000/docker.sock + out=$(capture_with) + grep -qx 'SHELLHUB_DEV_DOCKER_SOCKET=' <<< "$out" +} + +@test "rootless guard: a privileged port without the capability aborts and names setcap" { + export STUB_DOCKER_SECURITY="name=rootless name=seccomp,profile=builtin" + run capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=80 + [ "$status" -ne 0 ] + [[ "$output" == *"setcap cap_net_bind_service=ep"* ]] +} + +@test "rootless guard: the ssh port counts too" { + export STUB_DOCKER_SECURITY="name=rootless" + run capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=8080 SHELLHUB_SSH_PORT=22 + [ "$status" -ne 0 ] + [[ "$output" == *"port 22"* ]] +} + +@test "rootless guard: the ssh port keeps its bind address out of the comparison" { + export STUB_DOCKER_SECURITY="name=rootless" + run capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=8080 SHELLHUB_SSH_PORT=127.0.0.1:22 + [ "$status" -ne 0 ] + [[ "$output" == *"port 22"* ]] +} + +@test "rootless guard: a missing getcap warns and lets the stack start" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_NO_GETCAP=1 + out=$(capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=80) + [[ "$out" == *"getcap not found"* ]] + [[ "$out" == *"COMPOSE_FILE="* ]] +} + +@test "rootless guard: the capability on rootlesskit lets a privileged port through" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_GETCAP="cap_net_bind_service=ep" + out=$(capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=80) + [[ "$out" == *"COMPOSE_FILE="* ]] +} + +@test "rootless guard: a sysctl that frees the port lets it through" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_SYSCTL_UNPRIVILEGED_PORT_START=0 + out=$(capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=80 SHELLHUB_SSH_PORT=22) + [[ "$out" == *"COMPOSE_FILE="* ]] +} + +@test "rootless guard: a sysctl above 1024 makes a higher port privileged too" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_SYSCTL_UNPRIVILEGED_PORT_START=2048 + run capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=1500 SHELLHUB_SSH_PORT=2222 + [ "$status" -ne 0 ] + [[ "$output" == *"port 1500"* ]] +} + +@test "rootless guard: unprivileged ports pass" { + export STUB_DOCKER_SECURITY="name=rootless" + out=$(capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=8080 SHELLHUB_SSH_PORT=2222) + [[ "$out" == *"COMPOSE_FILE="* ]] +} + +@test "rootless guard: a rootful daemon ignores privileged ports" { + out=$(capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=80) + [[ "$out" == *"COMPOSE_FILE="* ]] +} + +@test "rootless guard: only in development" { + export STUB_DOCKER_SECURITY="name=rootless" + out=$(capture_with SHELLHUB_HTTP_PORT=80) + [[ "$out" == *"COMPOSE_FILE="* ]] +} + +@test "rootless guard: a failing docker info skips the guard" { + export STUB_DOCKER_INFO_FAILS=1 + out=$(capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=80) + [[ "$out" == *"COMPOSE_FILE="* ]] +}