Files
fleet/orbit/pkg/update/notifications.go
T
Victor Lyuboslavsky e20cedc8a0 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 -->
2026-06-03 17:38:14 -05:00

728 lines
27 KiB
Go

package update
import (
"errors"
"runtime"
"sync"
"sync/atomic"
"time"
"github.com/fleetdm/fleet/v4/orbit/pkg/bitlocker"
"github.com/fleetdm/fleet/v4/orbit/pkg/profiles"
"github.com/fleetdm/fleet/v4/orbit/pkg/scripts"
fleetscripts "github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/rs/zerolog/log"
)
type runCmdFunc func() error
type checkEnrollmentFunc func() (bool, string, error)
type checkAssignedEnrollmentProfileFunc func(url string) error
// renewEnrollmentProfileConfigReceiver is a kind of middleware that wraps an
// OrbitConfigFetcher and detects if the fleet server sent a notification to
// renew the enrollment profile. If so, it runs the command (as root) to
// bootstrap the renewal of the profile on the device (the user still needs to
// execute some manual steps to accept the new profile).
//
// 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 time.Duration
// for tests, to be able to mock command execution. If nil, will use
// runRenewEnrollmentProfile.
runCmdFn runCmdFunc
// for tests, to be able to mock the function that checks for Fleet
// enrollment
checkEnrollmentFn checkEnrollmentFunc
// for tests, to be able to mock the function that checks for the assigned enrollment profile
checkAssignedEnrollmentProfileFn checkAssignedEnrollmentProfileFunc
// ensures only one command runs at a time, protects access to lastRun
cmdMu sync.Mutex
lastRun time.Time
fleetURL string
}
func ApplyRenewEnrollmentProfileConfigFetcherMiddleware(fetcher OrbitConfigFetcher, frequency time.Duration, fleetURL string) fleet.OrbitConfigReceiver {
return &renewEnrollmentProfileConfigReceiver{Frequency: frequency, fleetURL: fleetURL}
}
func (h *renewEnrollmentProfileConfigReceiver) Run(config *fleet.OrbitConfig) error {
if config.Notifications.RenewEnrollmentProfile {
if h.cmdMu.TryLock() {
defer h.cmdMu.Unlock()
// Note that the macOS notification popup will be shown periodically
// until the Fleet server gets notified that the device is now properly
// enrolled (after the user's manual steps, and osquery reporting the
// updated mdm enrollment).
// See https://github.com/fleetdm/fleet/pull/9409#discussion_r1084382455
if time.Since(h.lastRun) >= h.Frequency {
// we perform this check locally on the client too to avoid showing the
// dialog if the client is enrolled to an MDM server.
enrollFn := h.checkEnrollmentFn
if enrollFn == nil {
enrollFn = profiles.IsEnrolledInMDM
}
enrolled, mdmServerURL, err := enrollFn()
if err != nil {
log.Error().Err(err).Msg("fetching enrollment status")
return nil
}
if enrolled {
log.Info().Msgf("a request to renew the enrollment profile was processed but not executed because the host is enrolled into an MDM server with URL: %s", mdmServerURL)
h.lastRun = time.Now().Add(-h.Frequency).Add(2 * time.Minute)
return nil
}
// we perform this check locally on the client too to avoid showing the
// dialog if the Fleet enrollment profile has not been assigned to the device in
// Apple Business.
assignedFn := h.checkAssignedEnrollmentProfileFn
if assignedFn == nil {
assignedFn = profiles.CheckAssignedEnrollmentProfile
}
if err := assignedFn(h.fleetURL); err != nil {
log.Error().Err(err).Msg("checking assigned enrollment profile")
log.Info().Msg("a request to renew the enrollment profile was processed but not executed because there was an error checking the assigned enrollment profile.")
// TODO: Design a better way to backoff `profiles show` so that the device doesn't get rate
// limited by Apple. For now, wait at least 2 minutes before retrying.
h.lastRun = time.Now().Add(-h.Frequency).Add(2 * time.Minute)
return nil
}
fn := h.runCmdFn
if fn == nil {
fn = runRenewEnrollmentProfile
}
if err := fn(); err != nil {
log.Info().Err(err).Msg("calling /usr/bin/profiles to renew enrollment profile failed")
// TODO: Design a better way to backoff `profiles show` so that the device doesn't get rate
// limited by Apple. For now, wait at least 2 minutes before retrying.
h.lastRun = time.Now().Add(-h.Frequency).Add(2 * time.Minute)
return nil
}
h.lastRun = time.Now()
log.Info().Msg("successfully called /usr/bin/profiles to renew enrollment profile")
} else {
log.Debug().Msg("skipped calling /usr/bin/profiles to renew enrollment profile, last run was too recent")
}
}
}
return nil
}
type execWinAPIFunc func(WindowsMDMEnrollmentArgs) error
type windowsMDMEnrollmentConfigReceiver struct {
// Frequency is the minimum amount of time that must pass between two
// executions of the windows MDM enrollment attempt.
Frequency time.Duration
// HostUUID is the current host's UUID.
HostUUID string
// OrbitNodeKey is the current host's orbit node key.
nodeKeyGetter OrbitNodeKeyGetter
// for tests, to be able to mock API commands. If nil, will use
// RunWindowsMDMEnrollment and RunWindowsMDMUnenrollment respectively.
execEnrollFn execWinAPIFunc
execUnenrollFn execWinAPIFunc
// ensures only one command runs at a time, protects access to lastXxxRun and
// isWindowsServer.
mu sync.Mutex
lastEnrollRun time.Time
lastUnenrollRun time.Time
isWindowsServer bool
}
type OrbitNodeKeyGetter interface {
GetNodeKey() (string, error)
}
func ApplyWindowsMDMEnrollmentFetcherMiddleware(
frequency time.Duration,
hostUUID string,
nodeKeyGetter OrbitNodeKeyGetter,
) fleet.OrbitConfigReceiver {
return &windowsMDMEnrollmentConfigReceiver{
Frequency: frequency,
HostUUID: hostUUID,
nodeKeyGetter: nodeKeyGetter,
}
}
var errIsWindowsServer = errors.New("device is a Windows Server")
// Run checks if the fleet server set the "needs windows {un}enrollment" flag
// to true, and executes the command to {un}enroll into Windows MDM (or not, if
// the device is a Windows Server). It also unenrolls the device if the flag
// "needs MDM migration" is set to true, so that the device can then be
// enrolled in Fleet MDM.
func (w *windowsMDMEnrollmentConfigReceiver) Run(cfg *fleet.OrbitConfig) error {
switch {
case cfg.Notifications.NeedsProgrammaticWindowsMDMEnrollment:
w.attemptEnrollment(cfg.Notifications)
case cfg.Notifications.NeedsProgrammaticWindowsMDMUnenrollment,
cfg.Notifications.NeedsMDMMigration:
label := "unenroll"
if cfg.Notifications.NeedsMDMMigration {
label = "migrate"
}
w.attemptUnenrollment(label)
}
return nil
}
func (w *windowsMDMEnrollmentConfigReceiver) attemptEnrollment(notifs fleet.OrbitConfigNotifications) {
if notifs.WindowsMDMDiscoveryEndpoint == "" {
log.Info().Err(errors.New("discovery endpoint is missing")).Msg("skipping enrollment, discovery endpoint is empty")
return
}
if w.mu.TryLock() {
defer w.mu.Unlock()
// do not enroll Windows Servers, and do not attempt enrollment if the last
// run is not at least Frequency ago.
if w.isWindowsServer {
log.Debug().Msg("skipped calling RegisterDeviceWithManagement to enroll Windows device, device is a server")
return
}
if time.Since(w.lastEnrollRun) <= w.Frequency {
log.Debug().Msg("skipped calling RegisterDeviceWithManagement to enroll Windows device, last run was too recent")
return
}
nodeKey, err := w.nodeKeyGetter.GetNodeKey()
if err != nil {
log.Info().Err(err).Msg("failed to get orbit node key to enroll Windows device")
return
}
fn := w.execEnrollFn
if fn == nil {
fn = RunWindowsMDMEnrollment
}
args := WindowsMDMEnrollmentArgs{
DiscoveryURL: notifs.WindowsMDMDiscoveryEndpoint,
HostUUID: w.HostUUID,
OrbitNodeKey: nodeKey,
}
if err := fn(args); err != nil {
if errors.Is(err, errIsWindowsServer) {
w.isWindowsServer = true
log.Info().Msg("device is a Windows Server, skipping enrollment")
} else {
log.Info().Err(err).Msg("calling RegisterDeviceWithManagement to enroll Windows device failed")
}
return
}
w.lastEnrollRun = time.Now()
log.Info().Msg("successfully called RegisterDeviceWithManagement to enroll Windows device")
}
}
func (w *windowsMDMEnrollmentConfigReceiver) attemptUnenrollment(actionLabel string) {
if w.mu.TryLock() {
defer w.mu.Unlock()
// do not unenroll Windows Servers, and do not attempt unenrollment if the
// last run is not at least Frequency ago.
if w.isWindowsServer {
log.Debug().Msgf("skipped calling UnregisterDeviceWithManagement to %s Windows device, device is a server", actionLabel)
return
}
if time.Since(w.lastUnenrollRun) <= w.Frequency {
log.Debug().Msgf("skipped calling UnregisterDeviceWithManagement to %s Windows device, last run was too recent", actionLabel)
return
}
fn := w.execUnenrollFn
if fn == nil {
fn = RunWindowsMDMUnenrollment
}
// NOTE: args is actually unused by unenrollment, it is just for the
// function signature consistency.
args := WindowsMDMEnrollmentArgs{}
if err := fn(args); err != nil {
if errors.Is(err, errIsWindowsServer) {
w.isWindowsServer = true
log.Info().Msgf("device is a Windows Server, skipping %s", actionLabel)
} else {
log.Info().Err(err).Msgf("calling UnregisterDeviceWithManagement to %s Windows device failed", actionLabel)
}
return
}
w.lastUnenrollRun = time.Now()
log.Info().Msgf("successfully called UnregisterDeviceWithManagement to %s Windows device", actionLabel)
}
}
// 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
// Fleet server so it knows the agent processed the request. Note that this
// should be set to the value of the --enable-scripts command-line flag. An
// additional, dynamic check is done automatically by the
// runScriptsConfigReceiver if this field is false to get the value from the
// MDM configuration profile.
ScriptsExecutionEnabled bool
// ScriptsClient is the client to use to fetch the script to execute and save
// back its results.
ScriptsClient scripts.Client
// the dynamic scripts enabled check is done to check via mdm configuration
// profile if the host is allowed to run dynamic scripts. It is only done
// on macos and only if ScriptsExecutionEnabled is false.
dynamicScriptsEnabled atomic.Bool
dynamicScriptsEnabledCheckInterval time.Duration
// for tests, if set will use this instead of profiles.GetFleetdConfig.
testGetFleetdConfig func() (*fleet.MDMAppleFleetdConfig, error)
// for tests, to be able to mock command execution. If nil, will use
// (scripts.Runner{...}).Run. To help with testing, the function receives as
// argument the scripts.Runner value that would've executed the call.
runScriptsFn func(*scripts.Runner, []string) error
// ensures only one script execution runs at a time
mu sync.Mutex
rootDirPath string
}
func ApplyRunScriptsConfigFetcherMiddleware(
scriptsEnabled bool, scriptsClient scripts.Client, rootDirPath string,
) (fleet.OrbitConfigReceiver, func() bool) {
scriptsFetcher := &runScriptsConfigReceiver{
ScriptsExecutionEnabled: scriptsEnabled,
ScriptsClient: scriptsClient,
dynamicScriptsEnabledCheckInterval: 5 * time.Minute,
rootDirPath: rootDirPath,
}
// start the dynamic check for scripts enabled if required
scriptsFetcher.runDynamicScriptsEnabledCheck()
return scriptsFetcher, scriptsFetcher.scriptsEnabled
}
func (h *runScriptsConfigReceiver) runDynamicScriptsEnabledCheck() {
getFleetdConfig := h.testGetFleetdConfig
if getFleetdConfig == nil {
getFleetdConfig = profiles.GetFleetdConfig
}
// only run on macos and only if scripts are disabled by default for the
// agent (but always run if a test get fleetd config function is set).
if (runtime.GOOS == "darwin" && !h.ScriptsExecutionEnabled) || (h.testGetFleetdConfig != nil) {
go func() {
runCheck := func() {
cfg, err := getFleetdConfig()
if err != nil {
if err != profiles.ErrNotImplemented {
// note that an unenrolled host will not return an error, it will
// return the zero-value struct, so this logging should not be too
// noisy unless something goes wrong.
log.Info().Err(err).Msg("get fleetd configuration failed")
}
return
}
h.dynamicScriptsEnabled.Store(cfg.EnableScripts)
}
// check immediately at startup, before checking at the interval
runCheck()
// check every minute
for range time.Tick(h.dynamicScriptsEnabledCheckInterval) {
runCheck()
}
}()
}
}
// GetConfig calls the wrapped Fetcher's GetConfig method, and if the fleet
// server sent a list of scripts to execute, starts a goroutine to execute
// them.
func (h *runScriptsConfigReceiver) Run(cfg *fleet.OrbitConfig) error {
timeout := fleetscripts.MaxHostExecutionTime
if cfg.ScriptExeTimeout > 0 {
timeout = time.Duration(cfg.ScriptExeTimeout) * time.Second
}
if runtime.GOOS == "darwin" {
if cfg.Notifications.RunSetupExperience && !CanRun(h.rootDirPath, "swiftDialog", SwiftDialogMacOSTarget) {
log.Info().Msg("exiting scripts config runner early during setup experience: swiftDialog is not installed")
return nil
}
}
if len(cfg.Notifications.PendingScriptExecutionIDs) > 0 {
log.Info().Msgf("received notification to run scripts %v", cfg.Notifications.PendingScriptExecutionIDs)
if h.mu.TryLock() {
log.Info().Msgf("proceeding to run scripts %v", cfg.Notifications.PendingScriptExecutionIDs)
runner := &scripts.Runner{
ScriptExecutionEnabled: h.scriptsEnabled(),
Client: h.ScriptsClient,
ScriptExecutionTimeout: timeout,
}
fn := runner.Run
if h.runScriptsFn != nil {
fn = func(execIDs []string) error {
return h.runScriptsFn(runner, execIDs)
}
}
go func() {
defer h.mu.Unlock()
if err := fn(cfg.Notifications.PendingScriptExecutionIDs); err != nil {
log.Info().Err(err).Msg("running scripts failed")
return
}
log.Info().Msgf("running scripts %v succeeded", cfg.Notifications.PendingScriptExecutionIDs)
}()
}
}
return nil
}
func (h *runScriptsConfigReceiver) scriptsEnabled() bool {
// scripts are always enabled if the agent is started with the
// --enable-scripts flag. If it is not started with this flag, then
// scripts are enabled only if the mdm profile says so.
return h.ScriptsExecutionEnabled || h.dynamicScriptsEnabled.Load()
}
type DiskEncryptionKeySetter interface {
SetOrUpdateDiskEncryptionKey(diskEncryptionStatus fleet.OrbitHostDiskEncryptionKeyPayload) error
}
// execEncryptVolumeFunc handles the encryption of a volume identified by its
// string identifier (e.g., "C:").
//
// It returns a string representing the recovery key and an error if any occurs during the process.
type execEncryptVolumeFunc func(volumeID string) (recoveryKey string, err error)
// execGetEncryptionStatusFunc retrieves the encryption status of all volumes
// managed by Bitlocker.
//
// It returns a slice of bitlocker.VolumeStatus, each representing the
// encryption status of a volume, and an error if the operation fails.
type execGetEncryptionStatusFunc func() (status []bitlocker.VolumeStatus, err error)
// execRotateRecoveryKeyFunc rotates the recovery key on an already-encrypted volume.
// It adds a new recovery key protector, removes old ones, and returns the new key.
type execRotateRecoveryKeyFunc func(volumeID string) (string, error)
type windowsMDMBitlockerConfigReceiver struct {
// Frequency is the minimum amount of time that must pass between two
// executions of the windows MDM enrollment attempt.
Frequency time.Duration
// Bitlocker Operation Results
EncryptionResult DiskEncryptionKeySetter
// tracks last time a disk encryption has successfully run
lastRun time.Time
// pendingRecoveryKey holds a rotated recovery key that was not yet
// successfully escrowed to Fleet. On subsequent ticks, orbit retries
// the escrow without rotating again, avoiding orphan protectors.
pendingRecoveryKey string
// ensures only one script execution runs at a time
mu sync.Mutex
// execEncryptVolumeFn handles volume encryption. Set by the middleware from the COMWorker, or overridden in tests.
execEncryptVolumeFn execEncryptVolumeFunc
// execGetEncryptionStatusFn retrieves encryption status. Set by the middleware from the COMWorker, or overridden in tests.
execGetEncryptionStatusFn execGetEncryptionStatusFunc
// execRotateRecoveryKeyFn rotates the recovery key on an already-encrypted volume.
execRotateRecoveryKeyFn execRotateRecoveryKeyFunc
}
func ApplyWindowsMDMBitlockerFetcherMiddleware(
frequency time.Duration,
encryptionResult DiskEncryptionKeySetter,
comWorker *bitlocker.COMWorker,
) fleet.OrbitConfigReceiver {
return &windowsMDMBitlockerConfigReceiver{
Frequency: frequency,
EncryptionResult: encryptionResult,
execEncryptVolumeFn: comWorker.EncryptVolume,
execGetEncryptionStatusFn: comWorker.GetEncryptionStatus,
execRotateRecoveryKeyFn: comWorker.RotateRecoveryKey,
}
}
// GetConfig calls the wrapped Fetcher's GetConfig method, and if the fleet
// server set the "EnforceBitLockerEncryption" flag to true, executes the command
// to attempt BitlockerEncryption (or not, if the device is a Windows Server).
func (w *windowsMDMBitlockerConfigReceiver) Run(cfg *fleet.OrbitConfig) error {
if cfg.Notifications.EnforceBitLockerEncryption {
if w.mu.TryLock() {
defer w.mu.Unlock()
w.attemptBitlockerEncryption()
}
}
return nil
}
func (w *windowsMDMBitlockerConfigReceiver) attemptBitlockerEncryption() {
if time.Since(w.lastRun) <= w.Frequency {
log.Debug().Msg("skipped encryption process, last run was too recent")
return
}
// Windows servers are not supported. Check and skip if that's the case.
if isServer, err := IsRunningOnWindowsServer(); isServer || err != nil {
if err != nil {
log.Error().Err(err).Msg("checking if the host is a Windows server")
} else {
log.Debug().Msg("device is a Windows Server, encryption is not going to be performed")
}
return
}
const targetVolume = "C:"
encryptionStatus, err := w.getEncryptionStatusForVolume(targetVolume)
if err != nil {
log.Debug().Err(err).Msgf("unable to get encryption status for target volume %s, continuing anyway", targetVolume)
}
// If a previous encryption or rotation succeeded but escrow failed,
// retry the escrow with the cached key. This runs before all other
// checks because the escrow is just a server API call that doesn't
// touch the disk -- it should succeed even during encryption in progress
// or when WMI status is transiently unavailable.
if w.pendingRecoveryKey != "" {
log.Debug().Msg("retrying escrow of previously rotated recovery key")
if serverErr := w.updateFleetServer(w.pendingRecoveryKey, nil); serverErr != nil {
log.Error().Err(serverErr).Msg("failed to escrow cached recovery key to Fleet Server")
return
}
w.pendingRecoveryKey = ""
w.lastRun = time.Now()
return
}
// don't do anything if the disk is being encrypted/decrypted
if w.bitLockerActionInProgress(encryptionStatus) {
log.Debug().Msgf("skipping encryption as the disk is not available. Disk conversion status: %d", encryptionStatus.ConversionStatus)
return
}
// If the disk is already encrypted, rotate the recovery key instead of
// decrypting and re-encrypting. This adds a new Fleet-managed recovery key
// protector, removes old ones, and escrows the new key. This matches how other MDMs
// handle pre-encrypted disks.
if encryptionStatus != nil &&
encryptionStatus.ConversionStatus == bitlocker.ConversionStatusFullyEncrypted {
log.Debug().Msg("disk is already encrypted, rotating recovery key")
recoveryKey, err := w.execRotateRecoveryKeyFn(targetVolume)
if err != nil {
log.Error().Err(err).Msg("recovery key rotation failed")
if serverErr := w.updateFleetServer("", err); serverErr != nil {
log.Error().Err(serverErr).Msg("failed to send key rotation failure to Fleet Server")
}
return
}
if serverErr := w.updateFleetServer(recoveryKey, nil); serverErr != nil {
log.Error().Err(serverErr).Msg("failed to escrow rotated recovery key to Fleet Server, will retry")
w.pendingRecoveryKey = recoveryKey
return
}
w.lastRun = time.Now()
return
}
recoveryKey, encryptionErr := w.performEncryption(targetVolume)
// before reporting the error to the server, check if the error we've got is valid.
// see the description of w.isMisreportedDecryptionError and issue #15916.
var pErr *bitlocker.EncryptionError
if errors.As(encryptionErr, &pErr) && w.isMisreportedDecryptionError(pErr, encryptionStatus) {
log.Error().Msg("disk encryption failed due to previous unsuccessful attempt, user action required")
return
}
if serverErr := w.updateFleetServer(recoveryKey, encryptionErr); serverErr != nil {
log.Error().Err(serverErr).Msg("failed to send encryption result to Fleet Server")
if encryptionErr == nil && recoveryKey != "" {
w.pendingRecoveryKey = recoveryKey
}
return
}
if encryptionErr != nil {
log.Error().Err(encryptionErr).Msg("failed to encrypt the volume")
return
}
w.pendingRecoveryKey = ""
w.lastRun = time.Now()
}
// getEncryptionStatusForVolume retrieves the encryption status for a specific volume.
func (w *windowsMDMBitlockerConfigReceiver) getEncryptionStatusForVolume(volume string) (*bitlocker.EncryptionStatus, error) {
status, err := w.execGetEncryptionStatusFn()
if err != nil {
return nil, err
}
for _, s := range status {
if s.DriveVolume == volume {
return s.Status, nil
}
}
return nil, nil
}
// bitLockerActionInProgress determines an encryption/decription action is in
// progress based on the reported status.
func (w *windowsMDMBitlockerConfigReceiver) bitLockerActionInProgress(status *bitlocker.EncryptionStatus) bool {
if status == nil {
return false
}
// Check if the status matches any of the specified conditions
return status.ConversionStatus == bitlocker.ConversionStatusDecryptionInProgress ||
status.ConversionStatus == bitlocker.ConversionStatusDecryptionPaused ||
status.ConversionStatus == bitlocker.ConversionStatusEncryptionInProgress ||
status.ConversionStatus == bitlocker.ConversionStatusEncryptionPaused
}
// performEncryption executes the encryption process.
func (w *windowsMDMBitlockerConfigReceiver) performEncryption(volume string) (string, error) {
recoveryKey, err := w.execEncryptVolumeFn(volume)
if err != nil {
return "", err
}
return recoveryKey, nil
}
// isMisreportedDecryptionError checks whether the given error is a potentially
// misreported decryption error.
//
// It addresses cases where a previous encryption attempt failed due to other
// errors but subsequent attempts to encrypt the disk could erroneously return
// a bitlocker.FVE_E_NOT_DECRYPTED error.
//
// This function checks if the disk is actually fully decrypted
// (status.ConversionStatus == bitlocker.CONVERSION_STATUS_FULLY_DECRYPTED) and
// whether the reported error is bitlocker.FVE_E_NOT_DECRYPTED. If these
// conditions are met, the error is not accurately reflecting the disk's actual
// encryption state.
//
// For more context, see issue #15916
func (w *windowsMDMBitlockerConfigReceiver) isMisreportedDecryptionError(err *bitlocker.EncryptionError, status *bitlocker.EncryptionStatus) bool {
return err.Code() == bitlocker.ErrorCodeNotDecrypted &&
status != nil &&
status.ConversionStatus == bitlocker.ConversionStatusFullyDecrypted
}
func (w *windowsMDMBitlockerConfigReceiver) updateFleetServer(key string, err error) error {
// Getting Bitlocker encryption operation error message if any
// This is going to be sent to Fleet Server
bitlockerError := ""
if err != nil {
bitlockerError = err.Error()
}
// Update Fleet Server with encryption result
payload := fleet.OrbitHostDiskEncryptionKeyPayload{
EncryptionKey: []byte(key),
ClientError: bitlockerError,
}
return w.EncryptionResult.SetOrUpdateDiskEncryptionKey(payload)
}