Files
omarchycn/bin/omarchy-windows-vm
T

1039 lines
36 KiB
Bash
Executable File

#!/bin/bash
# omarchy:summary=Install, launch, stop, inspect, or remove the Windows VM
# omarchy:args=<install|remove|launch|stop|status> [options]
# omarchy:requires-sudo=true
# 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 this session can reach the Docker socket directly (sudoless Docker
# on and in effect). Asking about the socket rather than the configured groups
# keeps the prompt in place through the window where sudoless Docker is enabled
# but the reboot that grants the group has not happened yet.
docker_needs_sudo() { omarchy-sudo-docker; }
# The command to hand pkexec for the privileged re-exec. pkexec runs whatever
# executable it is given (after authorization) and only shows the path in the
# 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 && $action != remove ]] && ! docker_needs_sudo; then
# Existing installs used bind sources in $HOME. One privileged run is
# needed to atomically move those directories below the root-owned mount
# boundary, even when the caller can otherwise reach Docker directly.
if [[ $action != up && $action != up_wait ]] || ! compose_needs_mount_migration; then
"__priv_$action" "$@"
return
fi
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_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) ---
# Resolve the account that authorized pkexec. Never trust HOME or a caller-
# supplied mount path in the privileged process: pkexec can reset HOME, and the
# old path arguments were the source of an arbitrary host bind-mount primitive.
resolve_caller() {
local entry canonical parent owner mode
if ((EUID == 0)); then
[[ ${PKEXEC_UID:-} =~ ^[0-9]+$ ]] && ((10#$PKEXEC_UID > 0)) || {
echo "omarchy-windows-vm: cannot identify the user who authorized this action" >&2
return 1
}
CALLER_UID=$((10#$PKEXEC_UID))
else
CALLER_UID=$(id -u)
fi
entry=$(getent passwd "$CALLER_UID") || {
echo "omarchy-windows-vm: no account exists for uid $CALLER_UID" >&2
return 1
}
IFS=: read -r _ _ _ CALLER_GID _ CALLER_HOME _ <<<"$entry"
[[ $CALLER_GID =~ ^[0-9]+$ && $CALLER_HOME == /* && -d $CALLER_HOME ]] || {
echo "omarchy-windows-vm: invalid home directory for uid $CALLER_UID" >&2
return 1
}
# A direct, non-root development invocation with a non-standard runtime has
# no privilege boundary and may use its current HOME (which also keeps these
# functions testable). Production always uses the account database value.
if ((EUID != 0)) && [[ $RUNTIME_DIR != /var/lib/omarchy/windows ]]; then
CALLER_HOME=${HOME:-$CALLER_HOME}
fi
canonical=$(realpath -e -- "$CALLER_HOME" 2>/dev/null) || return 1
[[ $canonical == "$CALLER_HOME" ]] || {
echo "omarchy-windows-vm: refusing a home directory reached through a symlink" >&2
return 1
}
if ((EUID == 0)); then
owner=$(stat -Lc '%u' "$CALLER_HOME") || return 1
[[ $owner == "$CALLER_UID" ]] || {
echo "omarchy-windows-vm: caller does not own $CALLER_HOME" >&2
return 1
}
# The user must not be able to rename or replace their home while root is
# moving the legacy data entry out of it.
parent=$(dirname -- "$CALLER_HOME")
while :; do
owner=$(stat -Lc '%u' "$parent") || return 1
mode=$(stat -Lc '%a' "$parent") || return 1
[[ $owner == 0 ]] && ! ((8#$mode & 022)) || {
echo "omarchy-windows-vm: unsafe writable parent in home path: $parent" >&2
return 1
}
[[ $parent == / ]] && break
parent=$(dirname -- "$parent")
done
fi
# Keep the potentially large disk on the same filesystem as the user's home,
# but outside that user-writable directory. The already-validated home parent
# is root-owned, so this sibling tree provides a stable rename boundary.
MOUNT_ROOT="$(dirname -- "$CALLER_HOME")/.omarchy-windows"
USERS_DIR="$MOUNT_ROOT/users"
CALLER_DATA_ROOT="$USERS_DIR/$CALLER_UID"
EXPECTED_STORAGE="$CALLER_DATA_ROOT/storage"
EXPECTED_SHARED="$CALLER_DATA_ROOT/shared"
LEGACY_STORAGE="$CALLER_HOME/.windows"
LEGACY_SHARED="$CALLER_HOME/Windows"
}
boundary_owner() {
# Production boundaries remain root-owned even when a docker-group user runs
# the read-only bring-up checks directly. A non-standard runtime is supported
# only for unprivileged tests/development and is owned by that caller.
if ((EUID == 0)) || [[ $RUNTIME_DIR == /var/lib/omarchy/windows ]]; then
printf '0'
else
printf '%s' "$CALLER_UID"
fi
}
assert_boundary_dir() {
local path="$1" expected_owner="$2" owner mode canonical
[[ -d $path && ! -L $path ]] || return 1
canonical=$(realpath -e -- "$path" 2>/dev/null) || return 1
[[ $canonical == "$path" ]] || return 1
owner=$(stat -Lc '%u' "$path") || return 1
mode=$(stat -Lc '%a' "$path") || return 1
[[ $owner == "$expected_owner" ]] && ! ((8#$mode & 022))
}
prepare_runtime_tree() {
local owner probe
owner=$(boundary_owner)
if ((EUID == 0)); then
[[ $RUNTIME_DIR == /var/lib/omarchy/windows ]] || {
echo "omarchy-windows-vm: refusing a non-standard privileged runtime path" >&2
return 1
}
# Check the nearest existing ancestor before mkdir can follow anything.
# Every new component is then created by root and checked again below.
probe=$RUNTIME_DIR
while [[ ! -e $probe && ! -L $probe ]]; do probe=$(dirname -- "$probe"); done
while :; do
assert_boundary_dir "$probe" 0 || {
echo "omarchy-windows-vm: unsafe runtime parent: $probe" >&2
return 1
}
[[ $probe == / ]] && break
probe=$(dirname -- "$probe")
done
if [[ -e $MOUNT_ROOT || -L $MOUNT_ROOT ]]; then
assert_boundary_dir "$MOUNT_ROOT" 0 || {
echo "omarchy-windows-vm: unsafe mount root: $MOUNT_ROOT" >&2
return 1
}
fi
fi
mkdir -p -- "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT"
chmod 0755 "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT"
if ((EUID == 0)); then
chown root:root "$RUNTIME_DIR" "$MOUNT_ROOT" "$USERS_DIR" "$CALLER_DATA_ROOT"
fi
assert_boundary_dir "$RUNTIME_DIR" "$owner" &&
assert_boundary_dir "$MOUNT_ROOT" "$owner" &&
assert_boundary_dir "$USERS_DIR" "$owner" &&
assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" || {
echo "omarchy-windows-vm: unsafe VM mount boundary" >&2
return 1
}
}
# Move an existing home entry first, then inspect the pinned object below the
# root-owned parent. This closes the check/use gap where an attacker could swap
# a checked home directory for a symlink before Docker resolved it.
prepare_mount_leaf() {
local legacy="$1" stable="$2" rejected source_dev target_dev
if [[ ! -e $stable && ! -L $stable ]]; then
if [[ -e $legacy || -L $legacy ]]; then
# rename(2) pins the exact directory entry the caller presented. GNU mv
# falls back to a privileged recursive copy across filesystems, which
# would reopen the source path and reintroduce the race, so fail closed in
# that uncommon layout instead of copying as root.
source_dev=$(stat -c '%d' -- "$legacy") || return 1
target_dev=$(stat -Lc '%d' -- "$CALLER_DATA_ROOT") || return 1
[[ $source_dev == "$target_dev" ]] || {
echo "omarchy-windows-vm: cannot safely migrate $legacy across filesystems" >&2
echo "Move it onto the filesystem containing $MOUNT_ROOT, then retry." >&2
return 1
}
mv --no-copy -T -- "$legacy" "$stable" || return 1
if [[ ! -d $stable || -L $stable ]]; then
rejected="$CALLER_DATA_ROOT/rejected-$(basename -- "$stable")-$$"
mv --no-copy -T -- "$stable" "$rejected" 2>/dev/null || true
echo "omarchy-windows-vm: refusing non-directory VM data entry at $legacy" >&2
return 1
fi
else
install -d -m 0700 "$stable"
fi
fi
[[ -d $stable && ! -L $stable ]] && [[ $(realpath -e -- "$stable" 2>/dev/null) == "$stable" ]] || {
echo "omarchy-windows-vm: unsafe VM data directory: $stable" >&2
return 1
}
if ((EUID == 0)); then
chown "$CALLER_UID:$CALLER_GID" "$stable"
fi
if [[ -L $legacy ]]; then
[[ $(realpath -e -- "$legacy" 2>/dev/null) == "$stable" ]] || {
echo "omarchy-windows-vm: $legacy does not point to its protected mount anchor" >&2
return 1
}
elif [[ -e $legacy ]]; then
echo "omarchy-windows-vm: refusing to replace existing data at $legacy" >&2
return 1
else
ln -s -- "$stable" "$legacy" || return 1
if ((EUID == 0)); then
chown -h "$CALLER_UID:$CALLER_GID" "$legacy"
fi
fi
}
prepare_caller_mounts() {
resolve_caller && prepare_runtime_tree &&
prepare_mount_leaf "$LEGACY_STORAGE" "$EXPECTED_STORAGE" &&
prepare_mount_leaf "$LEGACY_SHARED" "$EXPECTED_SHARED"
}
# 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 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" ;;
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"
prepare_caller_mounts || 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//\$/\$\$}
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:
- $EXPECTED_STORAGE:/storage
- $EXPECTED_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).
get_mount_source() {
sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1
}
# True only for the exact pair emitted by older Omarchy releases. The result is
# used by priv() to force a one-time elevated migration for sudoless-Docker
# users; arbitrary or mixed bind sources are never classified as migratable.
compose_needs_mount_migration() {
[[ -f $COMPOSE_FILE ]] || return 1
resolve_caller || return 1
[[ $(get_mount_source /storage) == "$LEGACY_STORAGE" &&
$(get_mount_source /shared) == "$LEGACY_SHARED" ]]
}
rewrite_compose_mounts() {
local tmp
tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || return 1
awk -v storage="$EXPECTED_STORAGE" -v shared="$EXPECTED_SHARED" '
/^[[:space:]]*-[[:space:]]*\/[^:]*:\/storage$/ { print " - " storage ":/storage"; next }
/^[[:space:]]*-[[:space:]]*\/[^:]*:\/shared$/ { print " - " shared ":/shared"; next }
{ print }
' "$COMPOSE_FILE" >"$tmp" || { rm -f "$tmp"; return 1; }
chmod 0640 "$tmp"
if ((EUID == 0)); then
chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp"
fi
mv -f -- "$tmp" "$COMPOSE_FILE"
}
assert_compose_trusted() {
local owner expected mode
[[ -f $COMPOSE_FILE && ! -L $COMPOSE_FILE ]] || return 1
owner=$(stat -Lc '%u' "$COMPOSE_FILE") || return 1
mode=$(stat -Lc '%a' "$COMPOSE_FILE") || return 1
expected=$(boundary_owner)
[[ $owner == "$expected" ]] && ! ((8#$mode & 022))
}
assert_mounts_safe() {
local storage shared owner
resolve_caller || return 1
assert_compose_trusted || {
echo "omarchy-windows-vm: refusing an untrusted compose file" >&2
return 1
}
storage=$(get_mount_source /storage)
shared=$(get_mount_source /shared)
if [[ $storage == "$LEGACY_STORAGE" && $shared == "$LEGACY_SHARED" ]]; then
((EUID == 0)) || {
echo "omarchy-windows-vm: legacy VM data needs an authorized migration" >&2
return 1
}
prepare_caller_mounts || return 1
rewrite_compose_mounts || return 1
storage=$EXPECTED_STORAGE
shared=$EXPECTED_SHARED
fi
[[ $storage == "$EXPECTED_STORAGE" && $shared == "$EXPECTED_SHARED" ]] || {
echo "omarchy-windows-vm: refusing unexpected host paths in the compose" >&2
return 1
}
owner=$(boundary_owner)
assert_boundary_dir "$RUNTIME_DIR" "$owner" &&
assert_boundary_dir "$MOUNT_ROOT" "$owner" &&
assert_boundary_dir "$USERS_DIR" "$owner" &&
assert_boundary_dir "$CALLER_DATA_ROOT" "$owner" &&
[[ -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]] &&
[[ -d $EXPECTED_SHARED && ! -L $EXPECTED_SHARED ]] &&
[[ $(realpath -e -- "$EXPECTED_STORAGE" 2>/dev/null) == "$EXPECTED_STORAGE" ]] &&
[[ $(realpath -e -- "$EXPECTED_SHARED" 2>/dev/null) == "$EXPECTED_SHARED" ]] || {
echo "omarchy-windows-vm: refusing an unsafe VM mount anchor" >&2
return 1
}
}
__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() {
resolve_caller || return 1
dc down 2>/dev/null || true
docker rmi "$IMAGE" 2>/dev/null || true
rm -f "$COMPOSE_FILE"
# Shared files intentionally survive removal. The storage leaf cannot be
# swapped by the user because its parent is the protected boundary.
if [[ $EXPECTED_STORAGE == "$USERS_DIR/$CALLER_UID/storage" && -d $EXPECTED_STORAGE && ! -L $EXPECTED_STORAGE ]]; then
rm -rf --one-file-system -- "$EXPECTED_STORAGE"
fi
}
# --- 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"
printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \
"$ram" "$cores" "$disk" "$username" "$password" "$tz" |
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
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")
[[ -z $tz ]] && tz="UTC"
# The elevated writer derives both mount anchors from the authenticated uid;
# it never consumes volume paths from this user-owned legacy file.
if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz"; 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
# Check for KVM support
if [[ ! -e /dev/kvm ]]; then
gum style \
--border normal \
--padding "1 2" \
--margin "1" \
"❌ KVM virtualization not available!" \
"" \
"Please enable virtualization in BIOS or run:" \
" sudo modprobe kvm-intel # for Intel CPUs" \
" sudo modprobe kvm-amd # for AMD CPUs"
exit 1
fi
# Check disk space
AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}')
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)"
exit 1
fi
}
# --- commands ----------------------------------------------------------------
install_windows() {
# Set up trap to handle Ctrl+C
trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT
check_prerequisites
omarchy-pkg-add freerdp openbsd-netcat gum
mkdir -p "$HOME/.local/share/applications"
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
Exec=uwsm app -- omarchy-windows-vm launch
Icon=windows
Terminal=false
Type=Application
Categories=System;Virtualization;
EOF
# Get system resources
TOTAL_RAM=$(free -h | awk 'NR==2 {print $2}')
TOTAL_RAM_GB=$(awk 'NR==1 {printf "%d", $2/1024/1024}' /proc/meminfo)
TOTAL_CORES=$(nproc)
echo ""
echo "System Resources Detected:"
echo " Total RAM: $TOTAL_RAM"
echo " Total CPU Cores: $TOTAL_CORES"
echo ""
RAM_OPTIONS=""
for size in 2 4 8 16 32 64; do
if ((size <= TOTAL_RAM_GB)); then
RAM_OPTIONS="$RAM_OPTIONS ${size}G"
fi
done
SELECTED_RAM=$(echo $RAM_OPTIONS | tr ' ' '\n' | gum choose --selected="4G" --header="How much RAM would you like to allocate to Windows VM?")
# Check if user cancelled
if [[ -z $SELECTED_RAM ]]; then
echo "Installation cancelled by user"
exit 1
fi
SELECTED_CORES=$(gum input --placeholder="Number of CPU cores (1-$TOTAL_CORES)" --value="2" --header="How many CPU cores would you like to allocate to Windows VM?" --char-limit=2)
# Check if user cancelled (Ctrl+C in gum input returns empty string)
if [[ -z $SELECTED_CORES ]]; then
echo "Installation cancelled by user"
exit 1
fi
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
# Check if we have enough space for minimum
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)"
exit 1
fi
DISK_OPTIONS=""
for size in 32 64 128 256 512; do
if ((size <= MAX_DISK_GB)); then
DISK_OPTIONS="$DISK_OPTIONS ${size}G"
fi
done
# Default to 64G if available, otherwise 32G
DEFAULT_DISK="64G"
if ! echo "$DISK_OPTIONS" | grep -q "64G"; then
DEFAULT_DISK="32G"
fi
SELECTED_DISK=$(echo $DISK_OPTIONS | tr ' ' '\n' | gum choose --selected="$DEFAULT_DISK" --header="How much disk space would you like to give Windows VM? (64GB+ recommended)")
# Check if user cancelled
if [[ -z $SELECTED_DISK ]]; then
echo "Installation cancelled by user"
exit 1
fi
# Extract just the number for prerequisite check
DISK_SIZE_NUM=$(echo "$SELECTED_DISK" | sed 's/G//')
# Re-check prerequisites with selected disk size
check_prerequisites "$DISK_SIZE_NUM"
# Prompt for username and password
USERNAME=$(gum input --placeholder="Username (Press enter to use default: docker)" --header="Enter Windows username:")
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
PASSWORD="admin"
PASSWORD_DISPLAY="(default)"
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 \
--border normal \
--padding "1 2" \
--margin "1" \
--align left \
--bold \
"Windows VM Configuration" \
"" \
"RAM: $SELECTED_RAM" \
"CPU: $SELECTED_CORES cores" \
"Disk: $SELECTED_DISK" \
"Username: $USERNAME" \
"Password: $PASSWORD_DISPLAY"
# Ask for confirmation
echo ""
if ! gum confirm "Proceed with this configuration?"; then
echo "Installation cancelled by user"
exit 1
fi
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). The writer creates protected storage/shared mount
# anchors and leaves the familiar home entries as symlinks to them.
write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \
"$USERNAME" "$PASSWORD" "$tz" || {
echo "❌ Failed to write the Windows VM configuration."
exit 1
}
write_credentials "$USERNAME" "$PASSWORD"
echo ""
echo "Starting Windows VM installation..."
echo "This will download a Windows 11 image (may take 10-15 minutes)."
echo ""
echo "Monitor installation progress at: http://127.0.0.1:8006"
echo ""
echo "Starting Windows VM with docker-compose..."
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"
exit 1
fi
echo ""
echo "Windows VM is starting up!"
echo ""
echo "Opening browser to monitor installation..."
# Open browser to monitor installation
sleep 3
xdg-open "http://127.0.0.1:8006"
echo ""
echo "Installation is running in the background."
echo "You can monitor progress at: http://127.0.0.1:8006"
echo ""
echo "Once finished, launch 'Windows' via Super + Space"
echo ""
echo "To stop the VM: omarchy-windows-vm stop"
echo ""
}
remove_windows() {
if ! gum confirm --default=false "Remove Windows VM and delete all associated data?"; then
echo "Removal cancelled by user"
exit 1
fi
echo "Removing Windows VM..."
migrate_legacy_compose 2>/dev/null || true
if [[ -f $COMPOSE_FILE ]]; then
priv remove || true
fi
rm -f "$HOME/.local/share/applications/windows-vm.desktop"
rm -rf "$HOME/.config/windows"
rm -rf "$HOME/.windows"
echo ""
echo "Windows VM removal completed!"
}
launch_windows() {
KEEP_ALIVE=false
if [[ $1 = "--keep-alive" ]] || [[ $1 = "-k" ]]; then
KEEP_ALIVE=true
fi
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
# 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"
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
# Build the connection info
if [[ $KEEP_ALIVE = "true" ]]; then
LIFECYCLE="VM will keep running after RDP closes
To stop: omarchy-windows-vm stop"
else
LIFECYCLE="VM will auto-stop when RDP closes"
fi
gum style \
--border normal \
--padding "1 2" \
--margin "1" \
--align center \
"Connecting to Windows VM" \
"" \
"$LIFECYCLE"
# FreeRDP 3 tries Kerberos before NTLM for NLA, and krb5 ships /etc/krb5.conf
# as the MIT sample with default_realm = ATHENA.MIT.EDU. Every connect then
# goes looking for MIT's KDC: with internet up it fails fast, but off the
# 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="$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
export KRB5_CONFIG="$KRB5_CONF"
# Detect display scale from Hyprland
HYPR_SCALE=$(hyprctl monitors -j | jq -r '.[] | select (.focused == true) | .scale')
SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}')
RDP_SCALE=""
if ((SCALE_PERCENT >= 170)); then
RDP_SCALE="/scale:180"
elif ((SCALE_PERCENT >= 130)); then
RDP_SCALE="/scale:140"
fi
# If scale is less than 130%, don't set any scale (use default 100)
# Connect with RDP in fullscreen (auto-detects resolution)
xfreerdp3 /u:"$WIN_USER" /p:"$WIN_PASS" /v:127.0.0.1:3389 -grab-keyboard /sound /microphone /clipboard /cert:ignore /title:"Windows VM - Omarchy" /dynamic-resolution /gfx:AVC444 /floatbar:sticky:off,default:visible,show:fullscreen $RDP_SCALE
# After RDP closes, stop the container unless --keep-alive was specified
if [[ $KEEP_ALIVE = "false" ]]; then
echo ""
echo "RDP session closed. Stopping Windows VM..."
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."
echo "To stop it: omarchy-windows-vm stop"
fi
}
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..."
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
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."
echo "To start: omarchy-windows-vm launch"
elif [[ $CONTAINER_STATUS = "running" ]]; then
gum style \
--border normal \
--padding "1 2" \
--margin "1" \
--align left \
"Windows VM Status: RUNNING" \
"" \
"Web interface: http://127.0.0.1:8006" \
"RDP available: port 3389" \
"" \
"To connect: omarchy-windows-vm launch" \
"To stop: omarchy-windows-vm stop"
else
echo "Windows VM is stopped (status: $CONTAINER_STATUS)"
echo "To start: omarchy-windows-vm launch"
fi
}
show_usage() {
echo "Usage: omarchy-windows-vm [command] [options]"
echo ""
echo "Commands:"
echo " install Install and configure Windows VM"
echo " remove Remove Windows VM and optionally its data"
echo " launch [options] Start Windows VM (if needed) and connect via RDP"
echo " Options:"
echo " --keep-alive, -k Keep VM running after RDP closes"
echo " stop Stop the running Windows VM"
echo " status Show current VM status"
echo " help Show this help message"
echo ""
echo "Examples:"
echo " omarchy-windows-vm install # Set up Windows VM for first time"
echo " omarchy-windows-vm launch # Connect to VM (auto-stop on exit)"
echo " omarchy-windows-vm launch -k # Connect to VM (keep running)"
echo " omarchy-windows-vm stop # Shut down the VM"
}
# Main command dispatcher
case "$1" in
__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