Protect the Windows VM web console
This commit is contained in:
committed by
Afonso Oliveira
parent
4fc14173b7
commit
bf10b75150
+42
-12
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user