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="* ]] +}