1588 lines
55 KiB
Bash
Executable File
1588 lines
55 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"
|
|
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/<pid>/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" <<EOF || exit 1
|
|
services:
|
|
windows:
|
|
image: $IMAGE
|
|
container_name: $CONTAINER
|
|
environment:
|
|
VERSION: "11"
|
|
RAM_SIZE: "$ram"
|
|
CPU_CORES: "$cores"
|
|
DISK_SIZE: "$disk"
|
|
USERNAME: "$username"
|
|
PASSWORD: "$esc_password"
|
|
PROTECT: "Y"
|
|
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
|
|
chmod 0640 "$tmp" || exit 1
|
|
if ((EUID == 0)); then
|
|
chown root:docker "$tmp" 2>/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 <<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=$(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
|