fleetd Windows MDM wake (push vs poll) (#46594)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #46567 and Resolves #46737 

Solution for the agressive polling:
- no WNS (although we could add it later as another avenue for
notifications)
- fleetd advertises a sync capability, persisted as
`mdm_windows_enrollments.fleetd_sync_capable`
- The management session relaxes the DMClient poll
(`poll_schedule_relaxed`)
- When an MDM command is queued, `has_pending_commands` flips, the next
orbit check-in returns `WindowsMDMSyncRequest`, and fleetd runs
`deviceenroller` to deliver it immediately
- older fleetd versions keep the 1-minute poll

Docs: https://github.com/fleetdm/fleet/pull/46780

Changes to osquery_perf and any additional changes after loadtesting
will be done in a separate PR.

# 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.

- [x] Timeouts are implemented and retries are limited to avoid infinite
loops

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

## fleetd/orbit/Fleet Desktop

- [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

* **New Features**
* On-demand Windows MDM sync: servers can request immediate delivery of
queued MDM commands to Windows clients; Orbit triggers client-side sync
on Windows.

* **Enhancements**
  * Orbit throttles per-device on-demand sync to avoid excessive runs.
* Server reconciles and persists device poll schedule (fast vs relaxed)
and exposes consolidated host MDM state (awaiting-configuration +
has-pending-commands).

* **Tests**
* Added tests covering host config state, pending-command flows,
poll-schedule toggling, and on-demand sync behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2026-06-03 17:38:14 -05:00
committed by GitHub
parent 2c56d54c9b
commit e20cedc8a0
24 changed files with 1158 additions and 165 deletions
+4
View File
@@ -13,3 +13,7 @@ func RunWindowsMDMUnenrollment(args WindowsMDMEnrollmentArgs) error {
func IsRunningOnWindowsServer() (bool, error) {
return false, nil
}
func TriggerWindowsMDMSync() error {
return nil
}
+96
View File
@@ -4,14 +4,21 @@
package update
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os/exec"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"unsafe"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
"github.com/rs/zerolog/log"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
@@ -190,3 +197,92 @@ func IsRunningOnWindowsServer() (bool, error) {
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
}
// windowsEnrollmentGUIDRe matches a standard enrollment GUID (8-4-4-4-12 hex). The matched subkey name becomes an argument to deviceenroller
// while orbit runs as SYSTEM, so we validate its shape before using it, even though writing the Enrollments key already requires admin.
var windowsEnrollmentGUIDRe = regexp.MustCompile(`^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$`)
// 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. 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)
}
// EnrollmentState == 1 is the active state observed for Fleet's MDM enrollment on tested Windows builds; the registry DWORD under
// Enrollments is not authoritatively documented by Microsoft. The ProviderID == "Fleet" check in the loop below scopes the match to
// Fleet's own enrollment, so this never selects an unrelated (e.g. Intune) enrollment that might use a different state value.
const enrollmentStateActive = 1
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()
if providerErr == nil && stateErr == nil && providerID == syncml.DocProvisioningAppProviderID && state == enrollmentStateActive {
// Don't hand a malformed subkey name to deviceenroller; skip it and keep looking for a well-formed enrollment GUID.
if !windowsEnrollmentGUIDRe.MatchString(name) {
continue
}
return name, nil
}
}
return "", errors.New("no active Fleet MDM enrollment found in registry")
}
+67 -2
View File
@@ -30,8 +30,7 @@ type checkAssignedEnrollmentProfileFunc func(url string) error
// It ensures only one renewal command is executed at any given time, and that
// it doesn't re-execute the command until a certain amount of time has passed.
type renewEnrollmentProfileConfigReceiver struct {
// Frequency is the minimum amount of time that must pass between two
// executions of the profile renewal command.
// Frequency is the minimum amount of time that must pass between two executions of the profile renewal command.
Frequency time.Duration
// for tests, to be able to mock command execution. If nil, will use
@@ -272,6 +271,72 @@ func (w *windowsMDMEnrollmentConfigReceiver) attemptUnenrollment(actionLabel str
}
}
// execSyncFunc starts an on-demand Windows MDM (OMA-DM) session with the Fleet server. Indirected so tests can mock it; if nil the receiver
// uses TriggerWindowsMDMSync.
type execSyncFunc func() error
// windowsMDMSyncConfigReceiver reacts to the server's WindowsMDMSyncRequest notification by starting an on-demand OMA-DM session, so queued
// Windows MDM commands are delivered without waiting for the device's next scheduled poll. This is what lets the server relax the
// aggressive Windows MDM poll while keeping command latency low.
type windowsMDMSyncConfigReceiver struct {
// Frequency is the minimum amount of time that must pass between two on-demand sync attempts, so a
// notification that lingers for a few config polls (before the server observes the command as acked) does
// not trigger back-to-back sessions.
Frequency time.Duration
// for tests, to mock the sync trigger. If nil, uses TriggerWindowsMDMSync.
execSyncFn execSyncFunc
// Held for the duration of the async sync goroutine so only one sync runs at a time; also protects lastRun.
mu sync.Mutex
lastRun time.Time
}
func ApplyWindowsMDMSyncFetcherMiddleware(frequency time.Duration) fleet.OrbitConfigReceiver {
return &windowsMDMSyncConfigReceiver{Frequency: frequency}
}
// Run starts an on-demand Windows MDM sync when the server sets WindowsMDMSyncRequest, so queued commands are delivered promptly even when
// the device's OMA-DM poll schedule has been relaxed. It returns immediately; the sync itself runs in the background (see attemptSync).
func (w *windowsMDMSyncConfigReceiver) Run(cfg *fleet.OrbitConfig) error {
if cfg.Notifications.WindowsMDMSyncRequest {
w.attemptSync()
}
return nil
}
func (w *windowsMDMSyncConfigReceiver) attemptSync() {
// TryLock keeps a single sync in flight: if one is already running, drop this attempt instead of piling up sessions.
if !w.mu.TryLock() {
return
}
// do not attempt a sync if the last run is not at least Frequency ago.
if time.Since(w.lastRun) <= w.Frequency {
log.Debug().Msg("skipped on-demand Windows MDM sync, last run was too recent")
w.mu.Unlock()
return
}
fn := w.execSyncFn
if fn == nil {
fn = TriggerWindowsMDMSync
}
// Run the sync in the background so deviceenroller latency never gates the config-receiver loop
go func() {
defer w.mu.Unlock()
if err := fn(); err != nil {
// lastRun is intentionally not updated on failure, so the next config poll retries while the command is still queued (matches the
// enrollment receiver's behavior).
log.Info().Err(err).Msg("triggering on-demand Windows MDM sync failed")
return
}
w.lastRun = time.Now()
log.Info().Msg("triggered on-demand Windows MDM sync")
}()
}
type runScriptsConfigReceiver struct {
// ScriptsExecutionEnabled indicates if this agent allows scripts execution.
// If it doesn't, scripts are not executed, but a response is returned to the
+100
View File
@@ -819,3 +819,103 @@ func TestBitlockerOperations(t *testing.T) {
})
}
func TestWindowsMDMSync(t *testing.T) {
var logBuf bytes.Buffer
oldLog := log.Logger
log.Logger = log.Output(&logBuf)
t.Cleanup(func() { log.Logger = oldLog })
syncCfg := func(req bool) *fleet.OrbitConfig {
return &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{WindowsMDMSyncRequest: req}}
}
// newCounting builds a receiver whose sync increments calls and returns err: the common shape for most subtests below.
newCounting := func(freq time.Duration, err error) (*windowsMDMSyncConfigReceiver, *atomic.Int32) {
var calls atomic.Int32
r := &windowsMDMSyncConfigReceiver{Frequency: freq, execSyncFn: func() error { calls.Add(1); return err }}
return r, &calls
}
// The sync runs in a background goroutine that holds w.mu until it finishes. waitIdle blocks until that goroutine has released the
// lock, so afterwards the test can read the call counter, lastRun, and the shared log buffer without racing the goroutine's writes.
// Subtests must stay sequential (no t.Parallel): they share logBuf and the global log.Logger.
waitIdle := func(t *testing.T, r *windowsMDMSyncConfigReceiver) {
t.Helper()
require.Eventually(t, func() bool {
if r.mu.TryLock() {
r.mu.Unlock()
return true
}
return false
}, 2*time.Second, time.Millisecond)
}
// runSync delivers cfg and blocks until any spawned sync goroutine has finished.
runSync := func(t *testing.T, r *windowsMDMSyncConfigReceiver, req bool) {
t.Helper()
require.NoError(t, r.Run(syncCfg(req)))
waitIdle(t, r)
}
t.Run("no sync request does not trigger", func(t *testing.T) {
logBuf.Reset()
r, calls := newCounting(time.Hour, nil)
runSync(t, r, false)
require.Equal(t, int32(0), calls.Load())
})
t.Run("sync request triggers once and records the run", func(t *testing.T) {
logBuf.Reset()
r, calls := newCounting(time.Hour, nil)
runSync(t, r, true)
require.Equal(t, int32(1), calls.Load())
require.False(t, r.lastRun.IsZero(), "a successful sync must record lastRun")
})
t.Run("second run within frequency is throttled", func(t *testing.T) {
logBuf.Reset()
r, calls := newCounting(time.Hour, nil)
runSync(t, r, true)
runSync(t, r, true)
require.Equal(t, int32(1), calls.Load(), "second run within Frequency should be throttled")
})
t.Run("not throttled once frequency elapsed", func(t *testing.T) {
logBuf.Reset()
r, calls := newCounting(0, nil)
runSync(t, r, true)
time.Sleep(time.Millisecond)
runSync(t, r, true)
require.Equal(t, int32(2), calls.Load())
})
t.Run("sync already in flight is dropped", func(t *testing.T) {
logBuf.Reset()
var calls atomic.Int32
release, started := make(chan struct{}), make(chan struct{})
r := &windowsMDMSyncConfigReceiver{Frequency: 0, execSyncFn: func() error {
calls.Add(1)
close(started)
<-release // hold w.mu so a concurrent Run observes TryLock failing
return nil
}}
require.NoError(t, r.Run(syncCfg(true))) // returns immediately; the sync goroutine now holds w.mu
<-started
require.NoError(t, r.Run(syncCfg(true))) // a sync is in flight, so this attempt must be dropped, not queued
require.Equal(t, int32(1), calls.Load(), "a second sync must not start while one is in flight")
close(release)
waitIdle(t, r)
require.Equal(t, int32(1), calls.Load())
})
t.Run("failure does not set lastRun and retries on next run", func(t *testing.T) {
logBuf.Reset()
r, calls := newCounting(time.Hour, io.ErrUnexpectedEOF)
runSync(t, r, true)
runSync(t, r, true)
require.Equal(t, int32(2), calls.Load(), "failed sync should not be throttled on the next run")
require.True(t, r.lastRun.IsZero(), "lastRun must remain unset after failures")
require.Contains(t, logBuf.String(), "triggering on-demand Windows MDM sync failed")
})
}