From 2d3a61b2831c1ab8771fe78fff3fa0086a6027e0 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Fri, 2 Oct 2026 20:09:52 -0300 Subject: [PATCH] fix: check the ports the stack publishes in the rootless guard The guard took the lowest of SHELLHUB_HTTP_PORT, default 80, and SHELLHUB_SSH_PORT, whether the stack publishes them or not. A worktree instance resets the gateway's ports in compose.instance.yml, since Traefik serves it, so it publishes only SSH on 2200+N; under rootless every wrapper command there still aborted with "cannot publish port 80", including runs that publish nothing at all. The guard now reads the lowest published port from docker compose config, with the files, profiles and overrides the wrapper selected, and passes when nothing below the unprivileged start is published. That needs COMPOSE_FILE, so the check moves below its export; the socket stays where it was. The rendered config also carries the auto-SSL overlay's HTTPS port, so the advice names SHELLHUB_HTTPS_PORT next to the other two. On NixOS rootlesskit is not on PATH, it sits inside the docker package, so getcap "" failed, the guard read the failure as a missing capability and printed a setcap command for an empty path. A missing rootlesskit now warns that the guard cannot tell, as a missing getcap already did. The lookup goes through ROOTLESSKIT when it is set, an empty value meaning missing, so a test can hide the binary without hiding the directory around it. The test stub's compose config now prints the ports each case names in STUB_PUBLISHED, shaped as Compose writes them, a bind address on its own host_ip line, instead of rebuilding them from the env files the way the old guard did. The rootlesskit-off-PATH case sets ROOTLESSKIT empty, so a rootless developer's own /usr/bin/rootlesskit cannot satisfy the lookup, and PATH keeps coreutils. The stub's config also fails until the wrapper has exported COMPOSE_FILE, so moving the guard back above that export turns the guard cases red. --- bin/docker-compose | 42 +++++++++++---------- tests/compose/helpers.bash | 21 ++++++++++- tests/compose/wrapper.bats | 76 ++++++++++++++++++++++++++------------ 3 files changed, 94 insertions(+), 45 deletions(-) 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="* ]] }