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:
@@ -13,3 +13,7 @@ func RunWindowsMDMUnenrollment(args WindowsMDMEnrollmentArgs) error {
|
||||
func IsRunningOnWindowsServer() (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func TriggerWindowsMDMSync() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user