From 5cd363067ab92245e7e48d739dc37a48c4153f9d Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:44:14 +0000 Subject: [PATCH 1/6] feat(docker-in-docker)!: run the daemon rootless without privileged The feature declared privileged, which the CLI applies to the whole dev container, so anyone in the docker group could start a privileged nested container and mount the host disk. Run dockerd rootless instead and drop privileged. This needs file-capability newuidmap instead of setuid, /dev/net/tun created at start, and --pidns so RootlessKit mounts a writable procfs. daemonJson now also lands in the remote user's config directory, which is where the rootless daemon reads it. BREAKING CHANGE: DOCKER_HOST moves to the remote user's socket and a non-root remoteUser is now required, so this ships as 2.0.0. Consumers pinned to :1 keep the rootful feature and must opt in via :2. --- .../devcontainer-feature.json | 23 ++--- src/docker-in-docker/docker-init.sh | 96 ++++++++++++++----- src/docker-in-docker/install.sh | 30 ++++-- test/docker-in-docker/daemon_json.sh | 3 +- test/docker-in-docker/scenarios.json | 2 + test/docker-in-docker/test.sh | 14 ++- 6 files changed, 120 insertions(+), 48 deletions(-) diff --git a/src/docker-in-docker/devcontainer-feature.json b/src/docker-in-docker/devcontainer-feature.json index 35ccaf0..ed42f03 100644 --- a/src/docker-in-docker/devcontainer-feature.json +++ b/src/docker-in-docker/devcontainer-feature.json @@ -1,8 +1,8 @@ { "id": "docker-in-docker", - "version": "1.2.0", + "version": "2.0.0", "name": "Docker in Docker", - "description": "Installs a Docker engine for building and running containers inside the dev container.", + "description": "Installs a rootless Docker engine for building and running containers inside the dev container.", "documentationURL": "https://github.com/hansehart/devcontainer-features/tree/main/src/docker-in-docker", "options": { "version": { @@ -18,9 +18,10 @@ } }, "entrypoint": "/usr/local/share/docker-in-docker/docker-init.sh", - "privileged": true, + "privileged": false, "containerEnv": { - "DOCKER_BUILDKIT": "1" + "DOCKER_BUILDKIT": "1", + "DOCKER_HOST": "unix:///run/user/1000/docker.sock" }, "customizations": { "vscode": { @@ -28,17 +29,5 @@ "ms-azuretools.vscode-containers" ] } - }, - "mounts": [ - { - "source": "dind-var-lib-docker", - "target": "/var/lib/docker", - "type": "volume" - }, - { - "source": "dind-var-lib-containerd", - "target": "/var/lib/containerd", - "type": "volume" - } - ] + } } diff --git a/src/docker-in-docker/docker-init.sh b/src/docker-in-docker/docker-init.sh index 052ae26..66c0f13 100644 --- a/src/docker-in-docker/docker-init.sh +++ b/src/docker-in-docker/docker-init.sh @@ -1,6 +1,21 @@ #!/bin/sh set -e +# The unprivileged user that owns the daemon (persisted by install.sh). +RUSER="$(cat /usr/local/share/docker-in-docker/rootless-user 2>/dev/null || echo root)" +RUID="$(id -u "$RUSER")" +RHOME="$(getent passwd "$RUSER" | cut -d: -f6)" +RUNTIME_DIR="/run/user/${RUID}" + +# The daemon runs as the remote user, and cannot start as root. +if [ "$RUSER" = "root" ]; then + echo "docker-in-docker: needs a non-root remoteUser, the daemon stays down" >&2 + exec "$@" +fi + +# Trust a mounted CA, so registries behind a TLS proxy resolve. +update-ca-certificates >/dev/null 2>&1 || true + # Match the iptables backend to the host kernel we share. if type iptables-legacy > /dev/null 2>&1 \ && { grep -qE '^ip_tables\b' /proc/modules || [ -d /sys/module/ip_tables ]; } \ @@ -23,44 +38,79 @@ if [ -d /sys/kernel/security ] && ! mountpoint -q /sys/kernel/security; then fi mountpoint -q /tmp || mount -t tmpfs none /tmp || true -# Delegate cgroup v2 controllers to a leaf so nested cgroups work. -if [ -f /sys/fs/cgroup/cgroup.controllers ]; then - mkdir -p /sys/fs/cgroup/init - # Retry the process move, which races with EBUSY on a cold boot. +# Nest the cgroup controllers where the host delegates them, and pass over where it does not. +if [ -f /sys/fs/cgroup/cgroup.controllers ] && mkdir -p /sys/fs/cgroup/init 2>/dev/null; then + # Move the existing processes into a leaf, retrying while they settle. cg_tries=0 until xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null \ || [ "$cg_tries" -ge 5 ]; do sleep 1 cg_tries=$((cg_tries + 1)) done + # Hand the controllers down to that leaf. sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \ > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true fi -# Start the daemon and wait for it to accept commands. -dockerd_pid="" +# Provide the device the userspace network stack builds its tap on. +if [ ! -c /dev/net/tun ]; then + mkdir -p /dev/net + mknod /dev/net/tun c 10 200 2>/dev/null || true + # Readable by the remote user, which runs the network stack. + chmod 0666 /dev/net/tun 2>/dev/null || true +fi + +# Prepare the runtime and data directories owned by the remote user. +mkdir -p "$RUNTIME_DIR" "${RHOME}/.local/share/docker" +chown "$RUSER":"$RUSER" "$RUNTIME_DIR" "${RHOME}/.local/share/docker" 2>/dev/null || true + +# Place the daemon settings where the daemon reads them, once the home mounts are in place. +if [ -f /usr/local/share/docker-in-docker/daemon.json ]; then + mkdir -p "${RHOME}/.config/docker" + cp /usr/local/share/docker-in-docker/daemon.json "${RHOME}/.config/docker/daemon.json" + chown -R "$RUSER":"$RUSER" "${RHOME}/.config/docker" 2>/dev/null || true +fi + +# Re-privilege the id-mapping helpers, since the image unpack drops their capabilities. +for b in /usr/bin/newuidmap /usr/bin/newgidmap; do + # Keep the caller's identity, which is what the kernel grants the mapping to. + chmod u-s "$b" 2>/dev/null || true + # Carry the mapping rights as file capabilities instead. + setcap cap_setuid,cap_setgid+ep "$b" 2>/dev/null || true +done + +# Point interactive shells at the socket the daemon listens on. +printf 'export DOCKER_HOST=unix://%s/docker.sock\n' "$RUNTIME_DIR" \ + > /etc/profile.d/99-rootless-docker.sh + start_dockerd() { - # Clear stale pid files left by an unclean stop. + # Clear pid files left by an unclean stop, which otherwise block the next start. find /run /var/run -iname 'docker*.pid' -delete 2>/dev/null || true find /run /var/run -iname 'container*.pid' -delete 2>/dev/null || true - dockerd > /tmp/dockerd.log 2>&1 & - dockerd_pid=$! - tries=0 - until docker info > /dev/null 2>&1 || [ "$tries" -ge 30 ]; do - sleep 1 - tries=$((tries + 1)) - done - docker info > /dev/null 2>&1 + + # Run as the remote user, with userspace networking and a private pid namespace. + runuser -u "$RUSER" -- env \ + HOME="$RHOME" \ + XDG_RUNTIME_DIR="$RUNTIME_DIR" \ + DOCKER_HOST="unix://${RUNTIME_DIR}/docker.sock" \ + PATH="/usr/bin:/usr/local/bin:/sbin:/usr/sbin:/bin" \ + DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns \ + DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 \ + DOCKERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS=false \ + DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS="--pidns" \ + DOCKERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SANDBOX=auto \ + DOCKERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SECCOMP=auto \ + dockerd-rootless.sh --storage-driver=overlay2 > /tmp/dockerd.log 2>&1 } -# Retry once when a stale lock in the persisted data volume kills the first start. -if ! start_dockerd; then - echo "docker-in-docker: dockerd did not come up, restarting once" >&2 - cat /tmp/dockerd.log >&2 || true - kill "$dockerd_pid" 2>/dev/null || true - sleep 1 - start_dockerd || { echo "docker-in-docker: dockerd failed to start" >&2; cat /tmp/dockerd.log >&2 || true; } -fi +# Supervise in the background, so the container command starts without waiting. +{ + if ! start_dockerd; then + echo "docker-in-docker: rootless dockerd exited, retrying once" >&2 + sleep 2 + start_dockerd || echo "docker-in-docker: rootless dockerd failed to start, see /tmp/dockerd.log" >&2 + fi +} & # Hand off to the container's original entrypoint/command. exec "$@" diff --git a/src/docker-in-docker/install.sh b/src/docker-in-docker/install.sh index 36670f7..fbf9a38 100644 --- a/src/docker-in-docker/install.sh +++ b/src/docker-in-docker/install.sh @@ -36,26 +36,44 @@ fi # shellcheck disable=SC2086 apt-get install -y --no-install-recommends $ce containerd.io docker-buildx-plugin docker-compose-plugin +# Rootless: the helpers and userspace networking an unprivileged daemon needs. +apt-get install -y --no-install-recommends \ + docker-ce-rootless-extras \ + fuse-overlayfs \ + libcap2-bin \ + slirp4netns \ + uidmap + # Pin the engine so a later apt upgrade keeps it in sync with the persisted data volume. apt-mark hold docker-ce docker-ce-cli containerd.io rm -rf /var/lib/apt/lists/* -# Configure: add the non-root remote user to the docker group so it can run docker directly. +# Configure: add the non-root remote user to the docker group so the client resolves the daemon. groupadd -f docker if [ -n "$_REMOTE_USER" ] && [ "$_REMOTE_USER" != "root" ]; then usermod -aG docker "$_REMOTE_USER" || true -fi -# Configure: write the requested daemon settings to daemon.json (empty leaves the file untouched). -if [ -n "$DAEMON_JSON" ]; then - mkdir -p /etc/docker - printf '%s\n' "$DAEMON_JSON" > /etc/docker/daemon.json + # Grant the subordinate ids the daemon maps its containers into. + if ! grep -q "^${_REMOTE_USER}:" /etc/subuid; then + echo "${_REMOTE_USER}:100000:65536" >> /etc/subuid + fi + if ! grep -q "^${_REMOTE_USER}:" /etc/subgid; then + echo "${_REMOTE_USER}:100000:65536" >> /etc/subgid + fi fi # Install: the entrypoint that starts dockerd at container start, then execs the container command. install -d /usr/local/share/docker-in-docker install -m 0755 "$(dirname "$0")/docker-init.sh" /usr/local/share/docker-in-docker/docker-init.sh +# Persist which user runs the rootless daemon (the entrypoint has no _REMOTE_USER at runtime). +printf '%s\n' "${_REMOTE_USER:-root}" > /usr/local/share/docker-in-docker/rootless-user + +# Configure: keep the requested daemon settings for the entrypoint to place (empty writes nothing). +if [ -n "$DAEMON_JSON" ]; then + printf '%s\n' "$DAEMON_JSON" > /usr/local/share/docker-in-docker/daemon.json +fi + # Verify: the CLI resolves on PATH. docker --version diff --git a/test/docker-in-docker/daemon_json.sh b/test/docker-in-docker/daemon_json.sh index f2db7f3..3636756 100644 --- a/test/docker-in-docker/daemon_json.sh +++ b/test/docker-in-docker/daemon_json.sh @@ -4,7 +4,8 @@ set -e # Import the test library source dev-container-features-test-lib -check "daemon.json no-new-privileges written" grep -qF '"no-new-privileges":true' /etc/docker/daemon.json +# The rootless daemon reads its settings from the remote user's config directory. +check "daemon.json no-new-privileges written" grep -qF '"no-new-privileges":true' "$HOME/.config/docker/daemon.json" # Report result reportResults diff --git a/test/docker-in-docker/scenarios.json b/test/docker-in-docker/scenarios.json index ac9d90b..768efe7 100644 --- a/test/docker-in-docker/scenarios.json +++ b/test/docker-in-docker/scenarios.json @@ -1,6 +1,7 @@ { "daemon_json": { "image": "ubuntu:24.04", + "remoteUser": "ubuntu", "features": { "docker-in-docker": { "daemonJson": "{\\\"no-new-privileges\\\":true}" @@ -9,6 +10,7 @@ }, "pinned_version": { "image": "ubuntu:24.04", + "remoteUser": "ubuntu", "features": { "docker-in-docker": { "version": "28" diff --git a/test/docker-in-docker/test.sh b/test/docker-in-docker/test.sh index b74adfa..56a3409 100644 --- a/test/docker-in-docker/test.sh +++ b/test/docker-in-docker/test.sh @@ -4,11 +4,23 @@ set -e # Import the test library source dev-container-features-test-lib +# The entrypoint starts the daemon in the background, so give it time to accept commands. +wait_for_daemon() { + for _ in $(seq 1 30); do + docker info > /dev/null 2>&1 && return 0 + sleep 1 + done + return 1 +} + check "docker on PATH" bash -c "command -v docker" check "docker version" docker --version check "compose plugin" docker compose version check "buildx plugin" docker buildx version -check "daemon reachable" docker info +check "daemon reachable" bash -c "$(declare -f wait_for_daemon); wait_for_daemon" +check "daemon runs rootless" bash -c "docker info --format '{{.SecurityOptions}}' | grep -q rootless" +check "overlay2 storage driver" bash -c "docker info --format '{{.Driver}}' | grep -qx overlay2" +check "socket is the remote user's" bash -c 'test -S "${DOCKER_HOST#unix://}"' check "nested container runs" docker run --rm hello-world # Report result From bf279fdc2eb16644a399e1abe725b27bb8bdc475 Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:19:31 +0000 Subject: [PATCH 2/6] fix(docker-in-docker): pin the rootless extras to the engine version The extras depend on the exact docker-ce version, so installing them unpinned in a second apt call pulled the engine up to the latest release and broke the version option. --- src/docker-in-docker/install.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/docker-in-docker/install.sh b/src/docker-in-docker/install.sh index fbf9a38..6c14172 100644 --- a/src/docker-in-docker/install.sh +++ b/src/docker-in-docker/install.sh @@ -25,11 +25,12 @@ apt-get update # Resolve: take the current CE packages, or pin them to an apt version matching VERSION. if [ "$VERSION" = "latest" ]; then - ce="docker-ce docker-ce-cli" + ce="docker-ce docker-ce-cli docker-ce-rootless-extras" else pin="$(apt-cache madison docker-ce | awk -v v="$VERSION" '$3 ~ v {print $3; exit}')" [ -n "$pin" ] || { echo "docker-in-docker: Docker CE version '$VERSION' not found in apt" >&2; exit 1; } - ce="docker-ce=$pin docker-ce-cli=$pin" + # The rootless extras depend on the exact engine version, so they carry the same pin. + ce="docker-ce=$pin docker-ce-cli=$pin docker-ce-rootless-extras=$pin" fi # Install: engine, CLI, containerd, and the buildx/compose plugins (Docker's official set). @@ -38,14 +39,13 @@ apt-get install -y --no-install-recommends $ce containerd.io docker-buildx-plugi # Rootless: the helpers and userspace networking an unprivileged daemon needs. apt-get install -y --no-install-recommends \ - docker-ce-rootless-extras \ fuse-overlayfs \ libcap2-bin \ slirp4netns \ uidmap # Pin the engine so a later apt upgrade keeps it in sync with the persisted data volume. -apt-mark hold docker-ce docker-ce-cli containerd.io +apt-mark hold docker-ce docker-ce-cli docker-ce-rootless-extras containerd.io rm -rf /var/lib/apt/lists/* From e9cb08aabddabfc9650bc7153a8f13ea9d5cdc17 Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:34:27 +0000 Subject: [PATCH 3/6] docs(docker-in-docker): put the section comments on the shared vocabulary Rootless:, Pin the engine, and Persist which user sat outside the Options/Dependencies/Resolve/Fetch/Install/Configure/Hook/Verify set the other features use, and the daemon.json block was labelled Configure though it saves state for the entrypoint like claude-code's Hook does. The section sequence now matches codex exactly. Repeated labels are left as they are: uv already carries three Configure blocks. --- src/docker-in-docker/install.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/docker-in-docker/install.sh b/src/docker-in-docker/install.sh index 3213494..faa8c68 100644 --- a/src/docker-in-docker/install.sh +++ b/src/docker-in-docker/install.sh @@ -37,14 +37,14 @@ fi # shellcheck disable=SC2086 apt-get install -y --no-install-recommends $ce containerd.io docker-buildx-plugin docker-compose-plugin -# Rootless: the helpers and userspace networking an unprivileged daemon needs. +# Install: the helpers and userspace networking an unprivileged daemon needs. apt-get install -y --no-install-recommends \ fuse-overlayfs \ libcap2-bin \ slirp4netns \ uidmap -# Pin the engine so a later apt upgrade keeps it in sync with the persisted data volume. +# Configure: pin the engine so a later apt upgrade keeps it in sync with the persisted data volume. apt-mark hold docker-ce docker-ce-cli docker-ce-rootless-extras containerd.io rm -rf /var/lib/apt/lists/* @@ -67,10 +67,10 @@ fi install -d /usr/local/share/docker-in-docker install -m 0755 "$(dirname "$0")/docker-init.sh" /usr/local/share/docker-in-docker/docker-init.sh -# Persist which user runs the rootless daemon (the entrypoint has no _REMOTE_USER at runtime). +# Hook: persist which user runs the rootless daemon (the entrypoint has no _REMOTE_USER at runtime). printf '%s\n' "${_REMOTE_USER:-root}" > /usr/local/share/docker-in-docker/rootless-user -# Configure: keep the requested daemon settings for the entrypoint to place (empty writes nothing). +# Hook: keep the requested daemon settings for the entrypoint to place (empty writes nothing). if [ -n "$DAEMON_JSON" ]; then printf '%s\n' "$DAEMON_JSON" > /usr/local/share/docker-in-docker/daemon.json fi From d5b362c4b91b9ff6557a176dfb93b9e84588ad02 Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:57:02 +0000 Subject: [PATCH 4/6] test(docker-in-docker): exercise the daemon under the cage's own profiles The base test asserted the daemon was reachable, but the harness runs it under the default apparmor and seccomp profiles, which deny the mounts and namespaces RootlessKit needs, so it could never pass. Move the daemon checks into a rootless_daemon scenario that applies the same relaxations a consumer does, using the seccomp profile itself rather than unconfined, so the test proves that profile is sufficient. Assert the container was never granted CAP_SYS_ADMIN, reading pid 1 rather than the test shell, whose capabilities are empty simply because it is not root. Leave the base test on what holds without those relaxations: the CLI resolves and the entrypoint hands off instead of aborting the container. Dump the daemon log on failure, which is all a CI run gets to read. --- README.md | 2 +- src/docker-in-docker/README.md | 32 + src/docker-in-docker/docker-init.sh | 7 +- test/docker-in-docker/rootless_daemon.sh | 27 + test/docker-in-docker/scenarios.json | 13 + test/docker-in-docker/seccomp.json | 1149 ++++++++++++++++++++++ test/docker-in-docker/test.sh | 20 +- 7 files changed, 1235 insertions(+), 15 deletions(-) create mode 100644 src/docker-in-docker/README.md create mode 100644 test/docker-in-docker/rootless_daemon.sh create mode 100644 test/docker-in-docker/seccomp.json diff --git a/README.md b/README.md index e0be5e6..64b02ce 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ | **age** | A simple, modern, and secure file-encryption tool. | `ghcr.io/hansehart/devcontainer-features/age` | | **Claude Code CLI** | Anthropic's official command-line tool for agentic coding with Claude. | `ghcr.io/hansehart/devcontainer-features/claude-code` | | **Codex CLI** | OpenAI's official command-line tool for agentic coding with Codex. | `ghcr.io/hansehart/devcontainer-features/codex` | -| **Docker in Docker** | A Docker engine for building and running containers inside the dev container. | `ghcr.io/hansehart/devcontainer-features/docker-in-docker` | +| **Docker in Docker** | A rootless Docker engine for building and running containers inside the dev container. | `ghcr.io/hansehart/devcontainer-features/docker-in-docker` | | **Headless Chrome** | A headless Chrome build for browser automation and rendering. | `ghcr.io/hansehart/devcontainer-features/headless-chrome` | | **LaTeX (TeX Live)** | The TeX Live distribution for typesetting LaTeX documents and bibliographies. | `ghcr.io/hansehart/devcontainer-features/latex` | | **Node.js** | The Node.js JavaScript runtime, with npm and npx for installing and running packages. | `ghcr.io/hansehart/devcontainer-features/node` | diff --git a/src/docker-in-docker/README.md b/src/docker-in-docker/README.md new file mode 100644 index 0000000..a404051 --- /dev/null +++ b/src/docker-in-docker/README.md @@ -0,0 +1,32 @@ +# Docker in Docker + +Installs a rootless Docker engine for building and running containers inside the dev +container. The daemon runs as the remote user, so the dev container does not need +`privileged` and a member of the docker group cannot reach the host's devices. + +## Requirements + +The daemon only starts when the container it runs in permits the namespaces and mounts +RootlessKit creates. The consumer has to provide all of the following. + +| Requirement | Why | +|---|---| +| A non-root `remoteUser` | The daemon runs as that user and refuses to start as root. | +| `no-new-privileges` unset | It makes the kernel ignore the file capabilities on `newuidmap` and `newgidmap`. | +| `apparmor=unconfined` | The default profile denies the mounts RootlessKit performs. | +| A seccomp profile allowing `clone`, `clone3`, `unshare`, `mount`, `umount2`, `pivot_root`, `setns` | The default profile gates them behind `CAP_SYS_ADMIN`, which the container does not hold. | + +`test/docker-in-docker/seccomp.json` is the reference profile: Docker's default with those +syscalls opened and nothing else, so `kexec_load`, `init_module` and `swapon` stay denied. +It is a copy of the profile the consuming project ships, and the two have to be kept in +step by hand. + +Without these the entrypoint still hands off to the container command, so the dev +container comes up with the CLI on PATH and no daemon behind it. + +## Limitations + +No cgroup resource limits without a systemd user session, userspace networking through +slirp4netns, no ports below 1024, no host device passthrough, and no overlay networks, so +Swarm services and nested Kubernetes do not work. Pin `:1` for the rootful engine, which +is frozen and no longer maintained. diff --git a/src/docker-in-docker/docker-init.sh b/src/docker-in-docker/docker-init.sh index 66c0f13..c061b34 100644 --- a/src/docker-in-docker/docker-init.sh +++ b/src/docker-in-docker/docker-init.sh @@ -107,8 +107,13 @@ start_dockerd() { { if ! start_dockerd; then echo "docker-in-docker: rootless dockerd exited, retrying once" >&2 + # Carry the reason into the container log, which is all a CI run gets to read. + cat /tmp/dockerd.log >&2 || true sleep 2 - start_dockerd || echo "docker-in-docker: rootless dockerd failed to start, see /tmp/dockerd.log" >&2 + if ! start_dockerd; then + echo "docker-in-docker: rootless dockerd failed to start" >&2 + cat /tmp/dockerd.log >&2 || true + fi fi } & diff --git a/test/docker-in-docker/rootless_daemon.sh b/test/docker-in-docker/rootless_daemon.sh new file mode 100644 index 0000000..a42bc52 --- /dev/null +++ b/test/docker-in-docker/rootless_daemon.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + +# Import the test library +source dev-container-features-test-lib + +# The entrypoint starts the daemon in the background, so give it time to accept commands. +wait_for_daemon() { + for _ in $(seq 1 30); do + docker info > /dev/null 2>&1 && return 0 + sleep 1 + done + return 1 +} + +check "daemon reachable" bash -c "$(declare -f wait_for_daemon); wait_for_daemon" +check "daemon runs rootless" bash -c "docker info --format '{{.SecurityOptions}}' | grep -q rootless" +# The point of 2.0.0: the daemon comes up in a container that was never granted +# CAP_SYS_ADMIN, which privileged would have put in pid 1's bounding set. Read pid 1 +# rather than this shell, whose capabilities are empty simply because it is not root. +check "container is unprivileged" bash -c 'bnd=$(awk "/^CapBnd:/ {print \$2}" /proc/1/status); [ $((0x$bnd >> 21 & 1)) -eq 0 ]' +check "overlay2 storage driver" bash -c "docker info --format '{{.Driver}}' | grep -qx overlay2" +check "socket is the remote user's" bash -c 'test -S "${DOCKER_HOST#unix://}" && [ "$DOCKER_HOST" = "unix:///run/user/$(id -u)/docker.sock" ]' +check "nested container runs" docker run --rm hello-world + +# Report result +reportResults diff --git a/test/docker-in-docker/scenarios.json b/test/docker-in-docker/scenarios.json index 768efe7..2abd2d1 100644 --- a/test/docker-in-docker/scenarios.json +++ b/test/docker-in-docker/scenarios.json @@ -16,5 +16,18 @@ "version": "28" } } + }, + "rootless_daemon": { + "image": "ubuntu:24.04", + "remoteUser": "ubuntu", + "runArgs": [ + "--security-opt", + "apparmor=unconfined", + "--security-opt", + "seccomp=${localWorkspaceFolder}/seccomp.json" + ], + "features": { + "docker-in-docker": {} + } } } diff --git a/test/docker-in-docker/seccomp.json b/test/docker-in-docker/seccomp.json new file mode 100644 index 0000000..337ad48 --- /dev/null +++ b/test/docker-in-docker/seccomp.json @@ -0,0 +1,1149 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 38, + "defaultErrno": "ENOSYS", + "archMap": [ + { + "architecture": "SCMP_ARCH_X86_64", + "subArchitectures": [ + "SCMP_ARCH_X86", + "SCMP_ARCH_X32" + ] + }, + { + "architecture": "SCMP_ARCH_AARCH64", + "subArchitectures": [ + "SCMP_ARCH_ARM" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64" + ] + }, + { + "architecture": "SCMP_ARCH_S390X", + "subArchitectures": [ + "SCMP_ARCH_S390" + ] + } + ], + "syscalls": [ + { + "names": [ + "bdflush", + "cachestat", + "io_pgetevents", + "io_pgetevents_time64", + "kexec_file_load", + "kexec_load", + "map_shadow_stack", + "migrate_pages", + "move_pages", + "nfsservctl", + "nice", + "oldfstat", + "oldlstat", + "oldolduname", + "oldstat", + "olduname", + "pciconfig_iobase", + "pciconfig_read", + "pciconfig_write", + "sgetmask", + "ssetmask", + "swapoff", + "swapon", + "syscall", + "sysfs", + "uselib", + "userfaultfd", + "ustat", + "vm86", + "vm86old", + "vmsplice" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": {}, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "_llseek", + "_newselect", + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "clone", + "clone3", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_init", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsconfig", + "fsetxattr", + "fsmount", + "fsopen", + "fspick", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "get_mempolicy", + "get_robust_list", + "get_thread_area", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "io_destroy", + "io_getevents", + "io_setup", + "io_submit", + "ioctl", + "ioprio_get", + "ioprio_set", + "ipc", + "keyctl", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listxattr", + "llistxattr", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "mbind", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mount", + "mount_setattr", + "move_mount", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "open", + "open_tree", + "openat", + "openat2", + "pause", + "pidfd_getfd", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pivot_root", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "process_vm_readv", + "process_vm_writev", + "pselect6", + "pselect6_time64", + "ptrace", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "reboot", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "set_mempolicy", + "set_robust_list", + "set_thread_area", + "set_tid_address", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setns", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "setsid", + "setsockopt", + "setuid", + "setuid32", + "setxattr", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signal", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "syslog", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "umount", + "umount2", + "uname", + "unlink", + "unlinkat", + "unshare", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "ppc64le" + ] + }, + "excludes": {} + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "breakpoint", + "cacheflush", + "set_tls", + "sync_file_range2" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "arm", + "arm64" + ] + }, + "excludes": {} + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "amd64", + "x32" + ] + }, + "excludes": {} + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + }, + "excludes": {} + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": {} + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "arches": [ + "riscv64" + ] + }, + "excludes": {} + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + }, + "excludes": {} + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "bpf", + "lookup_dcookie", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + }, + "excludes": {} + }, + { + "names": [ + "lookup_dcookie", + "quotactl", + "quotactl_fd", + "setdomainname" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "sethostname", + "setns" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "o3s: nested container init calls these, and the cage holds no CAP_SYS_ADMIN, so the upstream rule would deny them", + "includes": {}, + "excludes": {} + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + }, + "excludes": {} + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "delete_module", + "finit_module", + "init_module", + "query_module" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + }, + "excludes": {} + }, + { + "names": [ + "delete_module", + "finit_module", + "init_module", + "query_module" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_MODULE" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + }, + "excludes": {} + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_PACCT" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "kcmp", + "process_madvise" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + }, + "excludes": {} + }, + { + "names": [ + "kcmp", + "process_madvise" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "ioperm", + "iopl" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + }, + "excludes": {} + }, + { + "names": [ + "ioperm", + "iopl" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "clock_settime", + "clock_settime64", + "settimeofday", + "stime" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + }, + "excludes": {} + }, + { + "names": [ + "clock_settime", + "clock_settime64", + "settimeofday", + "stime" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_TIME" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + }, + "excludes": {} + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ERRNO", + "args": [ + { + "index": 0, + "value": 40, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": {}, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ERRNO", + "args": [ + { + "index": 0, + "value": 16, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + }, + { + "index": 2, + "value": 9, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + }, + "errnoRet": 22, + "errno": "EINVAL" + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 16, + "valueTwo": 0, + "op": "SCMP_CMP_EQ" + }, + { + "index": 2, + "value": 9, + "valueTwo": 0, + "op": "SCMP_CMP_NE" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 16, + "valueTwo": 0, + "op": "SCMP_CMP_NE" + } + ], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 40, + "valueTwo": 0, + "op": "SCMP_CMP_NE" + } + ], + "comment": "", + "includes": { + "caps": [ + "CAP_AUDIT_WRITE" + ] + }, + "excludes": {} + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN", + "CAP_BPF" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_BPF" + ] + }, + "excludes": {} + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ERRNO", + "args": [], + "comment": "", + "includes": {}, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN", + "CAP_PERFMON" + ] + }, + "errnoRet": 1, + "errno": "EPERM" + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "args": [], + "comment": "", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + }, + "excludes": {} + } + ] +} \ No newline at end of file diff --git a/test/docker-in-docker/test.sh b/test/docker-in-docker/test.sh index 56a3409..e104383 100644 --- a/test/docker-in-docker/test.sh +++ b/test/docker-in-docker/test.sh @@ -4,24 +4,18 @@ set -e # Import the test library source dev-container-features-test-lib -# The entrypoint starts the daemon in the background, so give it time to accept commands. -wait_for_daemon() { - for _ in $(seq 1 30); do - docker info > /dev/null 2>&1 && return 0 - sleep 1 - done - return 1 -} +# The harness runs this container under the default apparmor and seccomp profiles, which +# deny the mounts and namespaces RootlessKit needs, so the daemon is not expected here. +# The rootless_daemon scenario relaxes both and covers the daemon itself. check "docker on PATH" bash -c "command -v docker" check "docker version" docker --version check "compose plugin" docker compose version check "buildx plugin" docker buildx version -check "daemon reachable" bash -c "$(declare -f wait_for_daemon); wait_for_daemon" -check "daemon runs rootless" bash -c "docker info --format '{{.SecurityOptions}}' | grep -q rootless" -check "overlay2 storage driver" bash -c "docker info --format '{{.Driver}}' | grep -qx overlay2" -check "socket is the remote user's" bash -c 'test -S "${DOCKER_HOST#unix://}"' -check "nested container runs" docker run --rm hello-world +# The entrypoint writes this just before it backgrounds the daemon, so its presence shows +# the setup ran to completion and handed off rather than aborting the container. +check "entrypoint handed off" bash -c "grep -qF 'DOCKER_HOST=unix:///run/user/' /etc/profile.d/99-rootless-docker.sh" +check "socket path is the remote user's" bash -c '[ "$DOCKER_HOST" = "unix:///run/user/$(id -u)/docker.sock" ]' # Report result reportResults From 0abf78f1a1bf8b19e2d10331c37539e31a593ac4 Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:09:00 +0000 Subject: [PATCH 5/6] test(docker-in-docker): resolve the seccomp profile from the checkout localWorkspaceFolder points at the CLI's temp project folder, which does not hold the test files when the container launches, so docker refused to open the profile. Reach into the repository instead, which is where the workflow invokes the CLI from. --- src/docker-in-docker/README.md | 3 ++- test/docker-in-docker/scenarios.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/docker-in-docker/README.md b/src/docker-in-docker/README.md index a404051..b9070a4 100644 --- a/src/docker-in-docker/README.md +++ b/src/docker-in-docker/README.md @@ -19,7 +19,8 @@ RootlessKit creates. The consumer has to provide all of the following. `test/docker-in-docker/seccomp.json` is the reference profile: Docker's default with those syscalls opened and nothing else, so `kexec_load`, `init_module` and `swapon` stay denied. It is a copy of the profile the consuming project ships, and the two have to be kept in -step by hand. +step by hand. The `rootless_daemon` scenario reaches it through `${localEnv:PWD}`, so run +`devcontainer features test` from the repository root. Without these the entrypoint still hands off to the container command, so the dev container comes up with the CLI on PATH and no daemon behind it. diff --git a/test/docker-in-docker/scenarios.json b/test/docker-in-docker/scenarios.json index 2abd2d1..7ee90ce 100644 --- a/test/docker-in-docker/scenarios.json +++ b/test/docker-in-docker/scenarios.json @@ -24,7 +24,7 @@ "--security-opt", "apparmor=unconfined", "--security-opt", - "seccomp=${localWorkspaceFolder}/seccomp.json" + "seccomp=${localEnv:PWD}/test/docker-in-docker/seccomp.json" ], "features": { "docker-in-docker": {} From 326da7e3f31049657d39fe645d9150db8b7c7314 Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:49:06 +0000 Subject: [PATCH 6/6] fix(docker-in-docker): bring the rootless daemon up under the harness The test job failed on three independent defects. RootlessKit builds its tap device by calling ip through nsenter, and iproute2 was never installed, so the call returned 127 and the daemon never got a network. Install it beside slirp4netns. DOCKER_HOST named /run/user/1000/docker.sock, but the daemon listens in the remote user's runtime directory and the CLI rewrites that user's id to match the host account in a layer built after this feature installs. Neither install.sh nor a static containerEnv can know the final id. Publish the socket under a name that holds no id instead, and point DOCKER_HOST there. /var/run/docker.sock stays absent, so nothing takes this container for one holding a rootful daemon. daemon.json moved from the image into the entrypoint, and the container counts as started before feature entrypoints run, so the test read the path before the entrypoint wrote it. Write it into the image again, at the remote user's config directory, and leave the entrypoint a copy to restore from when a volume hides the home. Two smaller faults surfaced alongside. The root guard resolved the account before testing it, so a name with no passwd entry ended the script rather than taking the branch meant for it, and exec with no arguments returns instead of replacing the shell, so the guard fell through to the setup it was meant to skip. Give the rootless scenario a volume for the store, which the kernel refuses to keep on the container's own overlayfs, and close the runtime directory to the container's other accounts. Drop the feature README, which was the only one in the collection. The requirements it carried now sit on the lines that depend on them. --- src/docker-in-docker/README.md | 33 ------------------- .../devcontainer-feature.json | 2 +- src/docker-in-docker/docker-init.sh | 31 ++++++++++++----- src/docker-in-docker/install.sh | 17 +++++++++- test/docker-in-docker/rootless_daemon.sh | 5 ++- test/docker-in-docker/scenarios.json | 7 ++++ test/docker-in-docker/test.sh | 14 ++++---- 7 files changed, 58 insertions(+), 51 deletions(-) delete mode 100644 src/docker-in-docker/README.md diff --git a/src/docker-in-docker/README.md b/src/docker-in-docker/README.md deleted file mode 100644 index b9070a4..0000000 --- a/src/docker-in-docker/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Docker in Docker - -Installs a rootless Docker engine for building and running containers inside the dev -container. The daemon runs as the remote user, so the dev container does not need -`privileged` and a member of the docker group cannot reach the host's devices. - -## Requirements - -The daemon only starts when the container it runs in permits the namespaces and mounts -RootlessKit creates. The consumer has to provide all of the following. - -| Requirement | Why | -|---|---| -| A non-root `remoteUser` | The daemon runs as that user and refuses to start as root. | -| `no-new-privileges` unset | It makes the kernel ignore the file capabilities on `newuidmap` and `newgidmap`. | -| `apparmor=unconfined` | The default profile denies the mounts RootlessKit performs. | -| A seccomp profile allowing `clone`, `clone3`, `unshare`, `mount`, `umount2`, `pivot_root`, `setns` | The default profile gates them behind `CAP_SYS_ADMIN`, which the container does not hold. | - -`test/docker-in-docker/seccomp.json` is the reference profile: Docker's default with those -syscalls opened and nothing else, so `kexec_load`, `init_module` and `swapon` stay denied. -It is a copy of the profile the consuming project ships, and the two have to be kept in -step by hand. The `rootless_daemon` scenario reaches it through `${localEnv:PWD}`, so run -`devcontainer features test` from the repository root. - -Without these the entrypoint still hands off to the container command, so the dev -container comes up with the CLI on PATH and no daemon behind it. - -## Limitations - -No cgroup resource limits without a systemd user session, userspace networking through -slirp4netns, no ports below 1024, no host device passthrough, and no overlay networks, so -Swarm services and nested Kubernetes do not work. Pin `:1` for the rootful engine, which -is frozen and no longer maintained. diff --git a/src/docker-in-docker/devcontainer-feature.json b/src/docker-in-docker/devcontainer-feature.json index d52d185..bbfbbad 100644 --- a/src/docker-in-docker/devcontainer-feature.json +++ b/src/docker-in-docker/devcontainer-feature.json @@ -20,7 +20,7 @@ "privileged": false, "containerEnv": { "DOCKER_BUILDKIT": "1", - "DOCKER_HOST": "unix:///run/user/1000/docker.sock" + "DOCKER_HOST": "unix:///run/docker-rootless.sock" }, "customizations": { "vscode": { diff --git a/src/docker-in-docker/docker-init.sh b/src/docker-in-docker/docker-init.sh index c061b34..51e6a2e 100644 --- a/src/docker-in-docker/docker-init.sh +++ b/src/docker-in-docker/docker-init.sh @@ -3,16 +3,20 @@ set -e # The unprivileged user that owns the daemon (persisted by install.sh). RUSER="$(cat /usr/local/share/docker-in-docker/rootless-user 2>/dev/null || echo root)" -RUID="$(id -u "$RUSER")" -RHOME="$(getent passwd "$RUSER" | cut -d: -f6)" -RUNTIME_DIR="/run/user/${RUID}" # The daemon runs as the remote user, and cannot start as root. -if [ "$RUSER" = "root" ]; then +# Resolving the account here lets a name with no passwd entry take this branch too. +if [ "$RUSER" = "root" ] || ! RUSER_ENT="$(getent passwd "$RUSER")"; then echo "docker-in-docker: needs a non-root remoteUser, the daemon stays down" >&2 + # Hand off to the container command, or stop when there is none to hand off to. exec "$@" + exit 0 fi +RUID="$(echo "$RUSER_ENT" | cut -d: -f3)" +RHOME="$(echo "$RUSER_ENT" | cut -d: -f6)" +RUNTIME_DIR="/run/user/${RUID}" + # Trust a mounted CA, so registries behind a TLS proxy resolve. update-ca-certificates >/dev/null 2>&1 || true @@ -61,17 +65,22 @@ if [ ! -c /dev/net/tun ]; then fi # Prepare the runtime and data directories owned by the remote user. +# The store expects a volume here, since it cannot sit on the container's own overlayfs. mkdir -p "$RUNTIME_DIR" "${RHOME}/.local/share/docker" +# The runtime directory carries the daemon socket, so it stays shut to the other accounts. +chmod 0700 "$RUNTIME_DIR" chown "$RUSER":"$RUSER" "$RUNTIME_DIR" "${RHOME}/.local/share/docker" 2>/dev/null || true -# Place the daemon settings where the daemon reads them, once the home mounts are in place. -if [ -f /usr/local/share/docker-in-docker/daemon.json ]; then +# Restore the daemon settings when a volume mounted over the home hides the image's copy. +if [ -f /usr/local/share/docker-in-docker/daemon.json ] \ + && [ ! -f "${RHOME}/.config/docker/daemon.json" ]; then mkdir -p "${RHOME}/.config/docker" cp /usr/local/share/docker-in-docker/daemon.json "${RHOME}/.config/docker/daemon.json" chown -R "$RUSER":"$RUSER" "${RHOME}/.config/docker" 2>/dev/null || true fi # Re-privilege the id-mapping helpers, since the image unpack drops their capabilities. +# no-new-privileges has to stay unset, or the kernel ignores what is set below. for b in /usr/bin/newuidmap /usr/bin/newgidmap; do # Keep the caller's identity, which is what the kernel grants the mapping to. chmod u-s "$b" 2>/dev/null || true @@ -79,8 +88,12 @@ for b in /usr/bin/newuidmap /usr/bin/newgidmap; do setcap cap_setuid,cap_setgid+ep "$b" 2>/dev/null || true done -# Point interactive shells at the socket the daemon listens on. -printf 'export DOCKER_HOST=unix://%s/docker.sock\n' "$RUNTIME_DIR" \ +# Publish the socket under a name that holds no user id. +# The daemon's own path holds one, and it is only known once the container runs. +ln -sfn "${RUNTIME_DIR}/docker.sock" /run/docker-rootless.sock + +# Point login shells at the same name, for the ones that inherit no container environment. +printf 'export DOCKER_HOST=unix:///run/docker-rootless.sock\n' \ > /etc/profile.d/99-rootless-docker.sh start_dockerd() { @@ -89,6 +102,8 @@ start_dockerd() { find /run /var/run -iname 'container*.pid' -delete 2>/dev/null || true # Run as the remote user, with userspace networking and a private pid namespace. + # Exits unless the container's apparmor and seccomp profiles admit those namespaces. + # overlay2 needs the store on a real filesystem, which the volume above provides. runuser -u "$RUSER" -- env \ HOME="$RHOME" \ XDG_RUNTIME_DIR="$RUNTIME_DIR" \ diff --git a/src/docker-in-docker/install.sh b/src/docker-in-docker/install.sh index faa8c68..cd84bee 100644 --- a/src/docker-in-docker/install.sh +++ b/src/docker-in-docker/install.sh @@ -38,8 +38,12 @@ fi apt-get install -y --no-install-recommends $ce containerd.io docker-buildx-plugin docker-compose-plugin # Install: the helpers and userspace networking an unprivileged daemon needs. +# iproute2 carries `ip`, which the userspace network stack calls to build its tap device. +# libcap2-bin carries `setcap`, which the entrypoint puts on the id-mapping helpers. +# uidmap carries those helpers, which map the subordinate ids granted below. apt-get install -y --no-install-recommends \ fuse-overlayfs \ + iproute2 \ libcap2-bin \ slirp4netns \ uidmap @@ -51,6 +55,7 @@ rm -rf /var/lib/apt/lists/* # Configure: add the non-root remote user to the docker group so the client resolves the daemon. groupadd -f docker +# A root remote user has nothing to configure here, and leaves the daemon down at start. if [ -n "$_REMOTE_USER" ] && [ "$_REMOTE_USER" != "root" ]; then usermod -aG docker "$_REMOTE_USER" || true @@ -70,9 +75,19 @@ install -m 0755 "$(dirname "$0")/docker-init.sh" /usr/local/share/docker-in-dock # Hook: persist which user runs the rootless daemon (the entrypoint has no _REMOTE_USER at runtime). printf '%s\n' "${_REMOTE_USER:-root}" > /usr/local/share/docker-in-docker/rootless-user -# Hook: keep the requested daemon settings for the entrypoint to place (empty writes nothing). +# Configure: write the requested daemon settings into the image (empty writes nothing). if [ -n "$DAEMON_JSON" ]; then + # The copy the entrypoint restores from when a volume hides the home directory. printf '%s\n' "$DAEMON_JSON" > /usr/local/share/docker-in-docker/daemon.json + + # The copy the daemon reads, in place before anything can look for it. + remote_home="${_REMOTE_USER_HOME:-}" + if [ -n "$remote_home" ] && [ "${_REMOTE_USER:-root}" != "root" ]; then + install -d -m 0755 "${remote_home}/.config/docker" + printf '%s\n' "$DAEMON_JSON" > "${remote_home}/.config/docker/daemon.json" + # By name, since the numeric id can still change. + chown -R "$_REMOTE_USER" "${remote_home}/.config" || true + fi fi # Verify: the CLI resolves on PATH. diff --git a/test/docker-in-docker/rootless_daemon.sh b/test/docker-in-docker/rootless_daemon.sh index a42bc52..3d1419f 100644 --- a/test/docker-in-docker/rootless_daemon.sh +++ b/test/docker-in-docker/rootless_daemon.sh @@ -20,7 +20,10 @@ check "daemon runs rootless" bash -c "docker info --format '{{.SecurityOptions}} # rather than this shell, whose capabilities are empty simply because it is not root. check "container is unprivileged" bash -c 'bnd=$(awk "/^CapBnd:/ {print \$2}" /proc/1/status); [ $((0x$bnd >> 21 & 1)) -eq 0 ]' check "overlay2 storage driver" bash -c "docker info --format '{{.Driver}}' | grep -qx overlay2" -check "socket is the remote user's" bash -c 'test -S "${DOCKER_HOST#unix://}" && [ "$DOCKER_HOST" = "unix:///run/user/$(id -u)/docker.sock" ]' +# The published name has to reach a live socket. +check "socket is live at the fixed path" bash -c 'test -S /run/docker-rootless.sock && [ "$DOCKER_HOST" = "unix:///run/docker-rootless.sock" ]' +# And to lead to this shell's own runtime directory, which a path fixed at build time misses. +check "socket link resolves to the remote user" bash -c '[ "$(readlink /run/docker-rootless.sock)" = "/run/user/$(id -u)/docker.sock" ]' check "nested container runs" docker run --rm hello-world # Report result diff --git a/test/docker-in-docker/scenarios.json b/test/docker-in-docker/scenarios.json index 7ee90ce..96bbc0c 100644 --- a/test/docker-in-docker/scenarios.json +++ b/test/docker-in-docker/scenarios.json @@ -26,6 +26,13 @@ "--security-opt", "seccomp=${localEnv:PWD}/test/docker-in-docker/seccomp.json" ], + "mounts": [ + { + "source": "dind-rootless-data", + "target": "/home/ubuntu/.local/share/docker", + "type": "volume" + } + ], "features": { "docker-in-docker": {} } diff --git a/test/docker-in-docker/test.sh b/test/docker-in-docker/test.sh index e104383..7d9515d 100644 --- a/test/docker-in-docker/test.sh +++ b/test/docker-in-docker/test.sh @@ -4,18 +4,18 @@ set -e # Import the test library source dev-container-features-test-lib -# The harness runs this container under the default apparmor and seccomp profiles, which -# deny the mounts and namespaces RootlessKit needs, so the daemon is not expected here. -# The rootless_daemon scenario relaxes both and covers the daemon itself. +# Default apparmor and seccomp apply here, so no daemon is expected and only the client is +# checked. The rootless_daemon scenario relaxes both and covers the daemon itself. check "docker on PATH" bash -c "command -v docker" check "docker version" docker --version check "compose plugin" docker compose version check "buildx plugin" docker buildx version -# The entrypoint writes this just before it backgrounds the daemon, so its presence shows -# the setup ran to completion and handed off rather than aborting the container. -check "entrypoint handed off" bash -c "grep -qF 'DOCKER_HOST=unix:///run/user/' /etc/profile.d/99-rootless-docker.sh" -check "socket path is the remote user's" bash -c '[ "$DOCKER_HOST" = "unix:///run/user/$(id -u)/docker.sock" ]' +# Written just before the daemon is backgrounded, so its presence shows the setup finished. +check "entrypoint handed off" bash -c "grep -qF 'DOCKER_HOST=unix:///run/docker-rootless.sock' /etc/profile.d/99-rootless-docker.sh" +check "socket path is fixed" bash -c '[ "$DOCKER_HOST" = "unix:///run/docker-rootless.sock" ]' +# Read against the id this shell runs as, which a path fixed at build time would not match. +check "socket link resolves to the remote user" bash -c '[ "$(readlink /run/docker-rootless.sock)" = "/run/user/$(id -u)/docker.sock" ]' # Report result reportResults