diff --git a/bin/omarchy-launch-docker-tui b/bin/omarchy-launch-docker-tui index b31e9170..8d09894a 100755 --- a/bin/omarchy-launch-docker-tui +++ b/bin/omarchy-launch-docker-tui @@ -9,10 +9,12 @@ # no prompt. lazydocker needs the root-owned Docker socket, so when the group is # absent, gate that access behind a polkit prompt. If the user has opted into # sudoless Docker (omarchy-setup-security-sudoless-docker), the socket is already -# reachable, so run lazydocker directly. pkexec sanitizes the environment, so -# carry TERM through for the TUI to render and run lazydocker from root's PATH. -if id -nG 2>/dev/null | grep -qw docker; then - exec lazydocker -else +# reachable, so run lazydocker directly — omarchy-sudo-docker answers that for +# this session, so the prompt stays until the reboot that grants the group. +# pkexec sanitizes the environment, so carry TERM through for the TUI to render +# and run lazydocker from root's PATH. +if omarchy-sudo-docker; then exec pkexec /usr/bin/env TERM="${TERM:-xterm-256color}" lazydocker +else + exec lazydocker fi diff --git a/bin/omarchy-remove-security-sudoless-docker b/bin/omarchy-remove-security-sudoless-docker index 2bd5d09a..dd4c6175 100755 --- a/bin/omarchy-remove-security-sudoless-docker +++ b/bin/omarchy-remove-security-sudoless-docker @@ -5,7 +5,10 @@ set -e -if ! id -nG "$USER" 2>/dev/null | grep -qw docker; then +# Ask about the configured groups, not this session's: right after enabling, +# sudoless Docker is on for the account even though the running session still +# needs a prompt, and this command is what turns it back off. +if omarchy-sudo-docker --configured; then echo "Sudoless Docker is not enabled: $USER is not in the docker group." exit 0 fi @@ -13,13 +16,19 @@ fi echo "Removing $USER from the docker group..." sudo gpasswd -d "$USER" docker >/dev/null -# Group membership is fixed at login, so the running session keeps its docker -# access until it ends. Flag a reboot so omarchy-update-restart prompts for one -# (and the bar shows it pending); a plain log out and back in works too. +# Group membership is only re-read by a fresh session, and in practice logging +# out or newgrp isn't enough — only a reboot reliably applies it. Record it so a +# later `omarchy update` still prompts (omarchy-update-restart reads this), then +# offer to do it now. omarchy-state set reboot-required echo "" -echo "Sudoless Docker DISABLED. Reboot (or log out and back in) for the change to take effect." -echo "Docker access now goes through a polkit/sudo prompt again: the Docker TUI" -echo "(Super + Shift + D) and the Windows VM will ask when they need it, and the" -echo "plain 'docker' CLI runs under sudo." +echo "Sudoless Docker DISABLED. Docker access goes through a polkit/sudo prompt" +echo "again: the Docker TUI (Super + Shift + D) and the Windows VM ask when they" +echo "need it, and the plain 'docker' CLI runs under sudo. It takes effect after a reboot." +echo "" +# The migration reuses this command during 'omarchy update' and defers the +# reboot to omarchy-update-restart, so it doesn't cut the update short. +if [[ -z ${OMARCHY_DEFER_REBOOT:-} ]] && gum confirm "Reboot now to apply?"; then + omarchy-system-reboot +fi diff --git a/bin/omarchy-setup-security-sudoless-docker b/bin/omarchy-setup-security-sudoless-docker index ead18712..4577cbf1 100755 --- a/bin/omarchy-setup-security-sudoless-docker +++ b/bin/omarchy-setup-security-sudoless-docker @@ -5,7 +5,9 @@ set -e -if id -nG "$USER" 2>/dev/null | grep -qw docker; then +# Ask about the configured groups, not this session's: once enabled it stays +# enabled for the account even before the reboot that lets this session use it. +if ! omarchy-sudo-docker --configured; then echo "Sudoless Docker is already enabled: $USER is in the docker group." echo "To disable it again, run: omarchy-remove-security-sudoless-docker" exit 0 @@ -28,14 +30,20 @@ echo "" if gum confirm "Enable sudoless Docker? This gives anything running as you passwordless root."; then sudo usermod -aG docker "$USER" - # Group membership is fixed at login, so docker won't be reachable without a - # prompt until the session restarts. Flag a reboot so omarchy-update-restart - # prompts for one (and the bar shows it pending). + # A new docker group membership is only picked up by a fresh session, and in + # practice logging out or newgrp isn't enough — only a reboot reliably applies + # it. Record it so a later `omarchy update` still prompts + # (omarchy-update-restart reads this), then offer to do it now. omarchy-state set reboot-required echo "" - echo "Sudoless Docker ENABLED. Reboot, or log out and back in (or run 'newgrp docker')," - echo "for the new group membership to take effect." - echo "To disable it again, run: omarchy-remove-security-sudoless-docker" + echo "Sudoless Docker ENABLED. It takes effect after a reboot." + echo "To disable it again: Setup > Security > Sudoless Docker." + echo "" + # The migration reuses this command during 'omarchy update' and defers the + # reboot to omarchy-update-restart, so it doesn't cut the update short. + if [[ -z ${OMARCHY_DEFER_REBOOT:-} ]] && gum confirm "Reboot now to apply?"; then + omarchy-system-reboot + fi else echo "Aborted. No changes made. Docker access still goes through a prompt." fi diff --git a/bin/omarchy-sudo-docker b/bin/omarchy-sudo-docker new file mode 100755 index 00000000..19399cc4 --- /dev/null +++ b/bin/omarchy-sudo-docker @@ -0,0 +1,44 @@ +#!/bin/bash + +# omarchy:summary=Succeed when Docker needs sudo, fail when it can be used directly +# omarchy:args=[--configured] +# omarchy:examples=omarchy-sudo-docker && echo "needs sudo" | omarchy-sudo-docker --configured +# omarchy:hidden=true + +# The docker group is root-equivalent, so Omarchy leaves users out of it by +# default and reaches the daemon through a prompt instead. Everything that has +# to make that choice asks here rather than testing group membership itself. +# +# Two questions, because they have different answers between toggling sudoless +# Docker and the reboot that applies it (group membership is fixed when the +# session is created): +# +# (default) Does Docker need sudo *right now*? Answered by whether this +# process can actually reach the socket, which is what decides +# if a command must elevate. Still true in the window after +# sudoless Docker is enabled but before the reboot. +# --configured Will it need sudo once the account's groups take effect? +# Answered from the account's configured groups, so the menu +# offers the toggle that can actually change state. +# +# Succeeds (exit 0) when sudo is needed, so it reads as `if omarchy-sudo-docker`. + +DOCKER_SOCKET="${OMARCHY_DOCKER_SOCKET:-/var/run/docker.sock}" + +case "${1:-}" in +--configured) + # An account in the docker group will not need sudo after the next login. + id -nG "$USER" 2>/dev/null | grep -qw docker && exit 1 + exit 0 + ;; +"") + # A socket we can write is a daemon we can drive without elevating. A missing + # socket counts as needing sudo: reaching it means starting it as root anyway. + [[ -w $DOCKER_SOCKET ]] && exit 1 + exit 0 + ;; +*) + echo "Usage: omarchy-sudo-docker [--configured]" >&2 + exit 2 + ;; +esac diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 071dc784..1487cb21 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -31,8 +31,11 @@ CONTAINER="omarchy-windows" # --- privilege helpers ------------------------------------------------------- -# True when the user can reach the Docker socket directly (sudoless Docker on). -in_docker_group() { id -nG 2>/dev/null | grep -qw docker; } +# True when this session can reach the Docker socket directly (sudoless Docker +# on and in effect). Asking about the socket rather than the configured groups +# keeps the prompt in place through the window where sudoless Docker is enabled +# but the reboot that grants the group has not happened yet. +docker_needs_sudo() { omarchy-sudo-docker; } # The command to hand pkexec for the privileged re-exec. pkexec runs whatever # executable it is given (after authorization) and only shows the path in the @@ -64,7 +67,7 @@ priv_target() { priv() { local action="$1" shift - if [[ $action != write_compose ]] && in_docker_group; then + if [[ $action != write_compose ]] && ! docker_needs_sudo; then "__priv_$action" "$@" return fi diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index b1cfcf7b..59582db7 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -179,7 +179,7 @@ "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"}, "setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"}, - "setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"}, + "setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"}, "setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""}, "setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"}, "setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"}, @@ -291,7 +291,7 @@ "remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"}, "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, "remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"}, - "remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"id -nG | grep -qw docker","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, + "remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, "remove.browser.chrome": {"icon":"","label":"Chrome","when":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser chrome'"}, "remove.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser edge'"}, "remove.browser.brave": {"icon":"","label":"Brave","when":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser brave'"}, diff --git a/migrations/1787580187.sh b/migrations/1787580187.sh index 54ac1e9c..cf07cc66 100644 --- a/migrations/1787580187.sh +++ b/migrations/1787580187.sh @@ -2,13 +2,15 @@ echo "Move this install to the opt-in docker group default (the group is root-eq # The docker group grants passwordless root (a container can bind-mount / and # rewrite the host), so Omarchy no longer puts users in it by default. Bring -# existing installs in line: remove this user from the group if present. It takes -# effect at next login, and the current session keeps working until then. Anyone -# who wants passwordless docker back can opt in, behind a warning, with +# existing installs in line: remove this user from the group if present. The +# change applies after a reboot, so it stays reachable until then. Anyone who +# wants passwordless docker back can opt in, behind a warning, with # Setup > Security > Sudoless Docker. Reuses the removal command so there is one -# source of truth for the privileged change and its notice. +# source of truth for the privileged change and its notice; DEFER_REBOOT keeps +# it from prompting mid-update — omarchy-update-restart handles the reboot once +# the whole update has finished. if id -nG "$USER" | grep -qw docker; then - omarchy-remove-security-sudoless-docker + OMARCHY_DEFER_REBOOT=1 omarchy-remove-security-sudoless-docker fi # The Docker app entry copied into ~/.local/share/applications used to run diff --git a/test/shell.d/docker-group-migration-test.sh b/test/shell.d/docker-group-migration-test.sh index ba2c4108..d382f88f 100644 --- a/test/shell.d/docker-group-migration-test.sh +++ b/test/shell.d/docker-group-migration-test.sh @@ -39,16 +39,30 @@ cat >"$stub_bin/gpasswd" <<'STUB' #!/bin/bash echo "$@" >>"${GPASSWD_CALLS:?}" STUB -chmod +x "$stub_bin/id" "$stub_bin/sudo" "$stub_bin/gpasswd" +# gum confirm always says yes, and reboot records that it fired: the migration +# must still NOT reboot (it defers to omarchy-update-restart), so neither should +# be reached. +cat >"$stub_bin/gum" <<'STUB' +#!/bin/bash +[[ $1 == confirm ]] && exit 0 +exit 0 +STUB +cat >"$stub_bin/omarchy-system-reboot" <<'STUB' +#!/bin/bash +touch "${REBOOT_CALLED:?}" +STUB +chmod +x "$stub_bin/id" "$stub_bin/sudo" "$stub_bin/gpasswd" "$stub_bin/gum" "$stub_bin/omarchy-system-reboot" reboot_flag="$home/.local/state/omarchy/reboot-required" gpasswd_calls="$test_dir/gpasswd-calls" +reboot_called="$test_dir/reboot-called" launcher="$home/.local/share/applications/Docker.desktop" run_migration() { - rm -f "$gpasswd_calls" "$reboot_flag" + rm -f "$gpasswd_calls" "$reboot_flag" "$reboot_called" HOME="$home" OMARCHY_PATH="$omarchy_path" USER="tester" STUB_GROUPS="$1" \ - GPASSWD_CALLS="$gpasswd_calls" PATH="$stub_bin:$ROOT/bin:$PATH" \ + GPASSWD_CALLS="$gpasswd_calls" REBOOT_CALLED="$reboot_called" \ + PATH="$stub_bin:$ROOT/bin:$PATH" \ bash -euo pipefail "$migration" >/dev/null 2>&1 } @@ -56,6 +70,7 @@ run_migration() { run_migration "wheel input docker" || fail "migration runs when the user is in the docker group" grep -q -- "-d tester docker" "$gpasswd_calls" || fail "migration removes the user from the docker group" [[ -f $reboot_flag ]] || fail "migration flags a reboot so the group change takes effect" +[[ ! -f $reboot_called ]] || fail "migration must defer the reboot (not reboot mid-update)" [[ $(cat "$launcher") == "NEW-LAUNCHER" ]] || fail "migration refreshes the stale Docker launcher entry" pass "migration removes the group, flags a reboot, and refreshes the launcher" diff --git a/test/shell.d/sudo-docker-test.sh b/test/shell.d/sudo-docker-test.sh new file mode 100644 index 00000000..d108030a --- /dev/null +++ b/test/shell.d/sudo-docker-test.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# +# omarchy-sudo-docker is the single answer to "does Docker need sudo", and it +# answers two different questions on purpose. The default asks whether this +# session can reach the socket, which is what decides if a command must elevate. +# --configured asks whether the account is set up for sudoless Docker, which is +# what the menu needs so it offers the toggle that can change state. Between +# enabling sudoless Docker and the reboot that grants the group, those disagree. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +command="$ROOT/bin/omarchy-sudo-docker" + +# Stub id so the configured groups are controllable. +mkdir -p "$TMPDIR/bin" +cat >"$TMPDIR/bin/id" <<'STUB' +#!/bin/bash +printf '%s\n' "${STUB_GROUPS:-wheel input}" +STUB +chmod +x "$TMPDIR/bin/id" + +# A writable stand-in means the socket is reachable; an unwritable one means it +# is not. Test the file mode rather than a live daemon. +reachable_socket="$TMPDIR/reachable.sock" +blocked_socket="$TMPDIR/blocked.sock" +touch "$reachable_socket" "$blocked_socket" +chmod 600 "$reachable_socket" +chmod 400 "$blocked_socket" + +run() { # SOCKET GROUPS [--configured] + env PATH="$TMPDIR/bin:$PATH" OMARCHY_DOCKER_SOCKET="$1" STUB_GROUPS="$2" USER=tester \ + bash "$command" ${3:+"$3"} +} + +# Default mode follows the socket, not the group list. +run "$blocked_socket" "wheel input" || fail "an unreachable socket means Docker needs sudo" +run "$reachable_socket" "wheel input" && fail "a reachable socket means Docker does not need sudo" +pass "default mode answers from the socket this session can reach" + +# A socket that isn't there at all still needs elevation (starting it is root work). +run "$TMPDIR/absent.sock" "wheel input docker" || fail "a missing socket means Docker needs sudo" +pass "a missing socket counts as needing sudo" + +# --configured follows the account's groups, not the socket. +run "$blocked_socket" "wheel input docker" --configured && fail "a configured docker group means no sudo is needed" +run "$reachable_socket" "wheel input" --configured || fail "no docker group means sudo is needed" +pass "--configured answers from the account's groups" + +# The window this split exists for: sudoless Docker has just been enabled, so the +# account carries the group while the running session still cannot use it. The +# menu must offer Remove (--configured says no sudo) while lazydocker and the +# Windows VM must still prompt (default says sudo). +run "$blocked_socket" "wheel input docker" || fail "the session still needs sudo before the reboot" +run "$blocked_socket" "wheel input docker" --configured && fail "the account is already configured for sudoless Docker" +pass "the two modes disagree between enabling sudoless Docker and the reboot" + +# An unknown argument is a usage error, not a silent answer either way. +run "$reachable_socket" "wheel input" --bogus 2>/dev/null && fail "an unknown flag exits non-zero" +status=0 +run "$reachable_socket" "wheel input" --bogus >/dev/null 2>&1 || status=$? +(( status == 2 )) || fail "an unknown flag exits 2, not the boolean 1" +pass "an unknown flag is a usage error" diff --git a/test/shell.d/sudoless-docker-toggle-test.sh b/test/shell.d/sudoless-docker-toggle-test.sh new file mode 100644 index 00000000..ffa18316 --- /dev/null +++ b/test/shell.d/sudoless-docker-toggle-test.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# +# Toggling sudoless Docker changes the docker group, which only takes effect on a +# reboot. The setup/remove commands must flag the reboot and offer to do it now +# (gum confirm), but defer it when OMARCHY_DEFER_REBOOT is set (the migration +# reuses them inside `omarchy update`, where omarchy-update-restart handles it). + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +home="$test_dir/home" +stub_bin="$test_dir/bin" +mkdir -p "$home" "$stub_bin" + +cat >"$stub_bin/id" <<'STUB' +#!/bin/bash +printf '%s\n' "${STUB_GROUPS:-wheel input}" +STUB +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +cat >"$stub_bin/usermod" <<'STUB' +#!/bin/bash +echo "$@" >>"${USERMOD_CALLS:?}" +STUB +cat >"$stub_bin/gpasswd" <<'STUB' +#!/bin/bash +echo "$@" >>"${GPASSWD_CALLS:?}" +STUB +cat >"$stub_bin/gum" <<'STUB' +#!/bin/bash +touch "${GUM_CALLED:?}" +exit "${GUM_ANSWER:-0}" +STUB +cat >"$stub_bin/omarchy-system-reboot" <<'STUB' +#!/bin/bash +touch "${REBOOT_CALLED:?}" +STUB +chmod +x "$stub_bin"/* + +reboot_flag="$home/.local/state/omarchy/reboot-required" +gum_called="$test_dir/gum-called" +reboot_called="$test_dir/reboot-called" +gpasswd_calls="$test_dir/gpasswd-calls" +usermod_calls="$test_dir/usermod-calls" + +run() { # command STUB_GROUPS GUM_ANSWER DEFER(0|1) + rm -f "$reboot_flag" "$gum_called" "$reboot_called" "$gpasswd_calls" "$usermod_calls" + local defer_env=() + [[ ${4:-0} == 1 ]] && defer_env=(OMARCHY_DEFER_REBOOT=1) + env HOME="$home" USER="tester" STUB_GROUPS="$2" GUM_ANSWER="$3" \ + GUM_CALLED="$gum_called" REBOOT_CALLED="$reboot_called" \ + GPASSWD_CALLS="$gpasswd_calls" USERMOD_CALLS="$usermod_calls" \ + PATH="$stub_bin:$ROOT/bin:$PATH" "${defer_env[@]}" \ + bash "$ROOT/bin/$1" >/dev/null 2>&1 +} + +# Remove, interactive, reboot confirmed -> group removed, flag set, reboot fired. +run omarchy-remove-security-sudoless-docker "wheel input docker" 0 0 +grep -q -- "-d tester docker" "$gpasswd_calls" || fail "remove drops the user from the docker group" +[[ -f $reboot_flag ]] || fail "remove flags a reboot" +[[ -f $reboot_called ]] || fail "remove reboots when the prompt is confirmed" +pass "remove drops the group, flags a reboot, and reboots on confirm" + +# Remove, interactive, reboot declined -> flag set, but no reboot. +run omarchy-remove-security-sudoless-docker "wheel input docker" 1 0 +[[ -f $reboot_flag ]] || fail "remove still flags a reboot when the prompt is declined" +[[ ! -f $reboot_called ]] || fail "remove does not reboot when the prompt is declined" +pass "remove leaves the reboot to the user when declined" + +# Remove, deferred (migration/update) -> flag set, prompt never shown. +run omarchy-remove-security-sudoless-docker "wheel input docker" 0 1 +[[ -f $reboot_flag ]] || fail "deferred remove still flags a reboot" +[[ ! -f $gum_called ]] || fail "deferred remove must not prompt to reboot" +[[ ! -f $reboot_called ]] || fail "deferred remove must not reboot" +pass "deferred remove flags the reboot without prompting" + +# Remove, already out of the group -> no-op, nothing flagged. +run omarchy-remove-security-sudoless-docker "wheel input" 0 0 +[[ ! -f $gpasswd_calls ]] || fail "remove is a no-op when the user is not in the docker group" +[[ ! -f $reboot_flag ]] || fail "remove does not flag a reboot when nothing changed" +pass "remove is a no-op when sudoless Docker is already off" + +# Setup, enable confirmed then reboot confirmed -> group added, flag set, reboot. +run omarchy-setup-security-sudoless-docker "wheel input" 0 0 +grep -q -- "-aG docker tester" "$usermod_calls" || fail "setup adds the user to the docker group" +[[ -f $reboot_flag ]] || fail "setup flags a reboot" +[[ -f $reboot_called ]] || fail "setup reboots when the prompt is confirmed" +pass "setup adds the group, flags a reboot, and reboots on confirm"