Files
fleet/orbit/pkg/update/execwinapi_windows.go
Victor Lyuboslavsky 3aff550422 Fixed a bug where fleetd could not start on-demand Windows MDM session (#48765)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #48760 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

## fleetd/orbit/Fleet Desktop

Note: changes are Windows only; only verified/tested these items for
Linux and Windows

- [x] Verified compatibility with the latest released version of Fleet
(see [Must
rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md))
- [x] If the change applies to only one platform, confirmed that
`runtime.GOOS` is used as needed to isolate changes
- [x] Verified that fleetd runs on macOS, Linux and Windows
- [x] Verified auto-update works from the released version of component
to the new version (see [tools/tuf/test](../tools/tuf/test/README.md))


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed an issue where some Windows MDM enrollment sessions could stay
queued or fail to start on certain devices.
* Improved detection of active Windows MDM enrollment, helping commands
run more reliably when enrollment is already present.
* **Tests**
* Added coverage for Windows enrollment detection to prevent
regressions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-07 08:12:28 +01:00

276 lines
11 KiB
Go

//go:build windows
// nolint:gosec,G103 // Reason: unsafe required for Windows API calls.
package update
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"unsafe"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/rs/zerolog/log"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
var (
dllMDMRegistration *windows.LazyDLL = windows.NewLazySystemDLL("mdmregistration.dll")
// RegisterDeviceWithManagement registers a device with a MDM service:
// https://learn.microsoft.com/en-us/windows/win32/api/mdmregistration/nf-mdmregistration-registerdevicewithmanagement
procRegisterDeviceWithManagement *windows.LazyProc = dllMDMRegistration.NewProc("RegisterDeviceWithManagement")
// UnregisterDeviceWithManagement unregisters a device from a MDM service:
// https://learn.microsoft.com/en-us/windows/win32/api/mdmregistration/nf-mdmregistration-unregisterdevicewithmanagement
procUnregisterDeviceWithManagement *windows.LazyProc = dllMDMRegistration.NewProc("UnregisterDeviceWithManagement")
)
// Exported so that it can be used in tools/ (so that it can be built for
// Windows and tested on a Windows machine). Otherwise not meant to be called
// from outside this package.
func RunWindowsMDMEnrollment(args WindowsMDMEnrollmentArgs) error {
installType, err := readInstallationType()
if err != nil {
return err
}
if strings.ToLower(installType) == "server" {
// do not enroll, it is a server
return errIsWindowsServer
}
return enrollHostToMDM(args)
}
// Exported so that it can be used in tools/ (so that it can be built for
// Windows and tested on a Windows machine). Otherwise not meant to be called
// from outside this package.
func RunWindowsMDMUnenrollment(args WindowsMDMEnrollmentArgs) error {
installType, err := readInstallationType()
if err != nil {
return err
}
if strings.ToLower(installType) == "server" {
// do not unenroll, it is a server
return errIsWindowsServer
}
return unenrollHostFromMDM()
}
func readInstallationType() (string, error) {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE)
if err != nil {
return "", err
}
defer k.Close()
s, _, err := k.GetStringValue("InstallationType")
if err != nil {
return "", err
}
return s, nil
}
// TODO(mna): refactor to a Windows-specific package to constrain usage of
// unsafe to that package, once https://github.com/fleetdm/fleet/pull/12387
// lands.
// Perform the host MDM enrollment process using MS-MDE protocol:
// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde/5c841535-042e-489e-913c-9d783d741267
func enrollHostToMDM(args WindowsMDMEnrollmentArgs) error {
discoveryURLPtr, err := syscall.UTF16PtrFromString(args.DiscoveryURL)
if err != nil {
return fmt.Errorf("discovery URL to UTF16 pointer: %w", err)
}
// we use an empty user UPN, it is not a required argument
userPtr, err := syscall.UTF16PtrFromString("")
if err != nil {
return fmt.Errorf("user UPN to UTF16 pointer: %w", err)
}
accessTok, err := generateWindowsMDMAccessTokenPayload(args)
if err != nil {
return fmt.Errorf("generate access token payload: %w", err)
}
accessTokPtr, err := syscall.UTF16PtrFromString(string(accessTok))
if err != nil {
return fmt.Errorf("access token to UTF16 pointer: %w", err)
}
// pre-load the DLL and pre-find the procedure, to return a more meaningful
// message if those steps fail and avoid a panic (those are no-ops once
// loaded/found).
if err := dllMDMRegistration.Load(); err != nil {
return fmt.Errorf("load MDM dll: %w", err)
}
if err := procRegisterDeviceWithManagement.Find(); err != nil {
return fmt.Errorf("find MDM RegisterDeviceWithManagement procedure: %w", err)
}
code, _, err := procRegisterDeviceWithManagement.Call(
uintptr(unsafe.Pointer(userPtr)),
uintptr(unsafe.Pointer(discoveryURLPtr)),
uintptr(unsafe.Pointer(accessTokPtr)),
)
log.Debug().Msgf("RegisterDeviceWithManagement returned code: %#x ; message: %v", code, err)
if code != uintptr(windows.ERROR_SUCCESS) {
return improveWindowsAPIError("RegisterDeviceWithManagement", args.DiscoveryURL, code, err)
}
return nil
}
// Perform the host MDM unenrollment process using MS-MDE protocol:
// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde/5c841535-042e-489e-913c-9d783d741267
func unenrollHostFromMDM() error {
// pre-load the DLL and pre-find the procedure, to return a more meaningful
// message if those steps fail and avoid a panic (those are no-ops once
// loaded/found).
if err := dllMDMRegistration.Load(); err != nil {
return fmt.Errorf("load MDM dll: %w", err)
}
if err := procUnregisterDeviceWithManagement.Find(); err != nil {
return fmt.Errorf("find MDM UnregisterDeviceWithManagement procedure: %w", err)
}
// must explicitly pass 0 here, see for details:
// https://github.com/fleetdm/fleet/issues/12342#issuecomment-1608190367
code, _, err := procUnregisterDeviceWithManagement.Call(0)
log.Debug().Msgf("UnregisterDeviceWithManagement returned code: %#x ; message: %v", code, err)
if code != uintptr(windows.ERROR_SUCCESS) {
return improveWindowsAPIError("UnregisterDeviceWithManagement", "", code, err)
}
return nil
}
func improveWindowsAPIError(apiFunc, discoURL string, code uintptr, err error) error {
// hexadecimal error code can help identify error here:
// https://learn.microsoft.com/en-us/windows/win32/mdmreg/mdm-registration-constants
// decimal error code can help identify error here (look for the ERROR_xxx constants):
// https://pkg.go.dev/golang.org/x/sys/windows#pkg-constants
//
// Note that the error message may be "The operation completed
// successfully." even though there is an error (e.g. if the discovery URL
// results in a 404 not found, the error code will be 0x80190194 which
// means windows.HTTP_E_STATUS_NOT_FOUND). In this case, translate the
// message to something more useful.
if httpCode := code - uintptr(windows.HTTP_E_STATUS_BAD_REQUEST); httpCode < 200 {
// status bad request is 400, so if error code is between 400 and < 600.
if discoURL != "" {
err = fmt.Errorf("using discovery URL %q: HTTP error code %d", discoURL, http.StatusBadRequest+httpCode)
} else {
err = fmt.Errorf("HTTP error code %d", http.StatusBadRequest+httpCode)
}
}
return fmt.Errorf("%s failed: %s (%#x - %[3]d)", apiFunc, err, code)
}
func generateWindowsMDMAccessTokenPayload(args WindowsMDMEnrollmentArgs) ([]byte, error) {
var pld fleet.WindowsMDMAccessTokenPayload
pld.Type = fleet.WindowsMDMProgrammaticEnrollmentType // always programmatic for now
pld.Payload.OrbitNodeKey = args.OrbitNodeKey
return json.Marshal(pld)
}
// IsRunningOnWindowsServer determines if the process is running on a Windows
// server. Exported so it can be used across packages.
func IsRunningOnWindowsServer() (bool, error) {
installType, err := readInstallationType()
if err != nil {
return false, err
}
if strings.ToLower(installType) == "server" {
return true, nil
}
return false, nil
}
// windowsMDMSyncTriggerTimeout bounds the deviceenroller invocation. attemptSync runs it in a background goroutine that holds the receiver's
// lock, so this caps how long that goroutine (and the single-flight lock) stays tied up if deviceenroller ever hangs. `deviceenroller /o /c`
// returns within seconds in practice; the timeout is a generous backstop, not the expected duration.
const windowsMDMSyncTriggerTimeout = 2 * time.Minute
// TriggerWindowsMDMSync starts an on-demand, client-initiated OMA-DM session with the Fleet MDM server so that queued Windows MDM commands
// are delivered without waiting for the device's next scheduled poll. It runs the OS deviceenroller for Fleet's enrollment in
// client-initiated mode: `deviceenroller.exe /o <EnrollmentGUID> /c`.
//
// Microsoft does not publicly document the deviceenroller flags; `/o <GUID> /c` was established empirically and validated end-to-end on
// Windows 11 24H2 (build 26100) and 25H2 (build 26200). On a warm device it reliably starts a session. On a just-booted or just-resumed
// device the same call can exit 0 without actually starting a session, so a nil return here is best-effort, not a guarantee of delivery.
// Delivery is ultimately guaranteed by the server continuing to request a sync while the command is queued (orbit re-fires on the next
// config poll) and by the relaxed scheduled poll as a floor.
//
// Exported so it can be built/tested for Windows from tools/. Not meant to be called from outside this package.
func TriggerWindowsMDMSync() error {
guid, err := fleetMDMEnrollmentGUID()
if err != nil {
return fmt.Errorf("find Fleet MDM enrollment: %w", err)
}
// Resolve System32 via the Windows API rather than the process environment, so the path we execute as SYSTEM can't be redirected by a
// tampered SystemRoot. orbit is a 64-bit process, so this is the real (64-bit) System32 that contains deviceenroller.exe; there is no
// WOW64 redirection to account for. If the API ever fails, use the hardcoded default rather than trusting an environment variable.
systemDir, sysDirErr := windows.GetSystemDirectory()
if sysDirErr != nil || systemDir == "" {
systemDir = `C:\Windows\System32`
}
deviceEnroller := filepath.Join(systemDir, "deviceenroller.exe")
// Bound the call so a hung deviceenroller cannot block the config-receiver loop indefinitely.
ctx, cancel := context.WithTimeout(context.Background(), windowsMDMSyncTriggerTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, deviceEnroller, "/o", guid, "/c")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
if out, err := cmd.CombinedOutput(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("run deviceenroller /o %s /c timed out after %s (output: %q)", guid, windowsMDMSyncTriggerTimeout, string(out))
}
return fmt.Errorf("run deviceenroller /o %s /c: %w (output: %q)", guid, err, string(out))
}
return nil
}
// fleetMDMEnrollmentGUID returns the enrollment GUID of the active Fleet Windows MDM enrollment by scanning
// HKLM\SOFTWARE\Microsoft\Enrollments for the subkey whose ProviderID is Fleet's and whose EnrollmentState is active (see
// isActiveFleetEnrollment). The subkey name is the enrollment GUID that deviceenroller's /o argument expects.
func fleetMDMEnrollmentGUID() (string, error) {
const enrollmentsPath = `SOFTWARE\Microsoft\Enrollments`
root, err := registry.OpenKey(registry.LOCAL_MACHINE, enrollmentsPath, registry.READ)
if err != nil {
return "", fmt.Errorf("open enrollments registry key: %w", err)
}
defer root.Close()
names, err := root.ReadSubKeyNames(-1)
if err != nil {
return "", fmt.Errorf("read enrollment subkeys: %w", err)
}
for _, name := range names {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, enrollmentsPath+`\`+name, registry.QUERY_VALUE)
if err != nil {
continue
}
providerID, _, providerErr := k.GetStringValue("ProviderID")
state, _, stateErr := k.GetIntegerValue("EnrollmentState")
k.Close()
// A malformed subkey name (not a valid GUID) makes isActiveFleetEnrollment return false, so we keep scanning for a well-formed one.
if providerErr == nil && stateErr == nil && isActiveFleetEnrollment(providerID, state, name) {
return name, nil
}
}
return "", errors.New("no active Fleet MDM enrollment found in registry")
}