diff --git a/src/docker-outside-of-docker/NOTES.md b/src/docker-outside-of-docker/NOTES.md index 8cbd568b9..9d99da2ca 100644 --- a/src/docker-outside-of-docker/NOTES.md +++ b/src/docker-outside-of-docker/NOTES.md @@ -63,3 +63,17 @@ Debian Trixie (13) does not include moby-cli and related system packages, so the Ubuntu 26.04 LTS (Resolute) now has moby-cli packages available, so the feature supports installation with either `"moby": true` or `"moby": false`. `bash` is required to execute the `install.sh` script. + +## Socket access for non-root users + +By default, if the host Docker socket's group ID is not used by any group in the container, the Feature changes the GID of the container's `docker` group to match it when the container starts. Processes that were already running at that point (for example the container's own command when `overrideCommand` is `false`) keep their old supplementary groups and get `permission denied` on the socket. Processes started afterwards, such as terminals and `docker exec`, are not affected. + +Set `alwaysUseSocketProxy` to `true` to avoid this. The Feature then always forwards the host socket to `/var/run/docker.sock` with `socat`, and that socket is owned by the non-root user, so no group membership is needed. The Feature already falls back to this proxy when the socket is owned by `root` or its GID is already taken inside the container. The option has no effect when the remote user is `root`, because `root` can use the socket directly. + +```json +"features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "alwaysUseSocketProxy": true + } +} +``` diff --git a/src/docker-outside-of-docker/README.md b/src/docker-outside-of-docker/README.md index 3b37028e2..c62c52974 100644 --- a/src/docker-outside-of-docker/README.md +++ b/src/docker-outside-of-docker/README.md @@ -24,6 +24,7 @@ Re-use the host docker socket, adding the Docker CLI to a container. Feature inv | installDockerBuildx | Install Docker Buildx | boolean | true | | installDockerComposeSwitch | Install Compose Switch (provided docker compose is available) which is a replacement to the Compose V1 docker-compose (python) executable. It translates the command line into Compose V2 docker compose then runs the latter. | boolean | true | | socketPath | Path where the Docker socket is mounted inside the container. For rootless Docker, override the mount in devcontainer.json to map your host socket to this path. | string | /var/run/docker-host.sock | +| alwaysUseSocketProxy | Always proxy the host Docker socket (using socat) instead of changing the GID of the container's docker group to match it. Processes that start before that change, such as the container's own command, keep their old group list and cannot use the socket without the proxy. | boolean | false | ## Customizations diff --git a/src/docker-outside-of-docker/devcontainer-feature.json b/src/docker-outside-of-docker/devcontainer-feature.json index 9ab361cd6..ed3374f87 100644 --- a/src/docker-outside-of-docker/devcontainer-feature.json +++ b/src/docker-outside-of-docker/devcontainer-feature.json @@ -1,7 +1,7 @@ { "id": "docker-outside-of-docker", - "version": "1.10.1", + "version": "1.11.1", "name": "Docker (docker-outside-of-docker)", "documentationURL": "https://github.com/devcontainers/features/tree/main/src/docker-outside-of-docker", "description": "Re-use the host docker socket, adding the Docker CLI to a container. Feature invokes a script to enable using a forwarded Docker socket within a container to run Docker commands.", @@ -51,6 +51,11 @@ "type": "string", "default": "/var/run/docker-host.sock", "description": "Path where the Docker socket is mounted inside the container. For rootless Docker, override the mount in devcontainer.json to map your host socket to this path." + }, + "alwaysUseSocketProxy": { + "type": "boolean", + "default": false, + "description": "Always proxy the host Docker socket (using socat) instead of changing the GID of the container's docker group to match it. Processes that start before that change, such as the container's own command, keep their old group list and cannot use the socket without the proxy." } }, "entrypoint": "/usr/local/share/docker-init.sh", diff --git a/src/docker-outside-of-docker/install.sh b/src/docker-outside-of-docker/install.sh index 2a1515565..1ea81aa18 100755 --- a/src/docker-outside-of-docker/install.sh +++ b/src/docker-outside-of-docker/install.sh @@ -14,6 +14,7 @@ DOCKER_DASH_COMPOSE_VERSION="${DOCKERDASHCOMPOSEVERSION:-"latest"}" # v1 or v2 o ENABLE_NONROOT_DOCKER="${ENABLE_NONROOT_DOCKER:-"true"}" SOCKET_PATH="${SOCKETPATH:-"/var/run/docker-host.sock"}" # From feature option +ALWAYS_USE_SOCKET_PROXY="${ALWAYSUSESOCKETPROXY:-"false"}" SOURCE_SOCKET="${SOURCE_SOCKET:-"${SOCKET_PATH}"}" TARGET_SOCKET="${TARGET_SOCKET:-"/var/run/docker.sock"}" USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}" @@ -505,7 +506,7 @@ log "Ensuring ${USERNAME} has access to ${SOURCE_SOCKET} via ${TARGET_SOCKET}" # that we can set permissions on it without affecting the host. if [ "${ENABLE_NONROOT_DOCKER}" = "true" ] && [ "${SOURCE_SOCKET}" != "${TARGET_SOCKET}" ] && [ "${USERNAME}" != "root" ] && [ "${USERNAME}" != "0" ]; then SOCKET_GID=\$(stat -c '%g' ${SOURCE_SOCKET}) - if [ "\${SOCKET_GID}" != "0" ] && [ "\${SOCKET_GID}" != "${DOCKER_GID}" ] && ! grep -E ".+:x:\${SOCKET_GID}" /etc/group; then + if [ "${ALWAYS_USE_SOCKET_PROXY}" != "true" ] && [ "\${SOCKET_GID}" != "0" ] && [ "\${SOCKET_GID}" != "${DOCKER_GID}" ] && ! grep -E ".+:x:\${SOCKET_GID}" /etc/group; then sudoIf groupmod --gid "\${SOCKET_GID}" docker else # Enable proxy if not already running @@ -513,7 +514,9 @@ if [ "${ENABLE_NONROOT_DOCKER}" = "true" ] && [ "${SOURCE_SOCKET}" != "${TARGET_ log "Enabling socket proxy." log "Proxying ${SOURCE_SOCKET} to ${TARGET_SOCKET} for vscode" sudoIf rm -rf ${TARGET_SOCKET} - (sudoIf socat UNIX-LISTEN:${TARGET_SOCKET},fork,mode=660,user=${USERNAME},backlog=128 UNIX-CONNECT:${SOURCE_SOCKET} 2>&1 | sudoIf tee -a \${SOCAT_LOG} > /dev/null & echo "\$!" | sudoIf tee \${SOCAT_PID} > /dev/null) + # The Docker CLI closes its write side right after sending a request. -t is how long socat then + # waits before dropping the connection, so the 0.5s default cuts off output that arrives later. + (sudoIf socat -t 31536000 UNIX-LISTEN:${TARGET_SOCKET},fork,mode=660,user=${USERNAME},backlog=128 UNIX-CONNECT:${SOURCE_SOCKET} 2>&1 | sudoIf tee -a \${SOCAT_LOG} > /dev/null & echo "\$!" | sudoIf tee \${SOCAT_PID} > /dev/null) else log "Socket proxy already running." fi diff --git a/test/docker-outside-of-docker/scenarios.json b/test/docker-outside-of-docker/scenarios.json index 166cb1b03..a3942c6de 100644 --- a/test/docker-outside-of-docker/scenarios.json +++ b/test/docker-outside-of-docker/scenarios.json @@ -61,6 +61,16 @@ }, "containerUser": "vscode" }, + "socket_proxy_always": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "docker-outside-of-docker": { + "moby": false, + "alwaysUseSocketProxy": true + } + }, + "containerUser": "vscode" + }, "docker_build": { "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", "features": { diff --git a/test/docker-outside-of-docker/socket_proxy_always.sh b/test/docker-outside-of-docker/socket_proxy_always.sh new file mode 100755 index 000000000..738b4de6b --- /dev/null +++ b/test/docker-outside-of-docker/socket_proxy_always.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +set -e + +# Optional: Import test library +source dev-container-features-test-lib + +# This script runs in a fresh process, which always gets up-to-date supplementary groups, so it +# cannot show whether the container's own command (started before the entrypoint finished) can use +# the socket. Take that command's groups instead and run docker with exactly those. +INIT_PID="$(pgrep -u "$(id -u)" -f '[C]ontainer started' | head -n 1)" +INIT_GROUPS="$(awk '/^Groups:/ {$1=""; print}' "/proc/${INIT_PID}/status" | xargs | tr ' ' ',')" +DOCKER_BIN="$(command -v docker)" + +echo "container command pid: ${INIT_PID}, its groups: ${INIT_GROUPS}" + +check "container-command-found" bash -c "[ -n '${INIT_PID}' ]" +check "docker-socket-is-proxy" bash -c "[ -S /var/run/docker.sock ] && [ ! -L /var/run/docker.sock ]" +check "socat-proxy-running" bash -c "pgrep -af 'socat .*UNIX-LISTEN:/var/run/docker.sock.*UNIX-CONNECT:/var/run/docker-host.sock'" +check "log-contents-for-proxy" bash -c "grep 'Enabling socket proxy' /tmp/vscr-docker-from-docker.log" +check "docker-ps" bash -c "docker ps >/dev/null" +# Output that arrives well after the client stops sending must still reach the client (#483) +IMAGE="$(docker inspect "$(hostname)" --format '{{.Config.Image}}')" +check "delayed-output-through-proxy" bash -c "[ \"\$(docker run --rm --entrypoint sh '${IMAGE}' -c 'sleep 2; echo late')\" = late ]" +check "delayed-output-with-stdin-through-proxy" bash -c "[ \"\$(echo 'sleep 2; echo late' | docker run -i --rm --entrypoint sh '${IMAGE}')\" = late ]" +check "docker-ps-with-container-command-groups" bash -c "sudo setpriv --reuid '$(id -u)' --regid '$(id -g)' --groups '${INIT_GROUPS}' env HOME=/tmp '${DOCKER_BIN}' ps >/dev/null" + +# Deterministic check of the proxy selection. Whether the host socket's GID would pick `groupmod` depends +# on the host (GID 0 or a GID already in the image picks the proxy anyway), so build a fixture instead: +# a socket owned by an unused, nonzero GID that forwards to the real socket. The host socket is untouched. +FIXTURE_GID=4242 +FIXTURE_DIR=/tmp/socket-proxy-fixture +DOCKER_GID_BEFORE="$(getent group docker | cut -d: -f3)" + +check "fixture-gid-unused" bash -c "! getent group ${FIXTURE_GID}" + +sudo mkdir -p "${FIXTURE_DIR}" +sudo chmod 777 "${FIXTURE_DIR}" +sudo socat "UNIX-LISTEN:${FIXTURE_DIR}/host.sock,fork,mode=660" UNIX-CONNECT:/var/run/docker-host.sock >/dev/null 2>&1 & +for _ in $(seq 1 50); do [ -S "${FIXTURE_DIR}/host.sock" ] && break; sleep 0.1; done +check "fixture-socket-created" bash -c "[ -S '${FIXTURE_DIR}/host.sock' ]" +sudo chown "root:${FIXTURE_GID}" "${FIXTURE_DIR}/host.sock" + +# Run a copy of the generated docker-init.sh against the fixture. $1 = name, $2 = value of the +# option's baked-in condition ("true" as generated, "false" to emulate the default). +run_init_copy() { + local dir="${FIXTURE_DIR}/$1" + mkdir -p "${dir}" + sed -e "s#/tmp/vscr-docker-from-docker#${dir}/vscr#g" \ + -e "s#/var/run/docker-host.sock#${FIXTURE_DIR}/host.sock#g" \ + -e "s#/var/run/docker.sock#${dir}/docker.sock#g" \ + -e "s#\[ \"true\" != \"true\" \]#[ \"$2\" != \"true\" ]#" \ + /usr/local/share/docker-init.sh > "${dir}/init.sh" + chmod +x "${dir}/init.sh" + "${dir}/init.sh" true +} + +run_init_copy option-on true +check "option-on-selects-proxy" bash -c "grep 'Enabling socket proxy' '${FIXTURE_DIR}/option-on/vscr.log'" +check "option-on-docker-gid-unchanged" bash -c "[ \"\$(getent group docker | cut -d: -f3)\" = '${DOCKER_GID_BEFORE}' ]" +check "option-on-socket-owned-by-user" bash -c "[ \"\$(stat -c %U '${FIXTURE_DIR}/option-on/docker.sock')\" = \"\$(id -un)\" ]" +check "option-on-docker-ps-via-fixture-proxy" bash -c "docker -H 'unix://${FIXTURE_DIR}/option-on/docker.sock' ps >/dev/null" + +# Control, run last because it changes the docker group: with the condition off, this fixture takes +# the groupmod path. If it did not, the checks above would not prove anything. +check "control-condition-present-in-generated-script" bash -c "grep -qF '[ \"true\" != \"true\" ]' /usr/local/share/docker-init.sh" +run_init_copy option-off false +check "control-selects-groupmod" bash -c "[ \"\$(getent group docker | cut -d: -f3)\" = '${FIXTURE_GID}' ]" +check "control-does-not-start-proxy" bash -c "! grep 'Enabling socket proxy' '${FIXTURE_DIR}/option-off/vscr.log'" + +# Report result +reportResults