Don't put the user in the docker group; make it opt-in (#8056)

* Don't put the user in the docker group; make it opt-in

The docker group is root-equivalent: anything in it can `docker run -v /:/host`
and rewrite the host as root with no password. On a single-user box that's not
an escalation (the owner is already a wheel/sudo user), but it hands any code
running as the user — a rogue plugin, a poisoned dependency — a silent, headless,
passwordless path to root that sudo's password prompt would otherwise gate.

Stop granting the docker group by default. The daemon still runs (docker.socket);
the Docker TUI and the Windows VM reach it through a polkit prompt, and the plain
`docker` CLI runs under sudo. Sudoless Docker is a warned opt-in via
Setup > Security (omarchy-setup-security-sudoless-docker).

No automatic path may re-grant it: install and first-boot provisioning never
record or apply the group (provisioning also filters a docker line left in an
older factory snapshot), and the Quattro upgrade no longer adds it.

The Windows VM keeps needing the root daemon for a privileged container (KVM,
NET_ADMIN), so it is reworked to run without the group and without becoming a new
way in:

- The compose lives in a root-owned dir and is only written by an elevated,
  input-validated writer. A root-invoked bring-up must never consume a file a
  user-process could rewrite to bind-mount / into the guest — the old
  ~/.config/windows compose was exactly that. Volume paths are rebuilt from
  $HOME on migration rather than trusted from the (user-writable) legacy file,
  path validation rejects traversal, and the privileged sub-action is checked
  against an allowlist before dispatch (a slash in it would otherwise run as a
  path).
- pkexec elevates a verified root-owned command path, not a PATH-resolved one,
  so an authorized prompt can't be redirected to an attacker's binary.
- The guest password is kept in a private 0600 per-user file for RDP instead of
  a world-readable compose, and a declined authorization is reported as such,
  never as a completed stop.

Existing installs auto-migrate the VM (no redownload) and refresh the stale
Docker launcher entry.

🤖 Generated by Opus 4.8 in Claude Code. Reviewed by Codex XHigh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <codex@openai.com>
Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T

* Migrate existing installs off the docker group

The default flip only reaches new installs; existing users keep their docker
group membership and stay exposed. Extend the migration that already refreshes
the Docker launcher to also remove the current user from the group when present,
reusing omarchy-remove-security-sudoless-docker so there is one source of truth
for the change and its notice. It takes effect at next login (the current
session keeps working), and passwordless docker can be turned back on from
Setup > Security > Sudoless Docker.

Migrations run with sudo available — during `omarchy update`, or in the terminal
the pending-migrations notification opens — so the privileged removal does not
prompt at an unattended login. The no-op path (already out of the group) needs
no privilege.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T

* Refuse symlinked VM mount sources; correct the docker CLI docs

Review follow-ups.

valid_path keeps a traversal string (/./, //, ..) out of the compose, but it is
a string check: a symlink planted at ~/.windows or ~/Windows redirects the
privileged bind mount exactly as traversal would, because docker follows it. So
verify the mount sources as root immediately before bringing the VM up — refuse
a source that is a symlink or resolves through one — which is where the string
check cannot help. A missing source stays fine (docker creates a plain dir).

Also correct the development-tools manual: the CLI is not transparently elevated
(there is no docker wrapper and `d` is still plain docker), so say plainly that
docker on the command line takes `sudo` until sudoless Docker is enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gb7x6poap4hGCndPx5qt5T

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Codex XHigh <codex@openai.com>
This commit is contained in:
Omarchybot
2026-08-24 18:56:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 David Heinemeier Hansson Codex XHigh
parent 5afc9e1495
commit b5ded31e2f
18 changed files with 862 additions and 160 deletions
+1 -1
View File
@@ -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
+18
View File
@@ -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
+5
View File
@@ -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"
+20
View File
@@ -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."
+37
View File
@@ -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
+3 -3
View File
@@ -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
+446 -101
View File
@@ -4,7 +4,364 @@
# omarchy:args=<install|remove|launch|stop|status> [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" <<EOF
services:
windows:
image: $IMAGE
container_name: $CONTAINER
environment:
VERSION: "11"
RAM_SIZE: "$ram"
CPU_CORES: "$cores"
DISK_SIZE: "$disk"
USERNAME: "$username"
PASSWORD: "$esc_password"
TZ: "$tz"
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:
- $storage:/storage
- $shared:/shared
restart: "no"
stop_grace_period: 2m
EOF
# Readable by root and the docker group only. In sudoless mode the user is in
# the docker group and runs docker-compose against this file directly; in the
# default mode the elevated helper (root) reads it, and other local users
# cannot read the guest password. chown falls back to root:root if the docker
# group somehow does not exist (sudoless mode could not be enabled anyway).
chmod 0640 "$tmp" 2>/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}
@@ -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 <<EOF | tee "$HOME/.local/share/applications/windows-vm.desktop" >/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,7 +449,7 @@ 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
@@ -100,7 +458,7 @@ EOF
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,60 +613,31 @@ launch_windows() {
KEEP_ALIVE=true
fi
# Check if config exists
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 "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"
echo " View logs: docker logs omarchy-windows"
omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM"
exit 1
fi
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
@@ -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
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
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,25 +775,38 @@ show_usage() {
# Main command dispatcher
case "$1" in
install)
__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)
remove_windows
;;
launch|start)
launch | start)
launch_windows "$2"
;;
stop|down)
stop | down)
stop_windows
;;
status)
status)
status_windows
;;
help|--help|-h|"")
help | --help | -h | "")
show_usage
;;
*)
*)
echo "Unknown command: $1" >&2
echo "" >&2
show_usage >&2
+1 -1
View File
@@ -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" })
+2
View File
@@ -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'"},
+16 -10
View File
@@ -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.
:
+4 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
+20
View File
@@ -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
+14 -2
View File
@@ -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"
@@ -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"
+18 -12
View File
@@ -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" <<EOF
STUB
cat >"$TMPDIR/bin/usermod" <<STUB
#!/bin/bash
echo "\$@" >>"$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"
@@ -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"
+124
View File
@@ -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"