diff --git a/applications/Docker.desktop b/applications/Docker.desktop index 4f9831c0..b1852335 100644 --- a/applications/Docker.desktop +++ b/applications/Docker.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Version=1.0 Name=Docker -Exec=xdg-terminal-exec --app-id=TUI.tile -e lazydocker +Exec=xdg-terminal-exec --app-id=TUI.tile -e omarchy-launch-docker-tui Terminal=false Type=Application Icon=docker diff --git a/bin/omarchy-launch-docker-tui b/bin/omarchy-launch-docker-tui new file mode 100755 index 00000000..b31e9170 --- /dev/null +++ b/bin/omarchy-launch-docker-tui @@ -0,0 +1,18 @@ +#!/bin/bash + +# omarchy:summary=Open the Docker TUI (lazydocker) with access to the Docker daemon +# omarchy:hidden=true + +# By default the install user is NOT in the docker group: membership is +# root-equivalent (a container can bind-mount / and rewrite the host as root), +# so a single process running as the user could otherwise escalate to root with +# 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 + exec pkexec /usr/bin/env TERM="${TERM:-xterm-256color}" lazydocker +fi diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index 14d1c193..ac4b90c1 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -676,6 +676,11 @@ user_groups() { if [[ -f $PROVISIONING_DIR/groups ]]; then while IFS= read -r group; do [[ -n $group ]] || continue + # Never grant docker at first boot, even if an older install recorded it + # (or a factory snapshot predating the opt-in default carries it): the + # docker group is root-equivalent. It is opt-in via + # omarchy-setup-security-sudoless-docker. + [[ $group == "docker" ]] && continue getent group "$group" >/dev/null || continue [[ ",$groups," == *",$group,"* ]] || groups+=",$group" done <"$PROVISIONING_DIR/groups" diff --git a/bin/omarchy-remove-security-sudoless-docker b/bin/omarchy-remove-security-sudoless-docker new file mode 100755 index 00000000..fdc9e706 --- /dev/null +++ b/bin/omarchy-remove-security-sudoless-docker @@ -0,0 +1,20 @@ +#!/bin/bash + +# omarchy:summary=Disable sudoless Docker by removing your user from the docker group +# omarchy:requires-sudo=true + +set -e + +if ! id -nG "$USER" 2>/dev/null | grep -qw docker; then + echo "Sudoless Docker is not enabled: $USER is not in the docker group." + exit 0 +fi + +echo "Removing $USER from the docker group..." +sudo gpasswd -d "$USER" docker >/dev/null + +echo "" +echo "Sudoless Docker DISABLED. 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." diff --git a/bin/omarchy-setup-security-sudoless-docker b/bin/omarchy-setup-security-sudoless-docker new file mode 100755 index 00000000..38cee96f --- /dev/null +++ b/bin/omarchy-setup-security-sudoless-docker @@ -0,0 +1,37 @@ +#!/bin/bash + +# omarchy:summary=Enable sudoless Docker by adding your user to the docker group (root-equivalent!) +# omarchy:requires-sudo=true + +set -e + +if id -nG "$USER" 2>/dev/null | grep -qw docker; 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 +fi + +echo "" +echo "⚠️ WARNING: Enabling sudoless Docker adds you to the 'docker' group." +echo "" +echo "The Docker daemon runs as root, so membership in the docker group is" +echo "equivalent to passwordless root. Any process running as your user could" +echo "then run, for example:" +echo "" +echo " docker run -v /:/host alpine # full read/write of the host, as root" +echo "" +echo "and take over the machine with no password prompt. A single rogue script," +echo "dependency, or plugin running as you is enough. It is convenient for" +echo "development, but it removes the protection Omarchy keeps by default, where" +echo "Docker access goes through a polkit/sudo prompt." +echo "" + +if gum confirm "Enable sudoless Docker? This gives anything running as you passwordless root."; then + sudo usermod -aG docker "$USER" + echo "" + echo "Sudoless Docker ENABLED. 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" +else + echo "Aborted. No changes made. Docker access still goes through a prompt." +fi diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 37c09c04..e28da697 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1317,9 +1317,9 @@ apply_system_transition() { printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \ as_root tee /usr/lib/chromium/initial_preferences >/dev/null - if getent group docker >/dev/null; then - as_root usermod -aG docker "$target_user" - fi + # Deliberately do NOT add the user to the docker group. That group is + # root-equivalent, so it is opt-in now (Setup > Security > Sudoless Docker); + # re-granting it on upgrade would silently undo that default for everyone. if [[ -f /usr/bin/powerprofilesctl ]]; then as_root sed -i '/env python3/ c\#!/bin/python3' /usr/bin/powerprofilesctl || true diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 793c1969..071dc784 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -4,11 +4,368 @@ # omarchy:args= [options] # omarchy:requires-sudo=true -COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" +# The Windows VM runs a privileged container (KVM, /dev/net/tun, NET_ADMIN), so +# it needs the root-owned Docker daemon. By default the user is NOT in the docker +# group (that group is root-equivalent), so Docker access is gated behind a +# single polkit prompt. If the user opted into sudoless Docker +# (omarchy-setup-security-sudoless-docker), the socket is reachable directly and +# no prompt appears. +# +# The compose file lives in a root-owned directory and is only ever written by +# the elevated, input-validated write_compose action below. That is the whole +# point: a root-invoked `docker compose up` must never consume a file that a +# process running as the user could have rewritten to bind-mount / into the +# container. Earlier versions kept it under ~/.config/windows, which a rogue +# process could edit and then trigger a privileged bring-up of — a file-swap +# path to root. Do not move it back under $HOME. + +RUNTIME_DIR="${OMARCHY_WINDOWS_DIR:-/var/lib/omarchy/windows}" +COMPOSE_FILE="$RUNTIME_DIR/docker-compose.yml" +LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" +# The guest password lives in the root-owned compose (readable by root and the +# docker group), but RDP needs it as the user. Keep a private copy here, 0600 in +# the user's own config, so the plaintext password is never world-readable. +CREDENTIALS_FILE="$HOME/.config/windows/credentials" +IMAGE="dockurr/windows" +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; } + +# 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 +# prompt — it does NOT require the target to be root-owned. So resolve to the +# packaged command and refuse to elevate anything a non-root user could have +# written: a PATH-injected shim, or a user-owned dev checkout. Without this, a +# prompt the user grants for the trusted helper could run an attacker's binary +# as root. Fails closed (empty output) when no trustworthy target is found. +priv_target() { + local candidate owner mode + for candidate in /usr/bin/omarchy-windows-vm "$(type -P omarchy-windows-vm 2>/dev/null)"; do + [[ -n $candidate && -x $candidate ]] || continue + owner=$(stat -Lc '%u' "$candidate" 2>/dev/null) || continue + mode=$(stat -Lc '%a' "$candidate" 2>/dev/null) || continue + [[ $owner == "0" ]] || continue + ((8#$mode & 022)) && continue # writable by group or other -> reject + printf '%s\n' "$candidate" + return 0 + done + return 1 +} + +# Run a privileged VM action. write_compose always elevates (the compose is +# root-owned); the daemon operations run directly when sudoless Docker is on and +# otherwise behind a polkit prompt. The stock org.freedesktop.policykit.exec +# policy is auth_admin (not auth_admin_keep), so each elevated action prompts: +# a launch asks once to start and, unless authorization is still cached by the +# agent, again to stop. +priv() { + local action="$1" + shift + if [[ $action != write_compose ]] && in_docker_group; then + "__priv_$action" "$@" + return + fi + local target + target=$(priv_target) || { + echo "omarchy-windows-vm: refusing to run a non-root-owned command as root" >&2 + return 1 + } + pkexec "$target" __priv "$action" "$@" +} + +dc() { docker-compose -f "$COMPOSE_FILE" "$@"; } + +# --- validation (shared by the user-side prompts and the root-side writer) ---- + +valid_ram() { [[ $1 =~ ^[0-9]{1,3}G$ ]]; } +valid_cores() { [[ $1 =~ ^[0-9]{1,2}$ ]] && ((10#$1 >= 1)); } +valid_disk() { [[ $1 =~ ^[0-9]{1,4}G$ ]]; } +valid_username() { [[ $1 =~ ^[A-Za-z0-9_-]{1,20}$ ]]; } +valid_tz() { [[ $1 =~ ^[A-Za-z0-9_/.+-]{1,64}$ ]]; } +valid_path() { + [[ $1 =~ ^/[A-Za-z0-9._/-]+$ ]] || return 1 + # Reject non-normalized paths: a . or .. component canonicalizes at mount time + # (e.g. /./ or /a/../ -> /), which would bind-mount a sensitive directory — + # host / included — into the guest. Volumes must be given already-normalized. + case "$1" in + *//* | */./* | */../* | */. | */..) return 1 ;; + esac + return 0 +} +valid_password() { [[ $1 =~ ^[[:print:]]{1,64}$ ]]; } + +# The only privileged sub-actions __priv may dispatch. A bash command name +# containing a slash is executed as a path, so validating the action here — not +# just interpolating it into "__priv_${action}" — is what stops an action like +# ../tmp/evil from running an arbitrary file as root. +valid_priv_action() { + case "$1" in + write_compose | up | up_wait | down | status | remove) return 0 ;; + *) return 1 ;; + esac +} + +# --- privileged actions (run as root via pkexec, or directly when sudoless) --- + +# Reads KEY=VALUE lines on stdin, re-validates every field, and writes the +# compose atomically as root. Re-validation here is the security boundary: the +# writer refuses rather than emit a compose an attacker could have influenced. +# Only these fixed keys are honored; image, container name, devices, caps, and +# port bindings are hard-coded and never taken from input. +__priv_write_compose() { + local ram cores disk username password tz storage shared key value + + while IFS='=' read -r key value; do + case "$key" in + RAM) ram="$value" ;; + CORES) cores="$value" ;; + DISK) disk="$value" ;; + USERNAME) username="$value" ;; + PASSWORD) password="$value" ;; + TZ) tz="$value" ;; + STORAGE) storage="$value" ;; + SHARED) shared="$value" ;; + esac + done + + valid_ram "$ram" || { echo "invalid RAM: $ram" >&2; exit 2; } + valid_cores "$cores" || { echo "invalid CPU cores: $cores" >&2; exit 2; } + valid_disk "$disk" || { echo "invalid disk size: $disk" >&2; exit 2; } + valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } + valid_password "$password" || { echo "invalid password" >&2; exit 2; } + valid_tz "$tz" || tz="UTC" + valid_path "$storage" || { echo "invalid storage path: $storage" >&2; exit 2; } + valid_path "$shared" || { echo "invalid shared path: $shared" >&2; exit 2; } + + # Neutralize anything in the password that could be misread when the compose + # is parsed. Two layers apply, in this order at parse time: docker compose + # variable interpolation over the raw text ($VAR / $$), then YAML parsing of + # the double-quoted scalar. Encode for the inner layer first (backslash, then + # double-quote) and the interpolation layer last ($ -> $$), so a password + # containing " \ or $ reaches the guest verbatim. unescape() reverses this in + # the opposite order for the RDP credentials. + local esc_password=${password//\\/\\\\} + esc_password=${esc_password//\"/\\\"} + esc_password=${esc_password//\$/\$\$} + + mkdir -p "$RUNTIME_DIR" + chmod 0755 "$RUNTIME_DIR" 2>/dev/null || true + chown root:root "$RUNTIME_DIR" 2>/dev/null || true + + local tmp + tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") + cat >"$tmp" </dev/null || true + chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" 2>/dev/null || true + mv -f "$tmp" "$COMPOSE_FILE" +} + +# Read the host source of a bind mount out of the compose (e.g. /storage). +# valid_path kept a ':' out of the stored path, so splitting on it is safe. +get_mount_source() { + sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1 +} + +# Refuse to bring the VM up if a bind-mount source is a symlink, or reached +# through one. valid_path keeps a traversal string like /./ out of the compose, +# but a symlink planted at ~/.windows or ~/Windows would redirect the privileged +# mount just the same — docker follows it — and a string check cannot see that. +# So verify the real directories here, as root, immediately before the mount. A +# source that does not exist is fine: docker creates it as a plain directory. +assert_mounts_safe() { + local mnt src real + for mnt in /storage /shared; do + src=$(get_mount_source "$mnt") + [[ -n $src ]] || { + echo "omarchy-windows-vm: missing $mnt mount source in the compose" >&2 + return 1 + } + if [[ -L $src ]]; then + echo "omarchy-windows-vm: refusing to start — $src is a symlink; the VM mount source must be a real directory" >&2 + return 1 + fi + if [[ -e $src ]]; then + [[ -d $src ]] || { + echo "omarchy-windows-vm: refusing to start — $src is not a directory" >&2 + return 1 + } + real=$(realpath "$src" 2>/dev/null) + [[ $real == "$src" ]] || { + echo "omarchy-windows-vm: refusing to start — $src resolves through a symlink to $real" >&2 + return 1 + } + fi + done +} + +__priv_up() { assert_mounts_safe && dc up -d; } + +__priv_down() { dc down; } + +# Bring the VM up and wait until the guest reports it is ready, all under a +# single elevation so the readiness poll does not prompt on every iteration. +__priv_up_wait() { + assert_mounts_safe || return 1 + local status + status=$(docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null) + if [[ $status != "running" ]]; then + dc up -d || return 1 + fi + + # docker logs persists across restarts, so anchor the scan to the current + # start time; an empty --since would match a stale "started successfully". + local started_at count=0 + while true; do + started_at=$(docker inspect --format='{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null) + if [[ -n $started_at ]] && docker logs --since "$started_at" "$CONTAINER" 2>&1 | grep -qi "windows started successfully"; then + return 0 + fi + sleep 2 + ((++count > 60)) && { + echo "Timeout: Windows VM did not report ready within 2 minutes" >&2 + return 1 + } + done +} + +# Print the status (empty if the container does not exist) and always succeed, +# so a non-zero exit from priv status means the elevation itself failed +# (authorization declined) rather than "no such container". +__priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; } + +__priv_remove() { + dc down 2>/dev/null || true + docker rmi "$IMAGE" 2>/dev/null || true + rm -f "$COMPOSE_FILE" + rmdir "$RUNTIME_DIR" 2>/dev/null || true +} + +# --- config helpers ---------------------------------------------------------- + +# Feed the collected settings to the elevated writer. +write_compose() { + local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" storage="$7" shared="$8" + printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \ + "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared" | + priv write_compose +} + +# Reverse, in the opposite order, the escaping __priv_write_compose applied to +# the password: undo the interpolation layer ($$ -> $) first, then the YAML +# layer (\" -> ", then \\ -> \). +unescape() { + local v=$1 + v=${v//\$\$/\$} + v=${v//\\\"/\"} + v=${v//\\\\/\\} + printf '%s' "$v" +} + +# Store the RDP credentials privately for the user (0600) so the plaintext +# password is not world-readable. The password is one validated printable line +# (no newline), so plain KEY=VALUE is safe. +write_credentials() { + local username="$1" password="$2" old_umask + mkdir -p "$(dirname "$CREDENTIALS_FILE")" + old_umask=$(umask) + umask 077 + printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$CREDENTIALS_FILE" + chmod 600 "$CREDENTIALS_FILE" 2>/dev/null || true + umask "$old_umask" +} + +# Read one field from the private credentials file; IFS on the first = keeps a +# password that itself contains =. +read_credential() { + local want="$1" key value + [[ -f $CREDENTIALS_FILE ]] || return 1 + while IFS='=' read -r key value; do + [[ $key == "$want" ]] && { + printf '%s' "$value" + return 0 + } + done <"$CREDENTIALS_FILE" + return 1 +} + +read_compose_value() { + local key="$1" file="$2" + sed -n "s/.*${key}: \"\(.*\)\"/\1/p" "$file" | head -n1 +} + +# Older installs kept the compose under ~/.config/windows. Carry those settings +# into the root-owned location (preserving the VM's data via the same volume +# paths) so an upgrade does not strand or re-download an existing VM. +migrate_legacy_compose() { + [[ -f $COMPOSE_FILE ]] && return 0 + [[ -f $LEGACY_COMPOSE_FILE ]] || return 1 + + echo "Migrating Windows VM configuration to $COMPOSE_FILE ..." + local ram cores disk username password tz storage shared + ram=$(read_compose_value RAM_SIZE "$LEGACY_COMPOSE_FILE") + cores=$(read_compose_value CPU_CORES "$LEGACY_COMPOSE_FILE") + disk=$(read_compose_value DISK_SIZE "$LEGACY_COMPOSE_FILE") + username=$(read_compose_value USERNAME "$LEGACY_COMPOSE_FILE") + password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE") + tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE") + # The VM's data always lived in the user's own ~/.windows and ~/Windows; the + # old compose only ever recorded those. Reconstruct them from $HOME (trusted — + # this runs as the user) rather than reading host paths back from a file a + # rogue process could have rewritten to bind-mount, say, / into the guest. + storage="$HOME/.windows" + shared="$HOME/Windows" + + [[ -z $tz ]] && tz="UTC" + if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared"; then + echo "Could not migrate the existing configuration automatically." >&2 + echo "Re-run: omarchy-windows-vm install" >&2 + return 1 + fi + write_credentials "$username" "$password" + rm -f "$LEGACY_COMPOSE_FILE" +} + +# --- prerequisites ----------------------------------------------------------- check_prerequisites() { local DISK_SIZE_GB=${1:-64} - local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead + local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead # Check for KVM support if [[ ! -e /dev/kvm ]]; then @@ -26,7 +383,7 @@ check_prerequisites() { # Check disk space AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') - if (( AVAILABLE_SPACE < REQUIRED_SPACE )); then + if ((AVAILABLE_SPACE < REQUIRED_SPACE)); then echo "❌ Insufficient disk space!" echo " Available: ${AVAILABLE_SPACE}GB" echo " Required: ${REQUIRED_SPACE}GB (${DISK_SIZE_GB}GB disk + 10GB for Windows image)" @@ -34,6 +391,8 @@ check_prerequisites() { fi } +# --- commands ---------------------------------------------------------------- + install_windows() { # Set up trap to handle Ctrl+C trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT @@ -43,10 +402,9 @@ install_windows() { omarchy-pkg-add freerdp openbsd-netcat gum mkdir -p "$HOME/.windows" - mkdir -p "$HOME/.config/windows" mkdir -p "$HOME/.local/share/applications" - cat << EOF | tee "$HOME/.local/share/applications/windows-vm.desktop" > /dev/null + cat </dev/null [Desktop Entry] Name=Windows Comment=Start Windows VM via Docker and connect with RDP @@ -70,7 +428,7 @@ EOF RAM_OPTIONS="" for size in 2 4 8 16 32 64; do - if (( size <= TOTAL_RAM_GB )); then + if ((size <= TOTAL_RAM_GB)); then RAM_OPTIONS="$RAM_OPTIONS ${size}G" fi done @@ -91,16 +449,16 @@ EOF exit 1 fi - if ! [[ $SELECTED_CORES =~ ^[0-9]+$ ]] || (( SELECTED_CORES < 1 )) || (( SELECTED_CORES > TOTAL_CORES )); then + if ! valid_cores "$SELECTED_CORES" || ((SELECTED_CORES > TOTAL_CORES)); then echo "Invalid input. Using default: 2 cores" SELECTED_CORES=2 fi AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}') - MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image + MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image # Check if we have enough space for minimum - if (( MAX_DISK_GB < 32 )); then + if ((MAX_DISK_GB < 32)); then echo "❌ Insufficient disk space for Windows VM!" echo " Available: ${AVAILABLE_SPACE}GB" echo " Minimum required: 42GB (32GB disk + 10GB for Windows image)" @@ -109,7 +467,7 @@ EOF DISK_OPTIONS="" for size in 32 64 128 256 512; do - if (( size <= MAX_DISK_GB )); then + if ((size <= MAX_DISK_GB)); then DISK_OPTIONS="$DISK_OPTIONS ${size}G" fi done @@ -139,6 +497,10 @@ EOF if [[ -z $USERNAME ]]; then USERNAME="docker" fi + if ! valid_username "$USERNAME"; then + echo "Invalid username (use letters, digits, - or _, up to 20 chars). Using default: docker" + USERNAME="docker" + fi PASSWORD=$(gum input --placeholder="Password (Press enter to use default: admin)" --password --header="Enter Windows password:") if [[ -z $PASSWORD ]]; then @@ -147,6 +509,11 @@ EOF else PASSWORD_DISPLAY="(user-defined)" fi + if ! valid_password "$PASSWORD"; then + echo "Invalid password (printable characters, up to 64). Using default: admin" + PASSWORD="admin" + PASSWORD_DISPLAY="(default)" + fi # Display configuration summary gum style \ @@ -170,38 +537,19 @@ EOF exit 1 fi - mkdir -p $HOME/Windows + mkdir -p "$HOME/Windows" - # Create docker-compose.yml in user config directory - cat << EOF | tee "$COMPOSE_FILE" > /dev/null -services: - windows: - image: dockurr/windows - container_name: omarchy-windows - environment: - VERSION: "11" - RAM_SIZE: "$SELECTED_RAM" - CPU_CORES: "$SELECTED_CORES" - DISK_SIZE: "$SELECTED_DISK" - USERNAME: "$USERNAME" - PASSWORD: "$PASSWORD" - TZ: "$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC)" - ARGUMENTS: "-rtc base=localtime,clock=host,driftfix=slew" - devices: - - /dev/kvm - - /dev/net/tun - cap_add: - - NET_ADMIN - ports: - - 127.0.0.1:8006:8006 - - 127.0.0.1:3389:3389/tcp - - 127.0.0.1:3389:3389/udp - volumes: - - $HOME/.windows:/storage - - $HOME/Windows:/shared - restart: "no" - stop_grace_period: 2m -EOF + local tz + tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC) + + # Write the root-owned compose from the validated settings (one prompt if + # sudoless Docker is off), then bring the stack up. + write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \ + "$USERNAME" "$PASSWORD" "$tz" "$HOME/.windows" "$HOME/Windows" || { + echo "❌ Failed to write the Windows VM configuration." + exit 1 + } + write_credentials "$USERNAME" "$PASSWORD" echo "" echo "Starting Windows VM installation..." @@ -210,14 +558,12 @@ EOF echo "Monitor installation progress at: http://127.0.0.1:8006" echo "" - # Start docker-compose with user's config echo "Starting Windows VM with docker-compose..." - if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then + if ! priv up; then echo "❌ Failed to start Windows VM!" echo " Common issues:" echo " - Docker daemon not running: sudo systemctl start docker" echo " - Port already in use: check if another VM is running" - echo " - Permission issues: make sure you're in the docker group" exit 1 fi @@ -237,7 +583,6 @@ EOF echo "Once finished, launch 'Windows' via Super + Space" echo "" echo "To stop the VM: omarchy-windows-vm stop" - echo "To change resources: ~/.config/windows/docker-compose.yml" echo "" } @@ -249,11 +594,12 @@ remove_windows() { echo "Removing Windows VM..." - docker-compose -f "$COMPOSE_FILE" down 2>/dev/null || true + migrate_legacy_compose 2>/dev/null || true + if [[ -f $COMPOSE_FILE ]]; then + priv remove || true + fi - docker rmi dockurr/windows 2>/dev/null || echo "Image already removed or not found" - - rm "$HOME/.local/share/applications/windows-vm.desktop" + rm -f "$HOME/.local/share/applications/windows-vm.desktop" rm -rf "$HOME/.config/windows" rm -rf "$HOME/.windows" @@ -267,61 +613,32 @@ launch_windows() { KEEP_ALIVE=true fi - # Check if config exists - if [[ ! -f $COMPOSE_FILE ]]; then - echo "Windows VM not configured. Please run: omarchy-windows-vm install" - exit 1 + if ! migrate_legacy_compose; then + if [[ ! -f $COMPOSE_FILE ]]; then + echo "Windows VM not configured. Please run: omarchy-windows-vm install" + exit 1 + fi fi - # Extract credentials from compose file - WIN_USER=$(grep "USERNAME:" "$COMPOSE_FILE" | sed 's/.*USERNAME: "\(.*\)"/\1/') - WIN_PASS=$(grep "PASSWORD:" "$COMPOSE_FILE" | sed 's/.*PASSWORD: "\(.*\)"/\1/') - - # Use defaults if not found + # RDP credentials come from the private per-user file. Fall back to the compose + # only when it is readable (sudoless mode), reversing the writer's escaping. + WIN_USER=$(read_credential USERNAME) || WIN_USER="" + WIN_PASS=$(read_credential PASSWORD) || WIN_PASS="" + if [[ -z $WIN_USER || -z $WIN_PASS ]] && [[ -r $COMPOSE_FILE ]]; then + [[ -z $WIN_USER ]] && WIN_USER=$(unescape "$(read_compose_value USERNAME "$COMPOSE_FILE")") + [[ -z $WIN_PASS ]] && WIN_PASS=$(unescape "$(read_compose_value PASSWORD "$COMPOSE_FILE")") + fi [[ -z $WIN_USER ]] && WIN_USER="docker" [[ -z $WIN_PASS ]] && WIN_PASS="admin" - # Check if container is already running - CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null) - - if [[ $CONTAINER_STATUS != "running" ]]; then - echo "Starting Windows VM..." - - if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then - echo "❌ Failed to start Windows VM!" - echo " Try checking: omarchy-windows-vm status" - echo " View logs: docker logs omarchy-windows" - omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM" - exit 1 - fi + echo "Starting Windows VM (this may prompt for authorization)..." + if ! priv up_wait; then + echo "❌ Failed to start Windows VM!" + echo " Try checking: omarchy-windows-vm status" + omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM" + exit 1 fi - # docker logs keeps output across stop/start and is only cleared when the - # container is removed, so an unanchored grep matches "started successfully" - # from an earlier boot and returns immediately. Anchor the scan to the current - # container start, and run it even when the container was already up: the - # image restarts the guest in place whenever Windows reboots. - windows_started() { - local started_at - started_at=$(docker inspect --format='{{.State.StartedAt}}' omarchy-windows 2>/dev/null) - # An empty --since would drop the filter and match a stale boot again - [[ -n $started_at ]] || return 1 - docker logs --since "$started_at" omarchy-windows 2>&1 | grep -qi "windows started successfully" - } - - echo "Waiting for Windows VM to start..." - WAIT_COUNT=0 - until windows_started; do - sleep 2 - WAIT_COUNT=$((WAIT_COUNT + 1)) - if (( WAIT_COUNT > 60 )); then # 2 minutes timeout - echo "" - echo "❌ Timeout: Windows VM failed to start within 2 minutes" - echo " Check logs: docker logs omarchy-windows" - exit 1 - fi - done - # Build the connection info if [[ $KEEP_ALIVE = "true" ]]; then LIFECYCLE="VM will keep running after RDP closes @@ -345,7 +662,8 @@ To stop: omarchy-windows-vm stop" # network each attempt blocks ~23s and no RDP window is ever drawn. The VM # authenticates against a local Windows account, so point FreeRDP at a # realm-less config and let it fall straight through to NTLM. - KRB5_CONF="$(dirname "$COMPOSE_FILE")/krb5.conf" + KRB5_CONF="$HOME/.config/windows/krb5.conf" + mkdir -p "$(dirname "$KRB5_CONF")" if [[ ! -f $KRB5_CONF ]]; then printf '[libdefaults]\n dns_lookup_kdc = false\n dns_lookup_realm = false\n' >"$KRB5_CONF" fi @@ -356,9 +674,9 @@ To stop: omarchy-windows-vm stop" SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}') RDP_SCALE="" - if (( SCALE_PERCENT >= 170 )); then + if ((SCALE_PERCENT >= 170)); then RDP_SCALE="/scale:180" - elif (( SCALE_PERCENT >= 130 )); then + elif ((SCALE_PERCENT >= 130)); then RDP_SCALE="/scale:140" fi # If scale is less than 130%, don't set any scale (use default 100) @@ -370,8 +688,12 @@ To stop: omarchy-windows-vm stop" if [[ $KEEP_ALIVE = "false" ]]; then echo "" echo "RDP session closed. Stopping Windows VM..." - docker-compose -f "$COMPOSE_FILE" down - echo "Windows VM stopped." + if priv down; then + echo "Windows VM stopped." + else + echo "⚠️ Could not stop the Windows VM (authorization declined?)." + echo " It may still be running. Stop it with: omarchy-windows-vm stop" + fi else echo "" echo "RDP session closed. Windows VM is still running." @@ -380,24 +702,34 @@ To stop: omarchy-windows-vm stop" } stop_windows() { + migrate_legacy_compose 2>/dev/null || true if [[ ! -f $COMPOSE_FILE ]]; then echo "Windows VM not configured." exit 1 fi echo "Stopping Windows VM..." - docker-compose -f "$COMPOSE_FILE" down - echo "Windows VM stopped." + if priv down; then + echo "Windows VM stopped." + else + echo "⚠️ Could not stop the Windows VM (authorization declined?). It may still be running." + exit 1 + fi } status_windows() { + migrate_legacy_compose 2>/dev/null || true if [[ ! -f $COMPOSE_FILE ]]; then echo "Windows VM not configured." echo "To set up: omarchy-windows-vm install" exit 1 fi - CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null) + if ! CONTAINER_STATUS=$(priv status); then + echo "Could not query the Windows VM (authorization declined?)." + echo "To try again: omarchy-windows-vm status" + exit 1 + fi if [[ -z $CONTAINER_STATUS ]]; then echo "Windows VM container not found." @@ -443,28 +775,41 @@ show_usage() { # Main command dispatcher case "$1" in - install) - install_windows - ;; - remove) - remove_windows - ;; - launch|start) - launch_windows "$2" - ;; - stop|down) - stop_windows - ;; - status) - status_windows - ;; - help|--help|-h|"") - show_usage - ;; - *) - echo "Unknown command: $1" >&2 - echo "" >&2 - show_usage >&2 +__priv) + ((EUID == 0)) || { + echo "omarchy-windows-vm __priv must run as root" >&2 exit 1 - ;; + } + action="$2" + shift 2 + valid_priv_action "$action" || { + echo "omarchy-windows-vm: unknown privileged action" >&2 + exit 1 + } + "__priv_${action}" "$@" + ;; +install) + install_windows + ;; +remove) + remove_windows + ;; +launch | start) + launch_windows "$2" + ;; +stop | down) + stop_windows + ;; +status) + status_windows + ;; +help | --help | -h | "") + show_usage + ;; +*) + echo "Unknown command: $1" >&2 + echo "" >&2 + show_usage >&2 + exit 1 + ;; esac diff --git a/default/hypr/bindings/applications.lua b/default/hypr/bindings/applications.lua index 151a3fea..0c990e64 100644 --- a/default/hypr/bindings/applications.lua +++ b/default/hypr/bindings/applications.lua @@ -13,7 +13,7 @@ if o.preinstalled_bindings_enabled() then o.bind("SUPER + CTRL + RETURN", "Herdr", { omarchy = "terminal-herdr" }) o.bind("SUPER + SHIFT + M", "Music", { omarchy = "spotify" }) o.bind("SUPER + SHIFT + ALT + M", "Music TUI", { tui = "cliamp", focus = true }) - o.bind("SUPER + SHIFT + D", "Docker", { tui = "lazydocker" }) + o.bind("SUPER + SHIFT + D", "Docker", { tui = "omarchy-launch-docker-tui" }) o.bind("SUPER + SHIFT + G", "Signal", { omarchy = "signal" }) o.bind("SUPER + SHIFT + O", "Obsidian", { launch = "obsidian", focus = "^obsidian$" }) o.bind("SUPER + SHIFT + W", "Omawrite", { launch = "omawrite" }) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 3a24759b..b1cfcf7b 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -179,6 +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.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"}, @@ -290,6 +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.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/install/config/docker.sh b/install/config/docker.sh index 1dc17e81..39743905 100644 --- a/install/config/docker.sh +++ b/install/config/docker.sh @@ -1,10 +1,16 @@ -# Record the docker group for provisioning first-boot user creation and factory reset, -# then grant it directly when the install user already exists (deferred-provisioning -# installs create the user at first boot instead). -provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}" -mkdir -p "$provisioning_dir" -grep -qxF docker "$provisioning_dir/groups" 2>/dev/null || echo docker >>"$provisioning_dir/groups" - -if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then - usermod -aG docker "$OMARCHY_INSTALL_USER" -fi +# The Docker daemon runs as root and its socket is root-owned, so membership in +# the docker group is equivalent to passwordless root: any process in it can +# `docker run -v /:/host` and rewrite the host as root. We therefore do NOT add +# the install user to the docker group by default, so a single rogue process +# running as the user cannot silently escalate to root. +# +# The daemon is still enabled (docker.socket, in enable-services.sh) for system +# use. The Docker TUI (Super + Shift + D) and the Windows VM reach it through a +# polkit prompt, and the plain `docker` CLI runs under sudo. Users who want the +# convenience back can opt in, behind a warning, with: +# +# omarchy-setup-security-sudoless-docker (Setup > Security > Sudoless Docker) +# +# Nothing to do here now that the group is no longer granted, but the file stays +# as the recorded home of this decision and a hook for future daemon config. +: diff --git a/manual/18-development-tools.md b/manual/18-development-tools.md index a8bb3282..59ae9198 100644 --- a/manual/18-development-tools.md +++ b/manual/18-development-tools.md @@ -18,9 +18,11 @@ To install, say, Ruby, you'd run `mise use -g ruby`, which will both install Rub ## Docker -[Docker](https://www.docker.com/) hardly needs any introduction. It allows you to run isolated containers, and Omarchy installs everything needed to run it well. This includes Docker itself, [Docker Compose](https://docs.docker.com/compose/), and the user group changes needed for you to run Docker as the normal user and not as root. +[Docker](https://www.docker.com/) hardly needs any introduction. It allows you to run isolated containers, and Omarchy installs everything needed to run it well, including Docker itself and [Docker Compose](https://docs.docker.com/compose/). -Remember to checkout the Lazydocker command to manage your containers in a cool TUI using `Super + Shift + D`. +By default your user is *not* in the `docker` group. That group is effectively passwordless root — anything in it can `docker run -v /:/host` and take over the machine — so a single rogue script or dependency running as you would otherwise be one command away from root. So on the command line you run Docker with `sudo` (`sudo docker ps`, `sudo docker compose up`), and the graphical tools that talk to the daemon — the Docker TUI on `Super + Shift + D` and the Windows VM — ask for authorization when they need it. If you want the convenience of a groupless setup back and understand the tradeoff, enable it from **Setup > Security > Sudoless Docker** (or run `omarchy-setup-security-sudoless-docker`), which adds you to the `docker` group after a warning; then plain `docker` and the `d` alias work without `sudo` again. + +Remember to checkout the Lazydocker command to manage your containers in a cool TUI using `Super + Shift + D`; it asks for authorization the first time unless you have enabled sudoless Docker. You can setup the common databases for local development in Docker using _Install > Development > Docker DB_ in the Omarchy menu. diff --git a/manual/28-windows-vm.md b/manual/28-windows-vm.md index 128f0e16..9da7786f 100644 --- a/manual/28-windows-vm.md +++ b/manual/28-windows-vm.md @@ -38,6 +38,6 @@ The version installed is Windows 11 Pro, unactivated. You'll need your own licen If this computer shipped with Windows, the OEM key is still in firmware even after installing Omarchy. Print it with `omarchy windows key`. That key is bound to this machine — it will activate Windows reinstalled on this hardware, but it usually will not activate the VM. -You can change the resource allocation later by editing `~/.config/windows/docker-compose.yml`, which is also where you'd mount a USB device. See all the options on [the Dockur Windows project](https://github.com/dockur/windows). +You can change the resource allocation later by re-running `omarchy-windows-vm install`, which rewrites the VM's configuration from your answers. The compose file itself now lives at `/var/lib/omarchy/windows/docker-compose.yml` and is owned by root — that is deliberate, so a process running as you cannot rewrite it and have the privileged bring-up mount your whole disk into the container. If you need to hand-edit it (for example to mount a USB device), edit it with `sudo` and see all the options on [the Dockur Windows project](https://github.com/dockur/windows). To get rid of the whole thing, use _Remove > Windows_ from the Omarchy menu. That deletes the VM's disk and all its data, so make sure anything you care about is out of `~/Windows` first. diff --git a/migrations/1787580187.sh b/migrations/1787580187.sh new file mode 100644 index 00000000..54ac1e9c --- /dev/null +++ b/migrations/1787580187.sh @@ -0,0 +1,20 @@ +echo "Move this install to the opt-in docker group default (the group is root-equivalent)" + +# 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 +# Setup > Security > Sudoless Docker. Reuses the removal command so there is one +# source of truth for the privileged change and its notice. +if id -nG "$USER" | grep -qw docker; then + omarchy-remove-security-sudoless-docker +fi + +# The Docker app entry copied into ~/.local/share/applications used to run +# lazydocker directly; it now needs the wrapper that prompts for daemon access +# (or runs directly under sudoless Docker). Refresh just that file. +dest="$HOME/.local/share/applications/Docker.desktop" +if [[ -f $dest ]]; then + cp "$OMARCHY_PATH/applications/Docker.desktop" "$dest" +fi diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index e8e4944e..53b2c1d7 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -65,8 +65,20 @@ verify_services() { } verify_runtime_tools() { - timeout 20 docker info >/dev/null 2>&1 || fail "Docker is usable by the desktop user" - pass "Docker is usable by the desktop user" + # Docker access is intentionally NOT granted to the desktop user: the docker + # group is root-equivalent, so a rogue process running as the user could + # otherwise `docker run -v /:/host` its way to passwordless root. The daemon is + # still enabled (docker.socket, checked in verify_services) and reached through + # a polkit/sudo prompt; opting into sudoless Docker is a separate, warned step. + command -v docker >/dev/null 2>&1 || fail "Docker CLI is installed" + ! id -nG | grep -qw docker || fail "desktop user must not be in the docker group" + # The group name being absent is not sufficient — a world-writable socket or an + # ACL would still hand the user the root daemon. Prove it is actually + # unreachable without elevation. + if timeout 10 docker info >/dev/null 2>&1; then + fail "desktop user must not reach the Docker daemon without elevation" + fi + pass "Docker is installed but unreachable by the desktop user without elevation" nvim --headless '+qa' >/dev/null 2>&1 || fail "Neovim starts headlessly" pass "Neovim starts headlessly" diff --git a/test/shell.d/docker-group-migration-test.sh b/test/shell.d/docker-group-migration-test.sh new file mode 100644 index 00000000..3d2f5a99 --- /dev/null +++ b/test/shell.d/docker-group-migration-test.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# +# The docker-group opt-in migration must remove an existing install's user from +# the root-equivalent docker group (only when they are in it), refresh the stale +# Docker launcher entry, and stay idempotent on reruns. + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +migration="$ROOT/migrations/1787580187.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +home="$test_dir/home" +omarchy_path="$test_dir/omarchy" +stub_bin="$test_dir/bin" +mkdir -p "$home/.local/share/applications" "$omarchy_path/applications" "$stub_bin" + +# The packaged (new) launcher entry the migration should copy over the stale one. +printf 'NEW-LAUNCHER\n' >"$omarchy_path/applications/Docker.desktop" +printf 'OLD-LAUNCHER\n' >"$home/.local/share/applications/Docker.desktop" + +# Stub id to report a controllable group set, and the removal command to record +# that it was called instead of touching the real system. +cat >"$stub_bin/id" <<'STUB' +#!/bin/bash +# Only the migration's `id -nG "$USER"` needs answering here. +printf '%s\n' "${STUB_GROUPS:-wheel input}" +STUB +cat >"$stub_bin/omarchy-remove-security-sudoless-docker" <<'STUB' +#!/bin/bash +touch "${REMOVE_CALLED:?}" +STUB +chmod +x "$stub_bin/id" "$stub_bin/omarchy-remove-security-sudoless-docker" + +remove_called="$test_dir/remove-called" +run_migration() { + rm -f "$remove_called" + HOME="$home" OMARCHY_PATH="$omarchy_path" USER="tester" \ + STUB_GROUPS="$1" REMOVE_CALLED="$remove_called" \ + PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 +} + +# In the docker group: the user is removed and the launcher is refreshed. +run_migration "wheel input docker" || fail "migration runs when the user is in the docker group" +[[ -e $remove_called ]] || fail "migration removes a user who is in the docker group" +[[ $(cat "$home/.local/share/applications/Docker.desktop") == "NEW-LAUNCHER" ]] || + fail "migration refreshes the stale Docker launcher entry" +pass "migration removes the docker group and refreshes the launcher" + +# Not in the docker group (fresh install, or already migrated): no removal. +printf 'OLD-LAUNCHER\n' >"$home/.local/share/applications/Docker.desktop" +run_migration "wheel input" || fail "migration runs when the user is not in the docker group" +[[ ! -e $remove_called ]] || fail "migration must not call the removal when the user is not in the docker group" +[[ $(cat "$home/.local/share/applications/Docker.desktop") == "NEW-LAUNCHER" ]] || + fail "migration still refreshes the launcher when the group is already absent" +pass "migration is a no-op on the group when it is already absent" + +# No launcher entry present: the refresh is skipped without error. +rm -f "$home/.local/share/applications/Docker.desktop" +run_migration "wheel input" || fail "migration tolerates a missing launcher entry" +[[ ! -e $home/.local/share/applications/Docker.desktop ]] || + fail "migration does not create a launcher entry that was not there" +pass "migration skips the launcher refresh when no entry exists" diff --git a/test/shell.d/provisioning-groups-test.sh b/test/shell.d/provisioning-groups-test.sh index 23a8ef3a..d0eac226 100644 --- a/test/shell.d/provisioning-groups-test.sh +++ b/test/shell.d/provisioning-groups-test.sh @@ -3,6 +3,9 @@ # The install scripts that grant group memberships must record them in the provisioning # groups file (for first-boot user creation and factory reset) and only call # usermod when the install user actually exists. +# +# Docker is deliberately excluded: the docker group is root-equivalent, so it is +# no longer granted at install time (opt in with omarchy-setup-security-sudoless-docker). set -euo pipefail @@ -15,41 +18,44 @@ export OMARCHY_PROVISIONING_DIR="$TMPDIR/provisioning" # Stub getent/usermod: the fake system knows only the user "existing". mkdir -p "$TMPDIR/bin" -cat >"$TMPDIR/bin/getent" <<'EOF' +cat >"$TMPDIR/bin/getent" <<'STUB' #!/bin/bash [[ $1 == passwd && $2 == existing ]] && { echo "existing:x:1000:1000::/home/existing:/bin/bash"; exit 0; } exit 2 -EOF -cat >"$TMPDIR/bin/usermod" <"$TMPDIR/bin/usermod" <>"$TMPDIR/usermod.calls" -EOF +STUB chmod +x "$TMPDIR/bin/getent" "$TMPDIR/bin/usermod" export PATH="$TMPDIR/bin:$PATH" -# No install user (deferred-provisioning install): groups recorded, usermod not called. +# No install user (deferred-provisioning install): input recorded, usermod not called. OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" [[ -f $OMARCHY_PROVISIONING_DIR/groups ]] || fail "groups file written without an install user" -grep -qxF docker "$OMARCHY_PROVISIONING_DIR/groups" || fail "docker group recorded" grep -qxF input "$OMARCHY_PROVISIONING_DIR/groups" || fail "input group recorded" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called without an install user" pass "deferred provisioning records groups without calling usermod" +# The docker group is root-equivalent and must never be granted automatically. +! grep -qxF docker "$OMARCHY_PROVISIONING_DIR/groups" || fail "docker group must not be recorded" +pass "docker group is not recorded at install" + # Missing user (defensive): no usermod either. -OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/config/docker.sh" +OMARCHY_INSTALL_USER=ghost bash -eE "$ROOT/install/hardware/input-group.sh" [[ ! -f $TMPDIR/usermod.calls ]] || fail "usermod not called for a missing user" pass "missing install user defers group grants" # Re-running never duplicates entries. -OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/config/docker.sh" -[[ $(grep -cxF docker "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "docker group recorded once" +OMARCHY_INSTALL_USER="" bash -eE "$ROOT/install/hardware/input-group.sh" +[[ $(grep -cxF input "$OMARCHY_PROVISIONING_DIR/groups") == 1 ]] || fail "input group recorded once" pass "group recording is idempotent" -# Existing user: usermod applies the groups and the record still lands. +# Existing user: usermod applies the recorded groups, and docker is never among them. OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/config/docker.sh" OMARCHY_INSTALL_USER=existing bash -eE "$ROOT/install/hardware/input-group.sh" -grep -qx -- "-aG docker existing" "$TMPDIR/usermod.calls" || fail "usermod grants docker to the install user" grep -qx -- "-aG input existing" "$TMPDIR/usermod.calls" || fail "usermod grants input to the install user" -pass "existing install user still gets direct group grants" +! grep -q -- "docker" "$TMPDIR/usermod.calls" || fail "usermod must not grant docker to the install user" +pass "existing install user gets input but never docker" diff --git a/test/shell.d/sudoless-docker-posture-test.sh b/test/shell.d/sudoless-docker-posture-test.sh new file mode 100644 index 00000000..8b13b31a --- /dev/null +++ b/test/shell.d/sudoless-docker-posture-test.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# +# The docker group is root-equivalent, so no automatic path may grant it. These +# tests guard the paths that are not exercised by a fresh-install run: first-boot +# provisioning replaying a recorded (or factory-snapshot) group list, and the +# Quattro upgrade. Opting in stays a deliberate, warned step +# (omarchy-setup-security-sudoless-docker). + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# First-boot provisioning must never grant docker even when it is recorded (an +# older install, or a factory snapshot predating the opt-in default). +mkdir -p "$TMPDIR/bin" +printf '#!/bin/bash\nexit 0\n' >"$TMPDIR/bin/getent" # every group "exists" +chmod +x "$TMPDIR/bin/getent" +export PATH="$TMPDIR/bin:$PATH" + +PROVISIONING_DIR="$TMPDIR/prov" +mkdir -p "$PROVISIONING_DIR" +printf 'wheel\ninput\ndocker\n' >"$PROVISIONING_DIR/groups" + +# Load the real user_groups() from the provisioning command and run it. +eval "$(sed -n '/^user_groups() {/,/^}/p' "$ROOT/bin/omarchy-provision-owner")" +groups=$(user_groups) + +[[ ",$groups," == *",wheel,"* ]] || fail "user_groups always includes wheel" +[[ ",$groups," == *",input,"* ]] || fail "user_groups includes recorded non-docker groups" +[[ ",$groups," == *",docker,"* ]] && fail "user_groups must never grant the docker group" +pass "first-boot user_groups includes recorded groups but never docker" + +# The Quattro upgrade must not re-add the user to docker. +if rg -q 'usermod -aG docker' "$ROOT/bin/omarchy-upgrade-to-quattro"; then + fail "omarchy-upgrade-to-quattro must not add the user to the docker group" +fi +pass "the Quattro upgrade does not grant the docker group" diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh new file mode 100644 index 00000000..0aa353f2 --- /dev/null +++ b/test/shell.d/windows-vm-compose-test.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# +# The Windows VM compose file is written by an elevated, input-validated writer +# into a root-owned directory. These tests pin the security-critical behavior: +# no input can inject a host-root bind mount or a privileged flag, the password +# survives both the YAML and the compose-interpolation layer, only known +# privileged actions dispatch, and legacy configs migrate without redownloading. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT +export OMARCHY_WINDOWS_DIR="$TMPDIR/win" + +# Source the command's functions; the dispatcher just prints usage for "help". +set -- help +source "$ROOT/bin/omarchy-windows-vm" >/dev/null 2>&1 +COMPOSE="$OMARCHY_WINDOWS_DIR/docker-compose.yml" + +write() { # RAM CORES DISK USER PASS TZ STORAGE SHARED + printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \ + "$@" | __priv_write_compose +} + +# --- valid compose, with the dangerous bits pinned and unreachable by input --- +rm -f "$COMPOSE" +write 4G 2 64G alice 's3cret' Europe/Copenhagen /home/alice/.windows /home/alice/Windows +[[ -f $COMPOSE ]] || fail "writer produced a compose file" +grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned" +grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned" +grep -q -- '- /home/alice/.windows:/storage' "$COMPOSE" || fail "storage volume uses the given path" +grep -q -- '- /:/' "$COMPOSE" && fail "compose must never contain a host-root bind mount" +pass "writer emits a pinned compose with no host-root mount" + +# --- injection attempts are rejected, no file written --- +rm -f "$COMPOSE" +write 4G 2 64G 'x -v /:/h' p UTC /a /b 2>/dev/null && fail "malicious username was accepted" +[[ ! -f $COMPOSE ]] || fail "no compose written for a bad username" +write 4G 2 64G ok p UTC '/a -v /etc:/etc' /b 2>/dev/null && fail "malicious storage path was accepted" +write '4G; rm -rf /' 2 64G ok p UTC /a /b 2>/dev/null && fail "malicious RAM was accepted" +pass "injection attempts in username, path, and RAM are rejected" + +# --- password survives YAML (" \) and compose interpolation ($) --- +rm -f "$COMPOSE" +tricky='p@$$w:rd$HOME"x\y' +write 8G 4 64G bob "$tricky" UTC /h/.windows /h/Windows +grep -q 'PASSWORD: ".*\$\$.*"' "$COMPOSE" || fail "\$ is escaped as \$\$ for compose interpolation" +recovered=$(unescape "$(read_compose_value PASSWORD "$COMPOSE")") +[[ $recovered == "$tricky" ]] || fail "password round-trips through write/unescape" +pass "password with \" \\ and \$ round-trips" + +# --- only known privileged actions may dispatch --- +for action in write_compose up up_wait down status remove; do + valid_priv_action "$action" || fail "known privileged action rejected: $action" +done +for action in '/../evil/x' bogus 'up;rm' '' '__priv_up'; do + valid_priv_action "$action" && fail "privileged action whitelist accepted: [$action]" +done +pass "privileged action whitelist accepts known actions and rejects the rest" + +# --- legacy per-user compose migrates into the root-owned location --- +# A rogue process could have rewritten the user-owned legacy compose to bind +# mount host / into the guest, so migration must ignore its volume paths and +# reconstruct them from the current user's $HOME. +rm -rf "$OMARCHY_WINDOWS_DIR" +export HOME="$TMPDIR/home" +mkdir -p "$HOME/.config/windows" +LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" +COMPOSE_FILE="$COMPOSE" +cat >"$LEGACY_COMPOSE_FILE" <<'LEG' +services: + windows: + environment: + RAM_SIZE: "16G" + CPU_CORES: "6" + DISK_SIZE: "128G" + USERNAME: "legacyuser" + PASSWORD: "legacypass" + TZ: "America/New_York" + volumes: + - /./:/storage + - /etc:/shared +LEG +# In production the write elevates via pkexec; here run it in-process. +priv() { local a=$1; shift; "__priv_$a" "$@"; } +migrate_legacy_compose +[[ -f $COMPOSE_FILE ]] || fail "migration wrote the root-owned compose" +grep -q 'USERNAME: "legacyuser"' "$COMPOSE_FILE" || fail "migration preserves settings" +grep -q -- "- $HOME/.windows:/storage" "$COMPOSE_FILE" || fail "migration uses the user's home for the data volume" +grep -q -- '- /:/' "$COMPOSE_FILE" && fail "migration must not carry a host-root bind mount from a tampered legacy file" +grep -q -- '- /etc:/shared' "$COMPOSE_FILE" && fail "migration must not carry a tampered legacy volume path" +[[ ! -f $LEGACY_COMPOSE_FILE ]] || fail "migration removes the legacy compose" +pass "migration reconstructs data paths from \$HOME and ignores tampered legacy volumes" + +# --- bring-up refuses a symlinked mount source (a symlink redirects the +# privileged bind mount the same way traversal would; the string check on +# the stored path cannot see it) --- +rm -f "$COMPOSE" +mkdir -p "$TMPDIR/realstore" "$TMPDIR/realshare" +write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/realshare" +assert_mounts_safe || fail "real directory mount sources are accepted" +ln -sfn / "$TMPDIR/evilshare" +write 4G 2 64G dave pw UTC "$TMPDIR/realstore" "$TMPDIR/evilshare" +assert_mounts_safe && fail "a symlinked mount source must be refused" +pass "bring-up refuses a symlinked mount source" + +# --- valid_path rejects traversal and non-normalized paths --- +for p in /home/u/.windows /var/lib/omarchy/windows; do + valid_path "$p" || fail "valid_path rejected a normal path: $p" +done +for p in / /./ // /tmp/../etc /home/u/. '/home/u/../root' '/a//b'; do + valid_path "$p" && fail "valid_path accepted a traversal/non-normalized path: $p" +done +pass "valid_path accepts normalized paths and rejects traversal" + +# --- credentials are stored privately and round-trip (incl. = in password) --- +export CREDENTIALS_FILE="$TMPDIR/creds" +write_credentials 'carol' 'p=a$$w"x' +[[ $(stat -c '%a' "$CREDENTIALS_FILE") == "600" ]] || fail "credentials file is 0600" +[[ $(read_credential USERNAME) == "carol" ]] || fail "username round-trips" +[[ $(read_credential PASSWORD) == 'p=a$$w"x' ]] || fail "password (with =) round-trips" +pass "credentials are written 0600 and round-trip"