Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/docker-outside-of-docker/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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
}
}
```

> **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.
1 change: 1 addition & 0 deletions src/docker-outside-of-docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion src/docker-outside-of-docker/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/docker-outside-of-docker/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"}"}"
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, thanks. Fixed in cc68071.

socket_proxy_always now builds a fixture instead of depending on the host socket's GID: a socket owned by
an unused, nonzero GID (4242) that forwards to the real socket. The host socket is not touched. The script
runs a path-rewritten copy of the generated docker-init.sh against it. A fixture-socket-created check
makes the test fail with a clear message if the fixture socket does not appear.

  • Option on: the proxy starts ("Enabling socket proxy" in the log), the docker group's GID is unchanged,
    the proxy socket is owned by the user, and docker ps works through it.
  • Control, condition flipped to false: the same fixture takes the groupmod path (the group's GID becomes
    4242) and the proxy does not start. This shows the fixture really would select groupmod.

I also checked that the scenario fails when the option's condition is removed from install.sh, including
with the older checks disabled, so the new checks catch it on their own.

One caveat: the test matches the literal [ "true" != "true" ] in the generated script to flip the
condition. If that line changes (for example #1749 rewrites the if), the
control-condition-present-in-generated-script check fails instead of passing silently, and the test needs
updating.

sudoIf groupmod --gid "\${SOCKET_GID}" docker
else
# Enable proxy if not already running
Expand Down
10 changes: 10 additions & 0 deletions test/docker-outside-of-docker/scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
68 changes: 68 additions & 0 deletions test/docker-outside-of-docker/socket_proxy_always.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/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"

# 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