The mode check returned 1 without any output, so a launch from the app menu died after the polkit prompt with nothing to diagnose. Name each source directory and the mode it was left with.
1618 lines
56 KiB
Bash
Executable File
1618 lines
56 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.
|
|
# The mode is symbolic because a numeric chmod keeps setuid/setgid on a
|
|
# directory, and dockur marks an initially empty /shared setgid (2777).
|
|
chmod u=rwx,go=,a-s -- "/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}<&-
|
|
echo "omarchy-windows-vm: could not make the VM data directories private: $LEGACY_STORAGE is mode ${storage_mode:-unknown}, $LEGACY_SHARED is mode ${shared_mode:-unknown} (expected 700)" >&2
|
|
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
|
|
# omarchy:heredoc-expands paths=EXPECTED_STORAGE,EXPECTED_SHARED -- both are
|
|
# root-protected anchors derived from the authenticated caller uid and bound
|
|
# to source inodes that were opened and validated before this compose is
|
|
# written. The remaining expansions are revalidated scalar settings.
|
|
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 u=rwx,go=,a-s -- "$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)
|
|
|
|
RDP_ARGS=(
|
|
"/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
|
|
)
|
|
if [[ -n $RDP_SCALE ]]; then
|
|
RDP_ARGS+=("$RDP_SCALE")
|
|
fi
|
|
|
|
# Connect with RDP in fullscreen (auto-detects resolution). The arguments go
|
|
# in over stdin rather than on the command line: /proc/<pid>/cmdline is
|
|
# world-readable, so passing the VM password as /p:"$WIN_PASS" would show it
|
|
# to every other user on the machine for as long as the session is open.
|
|
# /args-from must stay the only argument here — FreeRDP rejects it outright
|
|
# when it is combined with any other, so new flags belong in RDP_ARGS above.
|
|
printf '%s\n' "${RDP_ARGS[@]}" | xfreerdp3 /args-from:stdin
|
|
|
|
# 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
|