From 9d53ff506e6838e7268ddb1e7c44bbdabad70681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 12 Aug 2026 19:11:49 +0000 Subject: [PATCH] port Docker, with the group-versus-rootless choice spelled out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three options, each explained rather than named, because the difference between them is a security posture and the default is the one that sounds harmless. 1 docker group, the default. The text says what the group actually is: anyone in it can run `docker run -v /:/host -it alpine chroot /host` and have a root shell. It is not "access to Docker", it is root by a longer route — the same framing os-user-docker.ts already uses for why members never get it. Whether that matters is conditional, and the run works it out rather than asserting either way: on an account that already has sudo it is a shorter path to something they can reach anyway, and it says so; on an account that does not, it is a real escalation, and it says that instead. Caught in testing, where the reassuring sentence was being printed for a throwaway account with no sudo at all — the exact case where it is untrue. 2 rootless, with the thing nobody would find out stated at the prompt: Officer's app store cannot provision containers with it. compose.ts, preflight.ts and system-monitor all spawn `docker` with no environment of their own, so they reach /var/run/docker.sock; DOCKER_HOST is set only for member commands, in os-user-docker.ts. pm2 started at boot by systemd has no session either, so exporting it in a shell rc does not reach the process that matters. The consequence is recorded in the summary, not just spoken. 3 neither, and what that costs. Also fixed in the port: the repository codename came from `lsb_release -cs`, which is wrong on every derivative — Mint reports "vanessa", Pop reports its own, and Docker publishes neither, so `apt update` fails against a repository that does not exist. os-release carries UBUNTU_CODENAME on exactly those systems for exactly this reason; it is preferred now, with VERSION_CODENAME as the fallback, and the ubuntu/debian half of the URL comes from ID_LIKE rather than being hardcoded. The shared `services` network is created only when missing, checked with `docker network inspect` rather than by running create and discarding the error. Verified on this host, and against a throwaway account both with and without sudo. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/setup/machine-setup/lib/docker.sh | 112 ++++++++++++++++ scripts/setup/machine-setup/machine-setup.sh | 132 ++++++++++++++++++- 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 scripts/setup/machine-setup/lib/docker.sh diff --git a/scripts/setup/machine-setup/lib/docker.sh b/scripts/setup/machine-setup/lib/docker.sh new file mode 100644 index 00000000..44237f70 --- /dev/null +++ b/scripts/setup/machine-setup/lib/docker.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# ============================================================================= +# machine-setup — Docker +# ============================================================================= +# +# Definitions only, like the other lib/ files. + +[[ -n "${MACHINE_SETUP_DOCKER_LOADED:-}" ]] && return 0 +MACHINE_SETUP_DOCKER_LOADED=1 + +DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}" + +docker_is_installed() { command -v docker &>/dev/null; } + +# The daemon, not just the binary. `docker --version` answers from the client +# alone and says nothing about whether there is anything to talk to. +docker_daemon_ok() { docker info &>/dev/null; } + +user_in_docker_group() { id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx docker; } + +docker_rootless_installed() { [[ -S "/run/user/$(id -u "$USERNAME" 2>/dev/null)/docker.sock" ]]; } + +# The codename Docker's repository is actually published under. +# +# `lsb_release -cs` is what the original used, and it is wrong on every +# derivative: Mint reports "vanessa", Pop reports its own, and Docker publishes +# neither — so `apt update` fails on a repository that does not exist. os-release +# carries UBUNTU_CODENAME on exactly those systems for exactly this reason, so it +# is preferred and VERSION_CODENAME is the fallback. +docker_repo_codename() { + local c + c="$(os_release UBUNTU_CODENAME || true)" + [[ -z "$c" ]] && c="$(os_release VERSION_CODENAME || true)" + echo "$c" +} + +# Which upstream to point at. A derivative is Ubuntu or Debian as far as Docker +# is concerned, and ID_LIKE is how it says which. +docker_repo_distro() { + case "$OS" in + ubuntu | debian) echo "$OS" ;; + *) + case " $(os_release ID_LIKE || true) " in + *" ubuntu "*) echo ubuntu ;; + *) echo debian ;; + esac + ;; + esac +} + +install_docker_engine() { + local distro codename + distro="$(docker_repo_distro)" + codename="$(docker_repo_codename)" + + [[ -n "$codename" ]] || { + warn "could not work out this release's codename — cannot add the Docker repository" + return 1 + } + + install -m 0755 -d /etc/apt/keyrings + curl -fsSL "https://download.docker.com/linux/${distro}/gpg" | + gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${distro} ${codename} stable" \ + >/etc/apt/sources.list.d/docker.list + + pkg_refresh >/dev/null + pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +} + +# A shared network so containers from different compose files can reach each +# other by name. Harmless if it is already there. +ensure_docker_network() { + docker network inspect "$DOCKER_NETWORK" &>/dev/null && return 0 + docker network create "$DOCKER_NETWORK" >/dev/null 2>&1 +} + +# ── Rootless, for the owner ── +# +# Works, and does not work with Officer's app store as it stands. Both are true +# and the second is the one nobody would find out until a container failed to +# provision, so it is stated at the prompt rather than left here. +# +# The app store spawns `docker` with no environment of its own — +# app-store/compose.ts, app-store/preflight.ts, api/system-monitor — so it talks +# to whatever socket the `officer` pm2 process's environment points at. That is +# /var/run/docker.sock unless DOCKER_HOST says otherwise, and nothing sets +# DOCKER_HOST for the owner: os-user-docker.ts sets it only for member commands. +# +# pm2 started at boot by systemd has no session either, so exporting it in a +# shell rc does not reach the process that matters. +install_docker_rootless() { + local uid + uid="$(id -u "$USERNAME")" + + # Without lingering, the user manager stops when the last session ends and + # takes the daemon with it. Officer's shells are not login sessions. + loginctl enable-linger "$USERNAME" >/dev/null 2>&1 + + sudo -u "$USERNAME" \ + XDG_RUNTIME_DIR="/run/user/${uid}" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \ + PATH="/usr/bin:/usr/sbin:/bin:/sbin" \ + dockerd-rootless-setuptool.sh install >/dev/null 2>&1 || return 1 + + sudo -u "$USERNAME" \ + XDG_RUNTIME_DIR="/run/user/${uid}" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \ + systemctl --user enable --now docker >/dev/null 2>&1 +} diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index 122cd97d..e7e7cd9c 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -34,6 +34,8 @@ source "$SCRIPT_DIR/lib/ssh.sh" source "$SCRIPT_DIR/lib/network.sh" # shellcheck source=lib/dev.sh source "$SCRIPT_DIR/lib/dev.sh" +# shellcheck source=lib/docker.sh +source "$SCRIPT_DIR/lib/docker.sh" # Trap errors with context. Installed here rather than in lib/base.sh, because # that file is definitions only and a trap is a side effect on whoever sources it. @@ -1349,13 +1351,141 @@ if ! skip; then step_ok fi +# ============================================================================= +# 21. Docker +# ============================================================================= + +step "Docker" +if ! skip; then + echo "" + info "Docker — containers, and how ${USERNAME} is allowed to talk to them" + echo " engine: $(docker_is_installed && docker --version 2>/dev/null | cut -d, -f1 || echo 'not installed')" + echo " daemon: $(docker_daemon_ok && echo 'reachable' || echo 'not reachable from here')" + echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')" + + if ! docker_is_installed; then + echo "" + echo " to install: docker-ce, the CLI, containerd, buildx and compose," + echo " from Docker's own repository" + if confirm "Install it?"; then + if install_docker_engine; then + ok "$(docker --version 2>/dev/null | cut -d, -f1) installed" + SUMMARY+=("Docker: engine installed") + else + warn "the Docker install did not complete" + ERRORS+=("Docker: engine install failed") + SUMMARY+=("Docker: install FAILED") + fi + else + warn "skipped by request" + SUMMARY+=("Docker: SKIPPED by request") + fi + fi + + if docker_is_installed; then + # ── how this account reaches the daemon ── + if user_in_docker_group || docker_rootless_installed; then + echo "" + echo " ${USERNAME} can already reach a daemon — leaving that as it is" + SUMMARY+=("Docker: access unchanged") + else + echo "" + info "How should ${USERNAME} use Docker?" + echo "" + echo " [1] add ${USERNAME} to the docker group (recommended)" + echo " Plain 'docker' commands, and the daemon runs as root." + echo " Be clear about what the group is: anyone in it can run" + echo " docker run -v /:/host -it alpine chroot /host" + echo " which is a root shell. The group is not 'access to Docker'," + echo " it is root by a longer route." + # Only true if the account really does have sudo. Saying it of one that + # does not would be reassuring about the exact case where the group is a + # genuine escalation. + if id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx sudo; then + echo " On this machine that grants nothing new — ${USERNAME} already" + echo " has sudo, so it is a shorter route to something they can" + echo " already reach." + else + warn " ${USERNAME} does NOT have sudo, so this genuinely escalates them." + echo " That is the case rootless exists for, and why members get it." + fi + echo "" + echo " [2] rootless Docker for ${USERNAME}" + echo " Their own daemon, containers in their own user namespace," + echo " root inside a container is nobody outside it. The same thing" + echo " Officer gives members." + warn " Officer's app store cannot provision containers with this." + echo " It runs 'docker' with no environment of its own, so it talks" + echo " to /var/run/docker.sock — not this account's socket. Making" + echo " it work means putting DOCKER_HOST in the officer process's" + echo " environment, which this script does not do." + echo "" + echo " [3] neither — use 'sudo docker'" + echo " Nothing is granted. Every command needs sudo, including" + echo " anything Officer would run as ${USERNAME}." + echo "" + + DOCKER_ACCESS="" + while [[ -z "$DOCKER_ACCESS" ]]; do + if ! read -rp " Which one? (1/2/3) [1]: " DOCKER_CHOICE; then + echo "" + fail "No answer." + fi + case "${DOCKER_CHOICE:-1}" in + 1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;; + *) warn "Pick 1, 2 or 3." ;; + esac + done + + case "$DOCKER_ACCESS" in + 1) + usermod -aG docker "$USERNAME" + ok "${USERNAME} added to the docker group" + echo " Takes effect at their next login — group membership is read" + echo " when the session starts, so the shell you are in now still" + echo " does not have it." + SUMMARY+=("Docker: ${USERNAME} in the docker group") + ;; + 2) + if ! pkg_is_installed uidmap || ! pkg_is_installed dbus-user-session; then + info " installing uidmap and dbus-user-session, which rootless needs" + pkg_install_now uidmap dbus-user-session + fi + if install_docker_rootless; then + ok "rootless Docker running for ${USERNAME}" + echo " DOCKER_HOST=unix:///run/user/$(id -u "$USERNAME")/docker.sock" + SUMMARY+=("Docker: rootless for ${USERNAME} — app store provisioning will NOT work until DOCKER_HOST is in officer's environment") + else + warn "the rootless setup did not complete" + ERRORS+=("Docker: rootless setup failed") + SUMMARY+=("Docker: rootless setup FAILED") + fi + ;; + 3) + echo " nothing granted — docker needs sudo" + SUMMARY+=("Docker: no access granted, sudo required") + ;; + esac + fi + + # ── the shared network ── + if docker_daemon_ok; then + if ensure_docker_network; then + echo " network '${DOCKER_NETWORK}' present, so containers from separate" + echo " compose files can reach each other by name" + fi + fi + fi + step_ok +fi + # ============================================================================= # NOT PORTED YET # ============================================================================= # # Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # -# docker · zsh + prompt (incl. .tmux.conf) · tailscale · neovim · js runtimes · +# zsh + prompt (incl. .tmux.conf) · tailscale · neovim · js runtimes · # dev tools · ufw · zshrc # # And one that is new rather than ported, to come last of all: