#!/bin/bash # omarchy:summary=Install, launch, stop, inspect, or remove the Windows VM # omarchy:args= [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" VM_LOCK_DIR=/run/lock/omarchy-windows-vm # Removal is the only path that recursively proves there are no bind aliases. # Bound every metadata walk so a large or hostile caller tree fails closed # instead of hanging a privileged action indefinitely. TREE_SCAN_TIMEOUT_SECONDS=5 TREE_SCAN_KILL_AFTER_SECONDS=1 TREE_SCAN_TIMEOUT=/usr/bin/timeout TREE_SCAN_FIND=/usr/bin/find # The privileged process must not resolve mount, stat, Docker, or any other # helper from a caller-controlled PATH, and validation must not change with an # inherited locale. pkexec normally sanitizes both; pin them here as defense in # depth for every direct __priv entry as well. if ((EUID == 0)); then export PATH=/usr/bin:/usr/sbin:/bin:/sbin export LC_ALL=C.UTF-8 fi # --- 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=/usr/bin/omarchy-windows-vm canonical probe owner mode [[ -f $candidate && ! -L $candidate && -x $candidate ]] || return 1 canonical=$(realpath -e -- "$candidate" 2>/dev/null) || return 1 [[ $canonical == "$candidate" ]] || return 1 probe=$candidate while :; do owner=$(stat -Lc '%u' "$probe" 2>/dev/null) || return 1 mode=$(stat -Lc '%a' "$probe" 2>/dev/null) || return 1 [[ $owner == 0 ]] && ! ((8#$mode & 022)) || return 1 [[ $probe == / ]] && break probe=$(dirname -- "$probe") done printf '%s\n' "$candidate" } # 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 # Bring-up normally runs directly for a docker-group user, but recreating # the protected bind anchors after reboot (or migrating an old compose) # still needs one privileged invocation. if [[ -d $VM_LOCK_DIR && ! -L $VM_LOCK_DIR && -r $VM_LOCK_DIR && -x $VM_LOCK_DIR ]] && { [[ $action != up && $action != up_wait ]] || { ! compose_needs_security_migration && mounts_ready >/dev/null 2>&1 } }; then with_vm_lock "__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" "$@"; } with_vm_lock() { local fd rc=0 if ((EUID == 0)); then assert_boundary_dir /run 0 && assert_boundary_dir /run/lock 0 || return 1 if getent group docker >/dev/null 2>&1; then install -d -o root -g docker -m 0750 -- "$VM_LOCK_DIR" || return 1 else install -d -o root -g root -m 0700 -- "$VM_LOCK_DIR" || return 1 fi fi assert_boundary_dir "$VM_LOCK_DIR" 0 || return 1 # Flock the directory inode itself. Concurrent first callers may both run # install -d, but mkdir is atomic and they necessarily open the same stable # inode below the root-owned /run/lock parent. exec {fd}<"$VM_LOCK_DIR/." || return 1 flock -x "$fd" || { exec {fd}<&-; return 1; } "$@" || rc=$? flock -u "$fd" || rc=1 exec {fd}<&- return "$rc" } # --- 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]{1,10}$ ]] && ((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 # opening and pinning the familiar data entries below 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 # Docker only ever sees fixed paths below the root-owned runtime tree. The # user's real data stays wherever ~/.windows and ~/Windows resolve (including # separately mounted homes and legitimate symlinks); those sources are pinned # into these anchors with bind mounts before Docker is allowed to start. MOUNT_ROOT="$RUNTIME_DIR/mounts" 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" # The first protected-anchor implementation used a root-owned sibling of # home. Recognize that exact derived pair during upgrade, but never accept a # path read from user input. OLD_MOUNT_ROOT="$(dirname -- "$CALLER_HOME")/.omarchy-windows" OLD_EXPECTED_STORAGE="$OLD_MOUNT_ROOT/users/$CALLER_UID/storage" OLD_EXPECTED_SHARED="$OLD_MOUNT_ROOT/users/$CALLER_UID/shared" } 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_boundary_component() { local path="$1" parent="$2" owner="$3" mode="$4" assert_boundary_dir "$parent" "$owner" || return 1 if [[ -e $path || -L $path ]]; then assert_boundary_dir "$path" "$owner" || return 1 else if ((EUID == 0)); then install -d -o root -g root -m "$mode" -- "$path" || return 1 else install -d -m "$mode" -- "$path" || return 1 fi fi chmod "$mode" -- "$path" || return 1 if ((EUID == 0)); then chown root:root -- "$path" || return 1; fi assert_boundary_dir "$path" "$owner" } prepare_runtime_tree() { local owner probe runtime_parent 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 fi runtime_parent=$(dirname -- "$RUNTIME_DIR") if ((EUID == 0)) && [[ ! -e $runtime_parent && ! -L $runtime_parent ]]; then [[ $runtime_parent == /var/lib/omarchy ]] || return 1 assert_boundary_dir /var/lib 0 || return 1 install -d -o root -g root -m 0755 -- "$runtime_parent" || return 1 fi prepare_boundary_component "$RUNTIME_DIR" "$runtime_parent" "$owner" 0755 && prepare_boundary_component "$MOUNT_ROOT" "$RUNTIME_DIR" "$owner" 0711 && prepare_boundary_component "$USERS_DIR" "$MOUNT_ROOT" "$owner" 0711 && prepare_boundary_component "$CALLER_DATA_ROOT" "$USERS_DIR" "$owner" 0711 || { echo "omarchy-windows-vm: unsafe VM mount boundary" >&2 return 1 } } # Open the source directory itself and keep the descriptor alive until after the # bind. /proc/$BASHPID/fd refers to this exact shell process (including when a # function runs in a pipeline subshell), not the short-lived mount subprocess, # so a rename or symlink swap after open cannot change which inode is mounted. open_mount_source() { local path="$1" label="$2" fd record uid identity [[ -d $path ]] || { echo "omarchy-windows-vm: $label source is not a directory: $path" >&2 return 1 } # Appending /. makes a directory-to-FIFO swap fail with ENOTDIR instead of # leaving the privileged helper blocked while opening an attacker-held pipe. exec {fd}<"$path/." || { echo "omarchy-windows-vm: cannot open $label source: $path" >&2 return 1 } [[ -d /proc/$BASHPID/fd/$fd ]] || { exec {fd}<&- return 1 } record=$(stat -Lc '%u|%d:%i' "/proc/$BASHPID/fd/$fd" 2>/dev/null) || { exec {fd}<&- return 1 } IFS='|' read -r uid identity <<<"$record" [[ $uid == "$CALLER_UID" ]] || { exec {fd}<&- echo "omarchy-windows-vm: $label source must be a directory owned by uid $CALLER_UID" >&2 return 1 } OPENED_MOUNT_FD=$fd OPENED_MOUNT_ID=$identity } # Return 0 when ancestor_id contains the already-open descendant directory, 1 # when the walk reaches the namespace root without finding it, and 2 on any # error or an implausibly deep walk. Every hop is opened relative to a pinned # directory FD; no caller-mutable pathname is re-resolved. pinned_dir_contains() { local ancestor_id="$1" descendant_fd="$2" walk_fd parent_fd current_id parent_id depth exec {walk_fd}<"/proc/$BASHPID/fd/$descendant_fd/." || return 2 for ((depth = 0; depth < 256; depth++)); do current_id=$(stat -Lc '%d:%i' "/proc/$BASHPID/fd/$walk_fd" 2>/dev/null) || { exec {walk_fd}<&- return 2 } if [[ $current_id == "$ancestor_id" ]]; then exec {walk_fd}<&- return 0 fi exec {parent_fd}<"/proc/$BASHPID/fd/$walk_fd/.." || { exec {walk_fd}<&- return 2 } parent_id=$(stat -Lc '%d:%i' "/proc/$BASHPID/fd/$parent_fd" 2>/dev/null) || { exec {parent_fd}<&- exec {walk_fd}<&- return 2 } if [[ $parent_id == "$current_id" ]]; then exec {parent_fd}<&- exec {walk_fd}<&- return 1 fi exec {walk_fd}<&- walk_fd=$parent_fd done exec {walk_fd}<&- return 2 } # A bind alias can give the same directory inode a second parent chain, so an # upward walk alone is insufficient for destructive removal. Search from a # pinned tree root for the other pinned inode without following symlinks or # crossing the removal traversal's filesystem boundary. Return 0 when found, 1 # when absent, and 2 on timeout, traversal error, or unexpected output. pinned_tree_contains() { local root_fd="$1" needle_fd="$2" found rc # -xdev still evaluates a nested mountpoint itself before pruning its # children, so a direct different-filesystem alias of the needle is found too. # This matches removal's traversal boundary without skipping mount aliases. if found=$("$TREE_SCAN_TIMEOUT" --signal=TERM --kill-after="${TREE_SCAN_KILL_AFTER_SECONDS}s" \ "${TREE_SCAN_TIMEOUT_SECONDS}s" "$TREE_SCAN_FIND" -P "/proc/$BASHPID/fd/$root_fd/." \ -xdev -type d -samefile "/proc/$BASHPID/fd/$needle_fd/." \ -printf 'found\n' -quit 2>/dev/null); then rc=0 else rc=$? fi ((rc == 0)) || return 2 case "$found" in found) return 0 ;; "") return 1 ;; *) return 2 ;; esac } validate_pinned_sources_disjoint() { local storage_fd="$1" storage_id="$2" shared_fd="$3" shared_id="$4" rc if [[ $storage_id == "$shared_id" ]]; then echo "omarchy-windows-vm: storage and shared must be different directories" >&2 return 1 fi if pinned_dir_contains "$storage_id" "$shared_fd"; then echo "omarchy-windows-vm: shared directory must not be inside storage" >&2 return 1 else rc=$? ((rc == 1)) || { echo "omarchy-windows-vm: could not verify storage/shared ancestry" >&2 return 1 } fi if pinned_dir_contains "$shared_id" "$storage_fd"; then echo "omarchy-windows-vm: storage directory must not be inside shared" >&2 return 1 else rc=$? ((rc == 1)) || { echo "omarchy-windows-vm: could not verify storage/shared ancestry" >&2 return 1 } fi } removal_trees_disjoint() { local storage_fd shared_fd anchor_storage_fd anchor_shared_fd storage_id shared_id rc=1 scan_rc local scan scan_root_fd scan_needle_fd scan_label open_mount_source "$LEGACY_STORAGE" storage || return 1 storage_fd=$OPENED_MOUNT_FD storage_id=$OPENED_MOUNT_ID if ! open_mount_source "$LEGACY_SHARED" shared; then exec {storage_fd}<&- return 1 fi shared_fd=$OPENED_MOUNT_FD shared_id=$OPENED_MOUNT_ID if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id" || ! mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" || ! mounted_leaf_matches "$EXPECTED_SHARED" "$shared_id"; then exec {storage_fd}<&- exec {shared_fd}<&- return 1 fi if ! exec {anchor_storage_fd}<"$EXPECTED_STORAGE/."; then exec {storage_fd}<&- exec {shared_fd}<&- return 1 fi if ! exec {anchor_shared_fd}<"$EXPECTED_SHARED/."; then exec {anchor_storage_fd}<&- exec {storage_fd}<&- exec {shared_fd}<&- return 1 fi # Scan the protected anchor views first. Also scan the pinned caller views: # a submount attached after the original non-recursive bind is intentionally # absent from the anchor view, but removal must still refuse that alias. rc=0 for scan in \ "$anchor_storage_fd:$anchor_shared_fd:shared below protected storage" \ "$anchor_shared_fd:$anchor_storage_fd:storage below protected shared" \ "$storage_fd:$shared_fd:shared below storage source" \ "$shared_fd:$storage_fd:storage below shared source"; do IFS=: read -r scan_root_fd scan_needle_fd scan_label <<<"$scan" if pinned_tree_contains "$scan_root_fd" "$scan_needle_fd"; then echo "omarchy-windows-vm: refusing removal: $scan_label" >&2 rc=1 break else scan_rc=$? if ((scan_rc != 1)); then echo "omarchy-windows-vm: removal containment scan failed or timed out" >&2 rc=1 break fi fi done exec {anchor_storage_fd}<&- exec {anchor_shared_fd}<&- exec {storage_fd}<&- exec {shared_fd}<&- return "$rc" } prepare_mount_anchor() { local path="$1" owner owner=$(boundary_owner) [[ ! -L $path ]] || return 1 if mountpoint -q -- "$path" 2>/dev/null; then return 0; fi prepare_boundary_component "$path" "$CALLER_DATA_ROOT" "$owner" 0700 || return 1 [[ -z $(find "$path" -mindepth 1 -print -quit) ]] || { echo "omarchy-windows-vm: refusing to hide data below mount anchor: $path" >&2 return 1 } } mounted_leaf_matches() { local stable="$1" identity="$2" actual owner mode canonical [[ -d $stable && ! -L $stable ]] || return 1 canonical=$(realpath -e -- "$stable" 2>/dev/null) || return 1 [[ $canonical == "$stable" ]] || return 1 mountpoint -q -- "$stable" 2>/dev/null || return 1 [[ $(mount_layer_count "$stable") == 1 ]] || return 1 actual=$(stat -Lc '%d:%i' "$stable" 2>/dev/null) || return 1 owner=$(stat -Lc '%u' "$stable" 2>/dev/null) || return 1 mode=$(stat -Lc '%a' "$stable" 2>/dev/null) || return 1 [[ $actual == "$identity" && $owner == "$CALLER_UID" && $mode == 700 ]] } bind_mount_leaf() { local fd="$1" identity="$2" stable="$3" actual owner MOUNT_LEAF_NEW=0 if mountpoint -q -- "$stable" 2>/dev/null; then mounted_leaf_matches "$stable" "$identity" || { echo "omarchy-windows-vm: protected mount at $stable no longer matches its home source" >&2 return 1 } return 0 fi # util-linux normally canonicalizes a /proc//fd link back to a pathname, # which would throw away the FD pin. Pass the procfd to mount(2) unchanged. mount --no-canonicalize --bind "/proc/$BASHPID/fd/$fd" "$stable" || return 1 MOUNT_LEAF_NEW=1 actual=$(stat -Lc '%d:%i' "$stable" 2>/dev/null) || actual="" owner=$(stat -Lc '%u' "$stable" 2>/dev/null) || owner="" if [[ $actual != "$identity" || $owner != "$CALLER_UID" ]]; then if umount -- "$stable"; then MOUNT_LEAF_NEW=0 else echo "omarchy-windows-vm: could not roll back unverified bind at $stable" >&2 fi echo "omarchy-windows-vm: bind verification failed for $stable" >&2 return 1 fi mounted_leaf_matches "$stable" "$identity" || { if umount -- "$stable"; then MOUNT_LEAF_NEW=0 else echo "omarchy-windows-vm: could not roll back invalid bind at $stable" >&2 fi return 1 } } prepare_caller_mounts() { local storage_fd storage_id shared_fd shared_id storage_mode shared_mode CALLER_MOUNTS_NEW_STORAGE=0 CALLER_MOUNTS_NEW_SHARED=0 resolve_caller && prepare_runtime_tree || return 1 prepare_mount_anchor "$EXPECTED_STORAGE" && prepare_mount_anchor "$EXPECTED_SHARED" || return 1 # Pre-open and validate both sources before changing either mount anchor. open_mount_source "$LEGACY_STORAGE" storage || return 1 storage_fd=$OPENED_MOUNT_FD storage_id=$OPENED_MOUNT_ID if ! open_mount_source "$LEGACY_SHARED" shared; then exec {storage_fd}<&- return 1 fi shared_fd=$OPENED_MOUNT_FD shared_id=$OPENED_MOUNT_ID if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id"; then exec {storage_fd}<&- exec {shared_fd}<&- return 1 fi # Privacy is an explicit preflight step for both already-pinned sources, not # a side effect halfway through the two-mount transaction. Old umask-022 # installs are hardened together before either Docker-facing anchor changes. chmod 0700 -- "/proc/$BASHPID/fd/$storage_fd" "/proc/$BASHPID/fd/$shared_fd" || { exec {storage_fd}<&- exec {shared_fd}<&- return 1 } storage_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$storage_fd" 2>/dev/null) || storage_mode="" shared_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$shared_fd" 2>/dev/null) || shared_mode="" if [[ $storage_mode != 700 || $shared_mode != 700 ]]; then exec {storage_fd}<&- exec {shared_fd}<&- return 1 fi if bind_mount_leaf "$storage_fd" "$storage_id" "$EXPECTED_STORAGE"; then CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW else CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW rollback_new_caller_mounts || true exec {storage_fd}<&- exec {shared_fd}<&- return 1 fi if ! bind_mount_leaf "$shared_fd" "$shared_id" "$EXPECTED_SHARED"; then CALLER_MOUNTS_NEW_SHARED=$MOUNT_LEAF_NEW rollback_new_caller_mounts || true exec {storage_fd}<&- exec {shared_fd}<&- return 1 fi CALLER_MOUNTS_NEW_SHARED=$MOUNT_LEAF_NEW exec {storage_fd}<&- exec {shared_fd}<&- } mounts_ready() { local storage_fd storage_id shared_fd shared_id rc=1 resolve_caller || return 1 open_mount_source "$LEGACY_STORAGE" storage || return 1 storage_fd=$OPENED_MOUNT_FD storage_id=$OPENED_MOUNT_ID if ! open_mount_source "$LEGACY_SHARED" shared; then exec {storage_fd}<&- return 1 fi shared_fd=$OPENED_MOUNT_FD shared_id=$OPENED_MOUNT_ID if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id"; then exec {storage_fd}<&- exec {shared_fd}<&- return 1 fi if assert_boundary_dir "$RUNTIME_DIR" "$(boundary_owner)" && assert_boundary_dir "$MOUNT_ROOT" "$(boundary_owner)" && assert_boundary_dir "$USERS_DIR" "$(boundary_owner)" && assert_boundary_dir "$CALLER_DATA_ROOT" "$(boundary_owner)" && mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" && mounted_leaf_matches "$EXPECTED_SHARED" "$shared_id"; then rc=0 fi exec {storage_fd}<&- exec {shared_fd}<&- return "$rc" } mount_layer_count() { local path="$1" awk -v path="$path" '$5 == path { count++ } END { print count + 0 }' /proc/self/mountinfo } mount_descendant_count() { local path="$1" awk -v prefix="$path/" 'index($5, prefix) == 1 { count++ } END { print count + 0 }' /proc/self/mountinfo } rollback_new_caller_mounts() { local failed=0 if ((CALLER_MOUNTS_NEW_SHARED)); then if umount -- "$EXPECTED_SHARED"; then CALLER_MOUNTS_NEW_SHARED=0; else failed=1; fi fi if ((CALLER_MOUNTS_NEW_STORAGE)); then if umount -- "$EXPECTED_STORAGE"; then CALLER_MOUNTS_NEW_STORAGE=0; else failed=1; fi fi ((failed == 0)) || echo "omarchy-windows-vm: could not roll back newly created VM mounts" >&2 return "$failed" } write_compose_atomically() ( local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" local tmp="" rc esc_password cleanup_writer() { rc=$? trap - EXIT [[ -z $tmp ]] || rm -f -- "$tmp" || true if ((rc != 0)) && ! rollback_new_caller_mounts; then rc=1; fi exit "$rc" } trap cleanup_writer EXIT # 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. esc_password=${password//\\/\\\\} esc_password=${esc_password//\"/\\\"} esc_password=${esc_password//\$/\$\$} tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || exit 1 cat >"$tmp" </dev/null || chown root:root "$tmp" || exit 1 fi mv -fT -- "$tmp" "$COMPOSE_FILE" || exit 1 tmp="" trap - EXIT ) # 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 # Readable by root and the docker group only. Any failure after mounting rolls # back just the binds this writer created and leaves an old compose untouched. write_compose_atomically "$ram" "$cores" "$disk" "$username" "$password" "$tz" || exit 2 } # 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 when a trusted compose has an exact security upgrade path. The # result forces a one-time elevated migration for sudoless-Docker users. An # arbitrary or mixed bind pair is never classified as migratable. compose_needs_security_migration() { local storage shared [[ -f $COMPOSE_FILE ]] || return 1 resolve_caller || return 1 [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || return 1 storage=$(get_mount_source /storage) shared=$(get_mount_source /shared) if compose_mount_pair_is_migratable "$storage" "$shared"; then return 0 fi [[ $storage == "$EXPECTED_STORAGE" && $shared == "$EXPECTED_SHARED" ]] && ! compose_web_protected } compose_mount_pair_is_migratable() { local storage="$1" shared="$2" [[ $storage == "$LEGACY_STORAGE" && $shared == "$LEGACY_SHARED" ]] || [[ $storage == "$OLD_EXPECTED_STORAGE" && $shared == "$OLD_EXPECTED_SHARED" ]] } mount_source_count() { local destination="$1" sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$destination\$|x|p" "$COMPOSE_FILE" | wc -l } compose_web_protected() { [[ $(sed -n 's/^[[:space:]]*PROTECT:.*$/x/p' "$COMPOSE_FILE" | wc -l) == 1 && $(sed -n 's/^[[:space:]]*PROTECT:[[:space:]]*"Y"[[:space:]]*$/x/p' "$COMPOSE_FILE" | wc -l) == 1 ]] } rewrite_compose_security() { local tmp tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || return 1 awk -v storage="$EXPECTED_STORAGE" -v shared="$EXPECTED_SHARED" ' /^ environment:$/ { print; print " PROTECT: \"Y\""; next } /^[[:space:]]+PROTECT:/ { next } /^[[:space:]]*-[[:space:]]*\/[^:]*:\/storage$/ { print " - " storage ":/storage"; next } /^[[:space:]]*-[[:space:]]*\/[^:]*:\/shared$/ { print " - " shared ":/shared"; next } { print } ' "$COMPOSE_FILE" >"$tmp" || { rm -f "$tmp"; return 1; } [[ $(sed -n 's/^[[:space:]]*PROTECT:.*$/x/p' "$tmp" | wc -l) == 1 && $(sed -n 's/^[[:space:]]*PROTECT:[[:space:]]*"Y"[[:space:]]*$/x/p' "$tmp" | wc -l) == 1 ]] || { rm -f "$tmp" return 1 } chmod 0640 "$tmp" || { rm -f "$tmp"; return 1; } if ((EUID == 0)); then chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" || { rm -f "$tmp" return 1 } fi mv -fT -- "$tmp" "$COMPOSE_FILE" || { rm -f "$tmp"; return 1; } } 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 needs_rewrite=0 mounts_prepared=0 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) [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || { echo "omarchy-windows-vm: refusing duplicate or missing VM mounts in the compose" >&2 return 1 } if compose_mount_pair_is_migratable "$storage" "$shared"; then ((EUID == 0)) || { echo "omarchy-windows-vm: legacy VM data needs an authorized migration" >&2 return 1 } prepare_caller_mounts || return 1 mounts_prepared=1 needs_rewrite=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 } if ! compose_web_protected; then ((EUID == 0)) || { echo "omarchy-windows-vm: web-console protection needs an authorized migration" >&2 return 1 } needs_rewrite=1 fi if ((needs_rewrite)) && ! rewrite_compose_security; then if ((mounts_prepared)); then rollback_new_caller_mounts || true; fi return 1 fi # Mounts disappear at reboot. Root recreates them from the already-opened, # caller-owned sources; a docker-group invocation may proceed directly only # while the exact pinned pair is still present. if ((EUID == 0)); then prepare_caller_mounts || return 1 fi mounts_ready || { 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() { # Rebuild/verify both pinned binds before deleting through the storage anchor. # In particular, a legitimate ~/.windows symlink means deleting only the link # from the user side would strand the virtual disk in its external target. assert_mounts_safe || return 1 [[ $EXPECTED_STORAGE == "$USERS_DIR/$CALLER_UID/storage" ]] || return 1 [[ $EXPECTED_SHARED == "$USERS_DIR/$CALLER_UID/shared" ]] || return 1 [[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && $(mount_layer_count "$EXPECTED_SHARED") == 1 && $(mount_descendant_count "$EXPECTED_STORAGE") == 0 && $(mount_descendant_count "$EXPECTED_SHARED") == 0 ]] || { echo "omarchy-windows-vm: refusing removal with unknown or stacked VM mounts" >&2 return 1 } dc down || { echo "omarchy-windows-vm: could not stop the Windows VM; storage was not deleted" >&2 return 1 } if docker inspect "$CONTAINER" >/dev/null 2>&1; then echo "omarchy-windows-vm: Windows container still exists; storage was not deleted" >&2 return 1 fi docker info >/dev/null 2>&1 || { echo "omarchy-windows-vm: cannot verify Docker state; storage was not deleted" >&2 return 1 } mounts_ready || { echo "omarchy-windows-vm: VM mount identity changed during removal" >&2 return 1 } removal_trees_disjoint || return 1 # find -xdev deliberately empties the verified disk source without crossing # into another mounted filesystem. Shared files are never traversed. find "$EXPECTED_STORAGE" -xdev -mindepth 1 -delete || return 1 [[ -z $(find "$EXPECTED_STORAGE" -mindepth 1 -print -quit) ]] || return 1 # Release only the single known top mounts checked above. Unmount shared first # so a storage-unmount failure cannot expose shared data to deletion. umount -- "$EXPECTED_SHARED" || return 1 umount -- "$EXPECTED_STORAGE" || return 1 docker rmi "$IMAGE" 2>/dev/null || true rm -f "$COMPOSE_FILE" rmdir -- "$EXPECTED_STORAGE" "$EXPECTED_SHARED" "$CALLER_DATA_ROOT" 2>/dev/null || true } # --- config helpers ---------------------------------------------------------- # Validate both familiar home entries before creating or changing either. A # legitimate symlink is kept exactly as-is; only its caller-owned directory # target is used. The privileged half repeats the ownership check on pinned FDs. preflight_user_mount_source() { local path="$1" label="$2" uid owner uid=$(id -u) if [[ -L $path ]]; then [[ -d $path ]] || { echo "omarchy-windows-vm: $label is a broken or non-directory symlink: $path" >&2 return 1 } elif [[ -e $path ]]; then [[ -d $path ]] || { echo "omarchy-windows-vm: $label is not a directory: $path" >&2 return 1 } else return 0 fi owner=$(stat -Lc '%u' -- "$path") || return 1 [[ $owner == "$uid" ]] || { echo "omarchy-windows-vm: $label must be owned by uid $uid: $path" >&2 return 1 } } prepare_user_mount_sources() { local storage="$HOME/.windows" shared="$HOME/Windows" storage_id shared_id preflight_user_mount_source "$storage" storage && preflight_user_mount_source "$shared" shared || return 1 [[ -e $storage || -L $storage ]] || install -d -m 0700 -- "$storage" || return 1 [[ -e $shared || -L $shared ]] || install -d -m 0700 -- "$shared" || return 1 storage_id=$(stat -Lc '%d:%i' -- "$storage") || return 1 shared_id=$(stat -Lc '%d:%i' -- "$shared") || return 1 [[ $storage_id != "$shared_id" ]] || { echo "omarchy-windows-vm: storage and shared must be different directories" >&2 return 1 } chmod 0700 -- "$storage" "$shared" } storage_space_path() { if [[ -d $HOME/.windows ]]; then realpath -e -- "$HOME/.windows" else printf '%s\n' "$HOME" fi } available_storage_gb() { local path path=$(storage_space_path) || return 1 df -P -- "$path" | awk 'NR==2 {print int($4/1024/1024)}' } # 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 dir tmp dir=$(dirname -- "$CREDENTIALS_FILE") mkdir -p "$dir" || return 1 chmod 0700 "$dir" || return 1 old_umask=$(umask) umask 077 tmp=$(mktemp "$dir/.credentials.XXXXXX") || { umask "$old_umask"; return 1; } if ! printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$tmp" || ! chmod 0600 "$tmp" || ! mv -fT -- "$tmp" "$CREDENTIALS_FILE"; then rm -f -- "$tmp" umask "$old_umask" return 1 fi 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" prepare_user_mount_sources || { echo "Could not validate the existing Windows VM data directories." >&2 return 1 } write_credentials "$username" "$password" || return 1 # 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 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=$(available_storage_gb) || { echo "❌ Could not determine available space for $HOME/.windows" >&2 exit 1 } 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 prepare_user_mount_sources || exit 1 check_prerequisites omarchy-pkg-add freerdp openbsd-netcat gum mkdir -p "$HOME/.local/share/applications" cat </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=$(available_storage_gb) || { echo "❌ Could not determine available space for $HOME/.windows" >&2 exit 1 } 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 pins the familiar home directories (or # their legitimate symlink targets) into root-protected bind anchors. 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 "❌ Failed to store private RDP credentials." >&2 exit 1 } 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..." if [[ ! -f $COMPOSE_FILE && -f $LEGACY_COMPOSE_FILE ]]; then migrate_legacy_compose || { echo "❌ Could not safely migrate the VM before removal." >&2 exit 1 } fi if [[ -f $COMPOSE_FILE ]]; then priv remove || { echo "❌ Windows VM removal stopped before user-side cleanup; inspect the VM data before retrying." >&2 exit 1 } 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 } with_vm_lock "__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