port Docker, with the group-versus-rootless choice spelled out

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 19:11:49 +00:00
co-authored by Claude Opus 5
parent f9c6b7925a
commit 9d53ff506e
2 changed files with 243 additions and 1 deletions
+112
View File
@@ -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
}
+131 -1
View File
@@ -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: