diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 0676b770..f1582e2d 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -92,7 +92,7 @@ priv() { # 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_mount_migration && mounts_ready >/dev/null 2>&1 + ! compose_needs_security_migration && mounts_ready >/dev/null 2>&1 } }; then with_vm_lock "__priv_$action" "$@" @@ -703,6 +703,7 @@ services: DISK_SIZE: "$disk" USERNAME: "$username" PASSWORD: "$esc_password" + PROTECT: "Y" TZ: "$tz" ARGUMENTS: "-rtc base=localtime,clock=host,driftfix=slew" devices: @@ -765,14 +766,20 @@ get_mount_source() { sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1 } -# True only for the exact pair emitted by older Omarchy releases. The result is -# used by priv() to force a one-time elevated migration for sudoless-Docker -# users; arbitrary or mixed bind sources are never classified as migratable. -compose_needs_mount_migration() { +# 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 - compose_mount_pair_is_migratable "$(get_mount_source /storage)" "$(get_mount_source /shared)" + 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() { @@ -786,14 +793,26 @@ mount_source_count() { sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$destination\$|x|p" "$COMPOSE_FILE" | wc -l } -rewrite_compose_mounts() { +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" || { @@ -814,7 +833,7 @@ assert_compose_trusted() { } assert_mounts_safe() { - local storage shared + 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 @@ -834,10 +853,8 @@ assert_mounts_safe() { return 1 } prepare_caller_mounts || return 1 - if ! rewrite_compose_mounts; then - rollback_new_caller_mounts || true - return 1 - fi + mounts_prepared=1 + needs_rewrite=1 storage=$EXPECTED_STORAGE shared=$EXPECTED_SHARED fi @@ -847,6 +864,19 @@ assert_mounts_safe() { 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. diff --git a/manual/28-windows-vm.md b/manual/28-windows-vm.md index 29946466..0551b23f 100644 --- a/manual/28-windows-vm.md +++ b/manual/28-windows-vm.md @@ -4,7 +4,7 @@ Omarchy offers an easy way to run Windows through a Docker VM. You can install i Your machine needs KVM virtualization for this, which most do — but it's sometimes switched off in the BIOS, and the installer will tell you if that's the case. You'll also want the disk space: whatever you give Windows, plus about 10GB for the image itself. -The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`. +The installer asks how much RAM, how many CPU cores, and how much disk to hand over (64GB or more is the sensible floor), then for a Windows username and password. Leave those blank and you get `docker` / `admin`. The download takes a while — 10-15 minutes is normal — and you can follow the progress in the browser at `http://127.0.0.1:8006`. The browser prompts for the same username and password before opening the console. ![windows-vm](images/windows-vm.webp) @@ -34,7 +34,7 @@ Keep the disk and shared paths as separate, non-overlapping directories. Removal Before the VM starts, Omarchy opens and pins those two directories, then bind-mounts the exact directory inodes onto private per-user anchors below `/var/lib/omarchy/windows/mounts`. Docker only sees those root-protected anchors. This preserves custom disk locations while preventing another process running as you from swapping a checked path before the privileged container consumes it. Existing disk and shared directories are tightened to mode `0700` during migration so other local accounts cannot browse their contents. -The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. +The VM's ports are bound to localhost only, so nothing on your network can reach the Windows machine. The web console also requires the configured Windows username and password, preventing another local account from driving the VM through port 8006. ## Limits and licensing diff --git a/test/shell.d/windows-vm-compose-test.sh b/test/shell.d/windows-vm-compose-test.sh index 0495e1e0..64825526 100644 --- a/test/shell.d/windows-vm-compose-test.sh +++ b/test/shell.d/windows-vm-compose-test.sh @@ -61,6 +61,7 @@ grep -q 'image: dockurr/windows' "$COMPOSE" || fail "image is pinned" grep -q -- '- NET_ADMIN' "$COMPOSE" || fail "cap_add is pinned" grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE" || fail "storage uses the protected anchor" grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE" || fail "shared uses the protected anchor" +grep -q 'PROTECT: "Y"' "$COMPOSE" || fail "web console is not password protected" [[ ! -L $HOME/.windows && ! -L $HOME/Windows ]] || fail "fresh sources stay real directories" [[ $(stat -Lc '%d:%i' "$HOME/.windows") == $(stat -Lc '%d:%i' "$EXPECTED_STORAGE") ]] || fail "storage bind did not pin source" [[ $(stat -Lc '%d:%i' "$HOME/Windows") == $(stat -Lc '%d:%i' "$EXPECTED_SHARED") ]] || fail "shared bind did not pin source" @@ -150,10 +151,16 @@ sed -i "s|/etc:/shared|$EXPECTED_SHARED:/shared|" "$COMPOSE" printf ' - %s:/storage\n' "$EXPECTED_STORAGE" >>"$COMPOSE" assert_mounts_safe 2>/dev/null && fail "duplicate destination accepted" write 16G 6 128G legacyuser legacypass America/New_York +sed -i 's/PROTECT: "Y"/PROTECT: "N"/' "$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "unprotected web console accepted" +sed -i 's/PROTECT: "N"/PROTECT: "Y"/' "$COMPOSE" +printf ' PROTECT: "N"\n' >>"$COMPOSE" +assert_mounts_safe 2>/dev/null && fail "duplicate web protection setting accepted" +sed -i '$d' "$COMPOSE" chmod 0666 "$COMPOSE" assert_mounts_safe 2>/dev/null && fail "writable compose accepted" chmod 0640 "$COMPOSE" -pass "bring-up rejects tampered, duplicate, and writable compose inputs" +pass "bring-up rejects tampered, duplicate, unprotected, and writable compose inputs" # Both sources are pinned before a bind; bad symlinks stay untouched. reset_case diff --git a/test/shell.d/windows-vm-mount-boundary-test.sh b/test/shell.d/windows-vm-mount-boundary-test.sh index a49e5652..34f36cc2 100644 --- a/test/shell.d/windows-vm-mount-boundary-test.sh +++ b/test/shell.d/windows-vm-mount-boundary-test.sh @@ -156,13 +156,23 @@ pass "root writer and final pre-Docker guard revalidate the pinned production mo # moving or replacing either familiar home symlink. sed -i "s|$EXPECTED_STORAGE:/storage|$OLD_EXPECTED_STORAGE:/storage|" "$COMPOSE_FILE" sed -i "s|$EXPECTED_SHARED:/shared|$OLD_EXPECTED_SHARED:/shared|" "$COMPOSE_FILE" -compose_needs_mount_migration || fail "previous protected anchor pair was not recognized for upgrade" +sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE" +compose_needs_security_migration || fail "previous protected compose was not recognized for upgrade" with_vm_lock assert_mounts_safe || fail "root could not upgrade previous protected anchors" grep -q -- "- $EXPECTED_STORAGE:/storage" "$COMPOSE_FILE" || fail "upgrade did not rewrite storage anchor" grep -q -- "- $EXPECTED_SHARED:/shared" "$COMPOSE_FILE" || fail "upgrade did not rewrite shared anchor" +grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "upgrade did not protect the web console" [[ $(readlink /home/alice/.windows) == /home/storage-target ]] || fail "protected-anchor upgrade replaced home storage link" pass "previous sibling-anchor installs upgrade in place to the fixed /var/lib boundary" +# A compose that already uses the fixed anchors still needs an authorized +# upgrade when it predates web-console authentication. +sed -i '/PROTECT: "Y"/d' "$COMPOSE_FILE" +compose_needs_security_migration || fail "unprotected fixed-anchor compose was not recognized for upgrade" +with_vm_lock assert_mounts_safe || fail "root could not protect an existing fixed-anchor compose" +grep -q 'PROTECT: "Y"' "$COMPOSE_FILE" || fail "fixed-anchor upgrade did not protect the web console" +pass "existing fixed-anchor compose gains web-console authentication" + # Preflight both sources before either bind on a clean anchor pair. umount "$EXPECTED_SHARED" umount "$EXPECTED_STORAGE"