Skip to content
Draft
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
14 changes: 14 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,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
}
}
```
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.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.",
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
7 changes: 5 additions & 2 deletions 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,15 +506,17 @@ 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
if [ ! -f "\${SOCAT_PID}" ] || ! ps -p \$(cat \${SOCAT_PID}) > /dev/null; then
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
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
72 changes: 72 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,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