From 7134bf49fb00cca1d398efa67eded9b6d09fc358 Mon Sep 17 00:00:00 2001 From: Alex Remily Date: Fri, 30 Oct 2026 01:01:56 +0000 Subject: [PATCH 1/3] [docker-outside-of-docker] Add alwaysUseSocketProxy option When the host Docker socket's GID is nonzero and unused in the container, the entrypoint changes the GID of the container's docker group with groupmod. That does not update the supplementary groups of processes that are already running, so they keep getting EACCES on the socket. With the dev container CLI this includes the wrapper shell that runs the entrypoint and then execs the image command (overrideCommand: false). Processes started later (docker exec, terminals) get fresh groups and are not affected, which is why tests that run in a new process do not catch it. Add an opt-in alwaysUseSocketProxy option (default false) that skips the groupmod branch and always uses the existing socat proxy, whose target socket is owned by the user and needs no group membership. Behavior is unchanged when the option is off. The proxy has a known limitation (#483), so it stays opt-in. Add the socket_proxy_always scenario. It inspects the container's own command (not a fresh process) and runs docker with that process's group list. The same check fails with the default configuration. --- src/docker-outside-of-docker/NOTES.md | 16 ++++++++++++ src/docker-outside-of-docker/README.md | 1 + .../devcontainer-feature.json | 7 +++++- src/docker-outside-of-docker/install.sh | 3 ++- test/docker-outside-of-docker/scenarios.json | 10 ++++++++ .../socket_proxy_always.sh | 25 +++++++++++++++++++ 6 files changed, 60 insertions(+), 2 deletions(-) create mode 100755 test/docker-outside-of-docker/socket_proxy_always.sh diff --git a/src/docker-outside-of-docker/NOTES.md b/src/docker-outside-of-docker/NOTES.md index 8cbd568b9..0ad262efe 100644 --- a/src/docker-outside-of-docker/NOTES.md +++ b/src/docker-outside-of-docker/NOTES.md @@ -63,3 +63,19 @@ 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. + +```json +"features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "alwaysUseSocketProxy": true + } +} +``` + +> **Note:** `socat` can drop output from `docker run --interactive` when input arrives with pauses in it ([#483](https://github.com/devcontainers/features/issues/483)). This is why the proxy is opt-in. 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..00b2b6fc5 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.0", "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..52a522c30 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 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..245c9a8cd --- /dev/null +++ b/test/docker-outside-of-docker/socket_proxy_always.sh @@ -0,0 +1,25 @@ +#!/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" +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" + +# Report result +reportResults From cc680716384ca810af8a71be13cd551e658d68b4 Mon Sep 17 00:00:00 2001 From: Alex Remily Date: Wed, 4 Nov 2026 00:39:42 +0000 Subject: [PATCH 2/3] [docker-outside-of-docker] Test proxy selection with an unused socket GID The scenario could pass without the option because a host socket with GID 0 or a GID already in the image selects the proxy anyway. Build a fixture socket owned by an unused GID, run a path-rewritten copy of the generated docker-init.sh against it, and assert that the option keeps the docker group's GID and starts the proxy. A control run with the condition flipped must take the groupmod path. Also describe delayed output after the client closes its write side in NOTES.md. --- src/docker-outside-of-docker/NOTES.md | 4 +- .../socket_proxy_always.sh | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/docker-outside-of-docker/NOTES.md b/src/docker-outside-of-docker/NOTES.md index 0ad262efe..04dc9d5d8 100644 --- a/src/docker-outside-of-docker/NOTES.md +++ b/src/docker-outside-of-docker/NOTES.md @@ -68,7 +68,7 @@ Ubuntu 26.04 LTS (Resolute) now has moby-cli packages available, so the feature 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. +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": { @@ -78,4 +78,4 @@ Set `alwaysUseSocketProxy` to `true` to avoid this. The Feature then always forw } ``` -> **Note:** `socat` can drop output from `docker run --interactive` when input arrives with pauses in it ([#483](https://github.com/devcontainers/features/issues/483)). This is why the proxy is opt-in. +> **Note:** The proxy can drop delayed output ([#483](https://github.com/devcontainers/features/issues/483)). The Docker CLI closes its write side right after sending a request, and `socat` then gives the daemon only 0.5 seconds to send anything more before it closes the connection. Output that arrives later is lost. This affects `docker run` and `docker exec` with or without `--interactive`, for example `docker run --rm ubuntu sh -c 'sleep 5; date'` prints nothing. The same applies when the Feature falls back to the proxy on its own, so consider it before enabling the option. diff --git a/test/docker-outside-of-docker/socket_proxy_always.sh b/test/docker-outside-of-docker/socket_proxy_always.sh index 245c9a8cd..d832141ff 100755 --- a/test/docker-outside-of-docker/socket_proxy_always.sh +++ b/test/docker-outside-of-docker/socket_proxy_always.sh @@ -21,5 +21,48 @@ check "log-contents-for-proxy" bash -c "grep 'Enabling socket proxy' /tmp/vscr-d check "docker-ps" bash -c "docker ps >/dev/null" 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 From 677c2e5da4d40f425ff2dbe2fe4620c77180828c Mon Sep 17 00:00:00 2001 From: Alex Remily Date: Sun, 1 Nov 2026 01:12:39 +0000 Subject: [PATCH 3/3] [docker-outside-of-docker] Keep socat proxy connections open until the daemon closes them The Docker CLI closes its write side of a connection right after it sends the request, even without --interactive. socat then shuts down the other direction and waits -t seconds (default 0.5) before ending the connection, so any output the daemon sends later is dropped. Through the proxy: - echo "sleep 2; uname" | docker run -i ... prints nothing (#483) - docker run ... sh -c 'sleep 2; echo late' prints nothing - docker exec ... printing a line per second returns after ~550ms with only the first line Pass a very large -t. It only applies after one side has closed, and socat ends the connection as soon as the other side closes as well, so a large value does not delay normal completion. Any finite value would still cut off long-running commands (a 5 second -t lost the output of a command that slept for 5 seconds). End of input is still forwarded (echo hi | docker run -i cat returns), 50 MB through the proxy arrives intact, and 100 parallel API clients succeed, as with the default. One cost: if a client is killed mid-stream while its container stays silent, the proxy's child process for that connection stays until the daemon next writes or closes it, instead of being dropped after 0.5s. This affects every use of the proxy: the existing fallback (for example a root-owned socket) as well as alwaysUseSocketProxy. Add checks to the socket_proxy_always scenario that run docker through the proxy with delayed output, with and without stdin. They fail without this change. Remove the Builds on the alwaysUseSocketProxy option, whose scenario is the way to exercise the proxy deterministically. Fixes #483 --- src/docker-outside-of-docker/NOTES.md | 2 -- src/docker-outside-of-docker/devcontainer-feature.json | 2 +- src/docker-outside-of-docker/install.sh | 4 +++- test/docker-outside-of-docker/socket_proxy_always.sh | 6 +++++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/docker-outside-of-docker/NOTES.md b/src/docker-outside-of-docker/NOTES.md index 04dc9d5d8..9d99da2ca 100644 --- a/src/docker-outside-of-docker/NOTES.md +++ b/src/docker-outside-of-docker/NOTES.md @@ -77,5 +77,3 @@ Set `alwaysUseSocketProxy` to `true` to avoid this. The Feature then always forw } } ``` - -> **Note:** The proxy can drop delayed output ([#483](https://github.com/devcontainers/features/issues/483)). The Docker CLI closes its write side right after sending a request, and `socat` then gives the daemon only 0.5 seconds to send anything more before it closes the connection. Output that arrives later is lost. This affects `docker run` and `docker exec` with or without `--interactive`, for example `docker run --rm ubuntu sh -c 'sleep 5; date'` prints nothing. The same applies when the Feature falls back to the proxy on its own, so consider it before enabling the option. diff --git a/src/docker-outside-of-docker/devcontainer-feature.json b/src/docker-outside-of-docker/devcontainer-feature.json index 00b2b6fc5..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.11.0", + "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.", diff --git a/src/docker-outside-of-docker/install.sh b/src/docker-outside-of-docker/install.sh index 52a522c30..1ea81aa18 100755 --- a/src/docker-outside-of-docker/install.sh +++ b/src/docker-outside-of-docker/install.sh @@ -514,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/socket_proxy_always.sh b/test/docker-outside-of-docker/socket_proxy_always.sh index d832141ff..738b4de6b 100755 --- a/test/docker-outside-of-docker/socket_proxy_always.sh +++ b/test/docker-outside-of-docker/socket_proxy_always.sh @@ -16,9 +16,13 @@ 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 "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