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 09a53702..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,64 +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..." - - # Send desktop notification - omarchy-notification-send -g  "Starting Windows VM" "This can take 15-30 seconds" -t 15000 - - 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 @@ -348,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 @@ -359,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) @@ -373,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." @@ -383,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." @@ -446,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 eefabfad..8312c51e 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -173,6 +173,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"}, @@ -283,6 +284,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 c7b534c6..792a235e 100644 --- a/manual/28-windows-vm.md +++ b/manual/28-windows-vm.md @@ -36,6 +36,6 @@ There's no GPU passthrough with this setup, so it's not suitable for gaming or v The version installed is Windows 11 Pro, unactivated. You'll need your own license key to use the gated features. -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"