Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 23 additions & 19 deletions bin/docker-compose
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 "$@"
21 changes: 19 additions & 2 deletions tests/compose/helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
Comment thread
otavio marked this conversation as resolved.
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"
Expand All @@ -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")
Expand Down
76 changes: 52 additions & 24 deletions tests/compose/wrapper.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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="* ]]
}
Loading