diff --git a/bin/docker-compose b/bin/docker-compose index 51928733260..99df37e4d1a 100755 --- a/bin/docker-compose +++ b/bin/docker-compose @@ -63,34 +63,22 @@ daemon_socket() { } rootlesskit_capabilities() { - rootlesskit=$(command -v rootlesskit 2>/dev/null || true) + rootlesskit=${ROOTLESSKIT-$(command -v rootlesskit 2>/dev/null)} + [ -n "$rootlesskit" ] || return 127 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" + docker compose config 2>/dev/null \ + | sed -n 's/^ *published: "\{0,1\}\([0-9][0-9]*\)"\{0,1\}$/\1/p' \ + | sort -n | head -n 1 } +rootless=false if [ "$SHELLHUB_ENV" = "development" ] && daemon_is_rootless; then + rootless=true 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: @@ -141,4 +129,20 @@ installed_playwright_version() { export SHELLHUB_WORKSPACE="${SHELLHUB_WORKSPACE:-$(cd .. && pwd -P)}" export E2E_PLAYWRIGHT_VERSION="${E2E_PLAYWRIGHT_VERSION:-$(installed_playwright_version)}" +if [ "$rootless" = true ]; then + port=$(lowest_published_port) + unprivileged_start=$(PATH="$sbin_path" sysctl -n net.ipv4.ip_unprivileged_port_start 2>/dev/null || echo 1024) + if [ -n "$port" ] && [ "$port" -lt "$unprivileged_start" ]; then + capabilities=$(rootlesskit_capabilities) && getcap_status=0 || getcap_status=$? + if [ "$getcap_status" -eq 127 ]; then + echo "⚠️ WARNING: getcap or rootlesskit 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, SHELLHUB_HTTPS_PORT and SHELLHUB_SSH_PORT to $unprivileged_start or above in .env.override." >&2 + exit 1 + fi + fi +fi + exec docker compose "$@" diff --git a/tests/compose/helpers.bash b/tests/compose/helpers.bash index 6b5f7f52aed..9720d21c9c9 100644 --- a/tests/compose/helpers.bash +++ b/tests/compose/helpers.bash @@ -8,7 +8,7 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" -unset EXTRA_COMPOSE_FILE DOCKER_HOST SHELLHUB_DEV_DOCKER_SOCKET +unset EXTRA_COMPOSE_FILE DOCKER_HOST SHELLHUB_DEV_DOCKER_SOCKET ROOTLESSKIT COMPOSE_FILE COMPOSE_ENV_FILES # Usage: capture_with VAR1=value VAR2=value ... capture_with() { @@ -25,7 +25,23 @@ case "$1" in [ -z "${STUB_DOCKER_INFO_FAILS:-}" ] || exit 1 echo "[${STUB_DOCKER_SECURITY:-name=seccomp,profile=builtin name=cgroupns}]" ;; - *) + compose) + if [ "$2" = config ]; then + [ -n "${COMPOSE_FILE:-}" ] || exit 1 + echo "services:" + echo " gateway:" + echo " ports:" + for port in ${STUB_PUBLISHED:-}; do + echo " - mode: ingress" + case "$port" in + *:*) echo " host_ip: ${port%:*}" ;; + esac + echo " target: 1" + echo " published: \"${port##*:}\"" + echo " protocol: tcp" + done + exit 0 + fi echo "COMPOSE_FILE=$COMPOSE_FILE" echo "COMPOSE_ENV_FILES=$COMPOSE_ENV_FILES" echo "COMPOSE_PROFILES=$COMPOSE_PROFILES" @@ -45,6 +61,7 @@ EOF : > "$stub_dir/rootlesskit" chmod +x "$stub_dir"/* fi + [ -z "${STUB_NO_ROOTLESSKIT:-}" ] || export ROOTLESSKIT= local tmp tmp=$(mktemp -p "$BATS_TEST_TMPDIR") diff --git a/tests/compose/wrapper.bats b/tests/compose/wrapper.bats index 902c7032037..745ee0820f0 100644 --- a/tests/compose/wrapper.bats +++ b/tests/compose/wrapper.bats @@ -129,71 +129,99 @@ load helpers } @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 + export STUB_DOCKER_SECURITY="name=rootless name=seccomp,profile=builtin" STUB_PUBLISHED="80 2222" + run capture_with SHELLHUB_ENV=development [ "$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 +@test "rootless guard: the lowest published port counts, not the first listed" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_PUBLISHED="8080 22" + run capture_with SHELLHUB_ENV=development [ "$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 +@test "rootless guard: a port bound to an address still counts" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_PUBLISHED="8080 127.0.0.1:22" + run capture_with SHELLHUB_ENV=development [ "$status" -ne 0 ] [[ "$output" == *"port 22"* ]] } +@test "rootless guard: the advice names every port variable" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_PUBLISHED="443" + run capture_with SHELLHUB_ENV=development + [ "$status" -ne 0 ] + [[ "$output" == *"SHELLHUB_HTTP_PORT, SHELLHUB_HTTPS_PORT and SHELLHUB_SSH_PORT"* ]] +} + @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"* ]] + export STUB_DOCKER_SECURITY="name=rootless" STUB_NO_GETCAP=1 STUB_PUBLISHED="80" + out=$(capture_with SHELLHUB_ENV=development) + [[ "$out" == *"getcap or rootlesskit 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) + export STUB_DOCKER_SECURITY="name=rootless" STUB_GETCAP="cap_net_bind_service=ep" STUB_PUBLISHED="80" + out=$(capture_with SHELLHUB_ENV=development) [[ "$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) + export STUB_DOCKER_SECURITY="name=rootless" STUB_SYSCTL_UNPRIVILEGED_PORT_START=0 STUB_PUBLISHED="80 22" + out=$(capture_with SHELLHUB_ENV=development) [[ "$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 + export STUB_DOCKER_SECURITY="name=rootless" STUB_SYSCTL_UNPRIVILEGED_PORT_START=2048 STUB_PUBLISHED="1500 2222" + run capture_with SHELLHUB_ENV=development [ "$status" -ne 0 ] [[ "$output" == *"port 1500"* ]] } +@test "rootless guard: a privileged port the stack does not publish is ignored" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_PUBLISHED="2210" + run capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=80 + [ "$status" -eq 0 ] + [[ "$output" != *"cannot publish"* ]] +} + +@test "rootless guard: a stack that publishes nothing passes" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_PUBLISHED="" + run capture_with SHELLHUB_ENV=development + [ "$status" -eq 0 ] +} + +@test "rootless guard: a rootlesskit off PATH warns instead of aborting" { + export STUB_DOCKER_SECURITY="name=rootless" STUB_NO_ROOTLESSKIT=1 STUB_PUBLISHED="80" + run capture_with SHELLHUB_ENV=development + [ "$status" -eq 0 ] + [[ "$output" == *"getcap or rootlesskit not found"* ]] +} + @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) + export STUB_DOCKER_SECURITY="name=rootless" STUB_PUBLISHED="8080 2222" + out=$(capture_with SHELLHUB_ENV=development) [[ "$out" == *"COMPOSE_FILE="* ]] } @test "rootless guard: a rootful daemon ignores privileged ports" { - out=$(capture_with SHELLHUB_ENV=development SHELLHUB_HTTP_PORT=80) + export STUB_PUBLISHED="80" + out=$(capture_with SHELLHUB_ENV=development) [[ "$out" == *"COMPOSE_FILE="* ]] } @test "rootless guard: only in development" { - export STUB_DOCKER_SECURITY="name=rootless" - out=$(capture_with SHELLHUB_HTTP_PORT=80) + export STUB_DOCKER_SECURITY="name=rootless" STUB_PUBLISHED="80" + out=$(capture_with) [[ "$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) + export STUB_DOCKER_INFO_FAILS=1 STUB_PUBLISHED="80" + out=$(capture_with SHELLHUB_ENV=development) [[ "$out" == *"COMPOSE_FILE="* ]] }