Skip to content

Unattended domain join and domain logon for the Windows VM - #10962

Open
ekollof wants to merge 13 commits into
omacom:quattrofrom
ekollof:windows-vm-domain-join
Open

ekollof wants to merge 13 commits into
omacom:quattrofrom
ekollof:windows-vm-domain-join

Conversation

@ekollof

@ekollof ekollof commented Sep 9, 2026

Copy link
Copy Markdown

Summary

Adds domain-join and domain-logon support to the Windows VM (omarchy-windows-vm), so a freshly installed VM behaves like any other domain-joined workstation.

  • The install wizard offers joining a Windows domain. When a domain is given, it collects an optional OU, a join account (domain admin or delegated account, UPN or bare form), and the domain user to sign in with over RDP — stored separately from the join credentials.
  • The validated privileged compose writer accepts optional DOMAIN, DOMAIN_OU, AUTOLOGIN=N and COMMAND keys and passes them to the underlying dockur image, which performs an unattended join during installation (Microsoft-Windows-UnattendedJoin). With no domain set the compose output is byte-identical to today's.
  • A new omarchy-windows-vm credentials subcommand updates the RDP sign-in identity after a join (or any password change).
  • Domain-joined VMs connect over a TLS transport and log on inside the session; local-account VMs keep NLA. The launcher also writes a matching Kerberos config (~/.config/windows/krb5.conf) for the domain case.
  • Domain installs run a guest command at the end of the unattended installation that adds Authenticated Users to the local Remote Desktop Users group, giving RDP logon the same rights as console logon.
  • Console autologin is disabled for domain installs (no local account exists after a join); local installs get a keep/drop prompt.
  • Account-name validation now follows Windows rules (periods allowed mid-name, 20-char sAMAccountName cap, 256-char UPN user part).

Testing

  • Extended the shell suites: domain compose emission/omission, writer rejections (bad FQDN/OU/UPN, default join credentials, non-N autologin), OU/COMMAND escaping, credential domain round-trip, and account-name validation cases.
  • ./test/cli, test/shell.d/windows-vm-{test,compose-test}.sh, privileged-heredoc-test.sh pass.
  • Exercised end-to-end against a live Samba AD domain over a relayed VPN: unattended join, RDP logon as a domain user, credential rotation, and console (port 8006) access with the join account.
  • Note: test/shell.d/windows-vm-mount-boundary-test.sh currently fails on a machine that has a running host VM (its uid-1000 fixture collides with the host's mounted production anchors). This is pre-existing on quattro, not touched by this PR — the fixture-uid selection fix rides in Fix Windows VM helper rejecting setgid source directories #9783.

Discussion

Why a TLS transport instead of NLA for domain VMs. CredSSP requires the guest to validate the logon against the domain controller before the session starts. FreeRDP's transport read budget (~200 ms) expires while the guest is still round-tripping to the DC whenever the DC path is slow — in my case a relayed WireGuard (netbird) link — and the connection is torn down mid-handshake ("BIO_read retries exceeded"). The guest's RDP stack additionally rejected CredSSP data with ACCESS_DENIED in some attempts (RdpCoreTS FEventProcessRecvdDataFailed, 0x80070005). A TLS transport carries no pre-logon exchange: the Windows logon screen appears inside the session and waits however long the domain lookup takes. Trade-off: credentials are typed at the logon screen rather than passed by the launcher; NLA is kept for local-account VMs where the same race does not exist.

Why the RDP grant is broad by default. Console logon on a domain machine works for every domain user, but RDP is gated by the Remote Desktop Services right, held by default only by Administrators and the local Remote Desktop Users group. Granting Authenticated Users (S-1-5-11) makes RDP behave like walking up to the machine. The VM's ports are bound to localhost only; the manual documents how to narrow it to specific accounts. Happy to flip the default to the login user only if the broad grant is a concern.

Kerberos notes. MIT krb5 1.21+ discards AS replies whose client name does not echo the request verbatim; AD canonicalizes UPNs, so the launcher's generated krb5.conf sets canonicalize = true (plus rdns = false, TCP-only, and [domain_realm] mapping). FreeRDP also derives its SPN from the canonical resolver name, which makes a hosts-alias Kerberos path unreliable — another reason the TLS transport is the dependable route.

Credentials handling. The join credentials (often a domain admin) live in the root-owned compose, same trust model as the existing password; the RDP login user lives in the user-private 0600 credentials file, with a UPN fallback for files written before the domain field existed.

Limits

  • Windows 11 Pro only (the compose pins VERSION: "11"); the mechanics are version-agnostic but only this target is wired.
  • Applies to fresh installs; an existing VM can join from inside Windows (Settings → System → About) and use the new credentials subcommand.
  • Entra ID/AAD-only join is out of scope (no on-prem DC involved).

@ekollof
ekollof force-pushed the windows-vm-domain-join branch from 7ba39ec to f144312 Compare September 9, 2026 11:58
@ekollof
ekollof marked this pull request as ready for review September 10, 2026 14:08
emielkollof-cs and others added 11 commits September 16, 2026 02:06
GNU chmod leaves setuid/setgid on directories for numeric modes of four
digits or fewer, so chmod 0700 cannot satisfy the exact-700 mount check
when ~/Windows was created with g+s. Harden with a-s,u=rwx,go= and
print the observed modes when the check still fails.
samba.sh treats an empty /shared bind as uninitialized and chmod 2777s
it at container start, undoing the host 700 privacy check after every
launch. Keep a hidden sentinel in the share and re-harden the directory
after docker compose up.
Creating ~/.omarchy-keep as root in a caller-owned directory is a
symlink-follow write primitive. Restore mode 700 on the pinned
directory inodes after the guest reports ready, and never fail a
successful start on that chmod.
restore_shared_privacy also chmodded $LEGACY_SHARED, which is $HOME/Windows: a pathname the unprivileged caller owns. The [[ -d && ! -L ]] test and the chmod are two syscalls, so the caller can swap the directory for a symlink in between and make the root half of __priv_up_wait chmod an arbitrary path to 0700 with the set-ID bits cleared. On a worker a swapper loop won that race on its 260th iteration, taking a root-owned 4755 binary outside the caller's home to root:700.

The loop's other element already covers the case. $EXPECTED_SHARED sits in the root-owned 0711 boundary tree the caller cannot write, and assert_mounts_safe has just proved through mounts_ready that it is a bind of the same inode as $LEGACY_SHARED — so chmodding the anchor is what ~/Windows ends up at, measured rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
install uses priv up, which returned as soon as the container started
and never re-hardened ~/Windows after samba.sh chmod 2777. Wait for
that 2777 (or the shared-folder log line) before restoring, and also
restore after dc down and when the guest-ready wait times out.
The 15s wait on priv up expires before dockur finishes the ISO
download, so samba.sh still chmod 2777s afterwards. Watch the
caller's share in the background after install and restore as the
owner. On stop, re-harden every protected per-uid share under the
runtime mounts tree instead of resolve_caller (no PKEXEC_UID under
direct sudo, and a second user would restore the wrong anchor).
disown only stops bash from hupping a background job when the shell itself exits. The watcher runs in the install terminal's foreground process group, so the kernel hangs it up when that terminal goes away — and install is launched by omarchy-launch-floating-terminal-with-presentation, which closes as soon as the user dismisses the "Press any key" prompt, minutes before dockur's samba.sh reaches the chmod 2777 the watcher exists to undo. Ignoring SIGHUP in the subshell is what disown was reaching for. An asynchronous command already ignores SIGINT and SIGQUIT when job control is off, so HUP is the only gap.

Measured under a pty: without the trap the share is still 2777 four seconds after the terminal exits; with it the watcher restores 700. The new test reproduces that shape with script(1) and fails when the trap is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ekollof
ekollof force-pushed the windows-vm-domain-join branch from 9f2550d to 0521e09 Compare September 16, 2026 00:09
@ekollof

ekollof commented Sep 16, 2026

Copy link
Copy Markdown
Author

Re-stacked onto the rebased setgid branch and current quattro (2fbac0c); the branch is linear again (merge commit removed). The RDP change now builds on the /args-from password fix: the /sec:tls vs /sec:nla transport selection is appended to RDP_ARGS. windows-vm-test.sh, windows-vm-compose-test.sh and windows-vm-mount-boundary-test.sh pass.

Emiel Kollof added 2 commits September 16, 2026 20:44
The install wizard offers joining a Windows domain. When a domain is
given, the username/password pair becomes the unattended join
credential carried through the validated privileged writer as
DOMAIN/DOMAIN_OU, the console autologin is dropped (no local account
exists after a join), and the domain user to sign in with is stored
separately in the private credentials file. The launcher connects
domain VMs over a TLS transport so the logon happens inside the
session, where the Windows logon screen waits as long as the domain
lookup takes -- the pre-logon CredSSP exchange cannot survive a
high-latency domain controller path. A guest command runs at the end
of the installation to grant all authenticated accounts the Remote
Desktop Services right, and a new credentials subcommand updates the
RDP sign-in identity afterwards. Account-name validation follows
Windows rules (periods, 20-char sAMAccountName, 256-char UPN).

With no domain configured the compose output is byte-identical to
before.
@ekollof
ekollof force-pushed the windows-vm-domain-join branch from 0521e09 to 7a38223 Compare September 16, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants