Files
fleet/server/service/setup_experience.go
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

472 lines
18 KiB
Go

package service
import (
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
"github.com/fleetdm/fleet/v4/server/ptr"
)
type putSetupExperienceSoftwareRequest struct {
Platform string `json:"platform"`
TeamID uint `json:"team_id" renameto:"fleet_id"`
TitleIDs []uint `json:"software_title_ids"`
}
func (r *putSetupExperienceSoftwareRequest) ValidateRequest() error {
return validateSetupExperiencePlatform(r.Platform)
}
type putSetupExperienceSoftwareResponse struct {
Err error `json:"error,omitempty"`
}
func (r putSetupExperienceSoftwareResponse) Error() error { return r.Err }
func putSetupExperienceSoftware(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*putSetupExperienceSoftwareRequest)
platform := transformPlatformForSetupExperience(req.Platform)
err := svc.SetSetupExperienceSoftware(ctx, platform, req.TeamID, req.TitleIDs)
if err != nil {
return &putSetupExperienceSoftwareResponse{Err: err}, nil
}
return &putSetupExperienceSoftwareResponse{}, nil
}
func (svc *Service) SetSetupExperienceSoftware(ctx context.Context, platform string, teamID uint, titleIDs []uint) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
type getSetupExperienceSoftwareRequest struct {
// Platforms can be a comma separated list
Platforms string `query:"platform,optional"`
fleet.ListOptions
TeamID uint `query:"team_id" renameto:"fleet_id"`
}
func (r *getSetupExperienceSoftwareRequest) ValidateRequest() error {
return validateSetupExperiencePlatform(r.Platforms)
}
type getSetupExperienceSoftwareResponse struct {
SoftwareTitles []fleet.SoftwareTitleListResult `json:"software_titles"`
Count int `json:"count"`
Meta *fleet.PaginationMetadata `json:"meta"`
Err error `json:"error,omitempty"`
}
func (r getSetupExperienceSoftwareResponse) Error() error { return r.Err }
func getSetupExperienceSoftware(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getSetupExperienceSoftwareRequest)
platform := transformPlatformListForSetupExperience(req.Platforms)
titles, count, meta, err := svc.ListSetupExperienceSoftware(ctx, platform, req.TeamID, req.ListOptions)
if err != nil {
return &getSetupExperienceSoftwareResponse{Err: err}, nil
}
return &getSetupExperienceSoftwareResponse{SoftwareTitles: titles, Count: count, Meta: meta}, nil
}
func (svc *Service) ListSetupExperienceSoftware(ctx context.Context, platform string, teamID uint, opts fleet.ListOptions) ([]fleet.SoftwareTitleListResult, int, *fleet.PaginationMetadata, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, 0, nil, fleet.ErrMissingLicense
}
type getSetupExperienceScriptRequest struct {
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
Alt string `query:"alt,optional"`
}
type getSetupExperienceScriptResponse struct {
*fleet.Script
Err error `json:"error,omitempty"`
}
func (r getSetupExperienceScriptResponse) Error() error { return r.Err }
func getSetupExperienceScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getSetupExperienceScriptRequest)
downloadRequested := req.Alt == "media"
// // TODO: do we want to allow end users to specify team_id=0? if so, we'll need convert it to nil here so that we can
// // use it in the auth layer where team_id=0 is not allowed?
script, content, err := svc.GetSetupExperienceScript(ctx, req.TeamID, downloadRequested)
if err != nil {
return getSetupExperienceScriptResponse{Err: err}, nil
}
if downloadRequested {
return fleet.DownloadFileResponse{
Content: content,
Filename: fmt.Sprintf("%s %s", time.Now().Format(time.DateOnly), script.Name),
}, nil
}
return getSetupExperienceScriptResponse{Script: script}, nil
}
func (svc *Service) GetSetupExperienceScript(ctx context.Context, teamID *uint, withContent bool) (*fleet.Script, []byte, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, nil, fleet.ErrMissingLicense
}
type setSetupExperienceScriptRequest struct {
TeamID *uint
Script *multipart.FileHeader
}
func (setSetupExperienceScriptRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
var decoded setSetupExperienceScriptRequest
err := parseMultipartForm(ctx, r, platform_http.MaxMultipartFormSize)
if err != nil {
return nil, &fleet.BadRequestError{
Message: "failed to parse multipart form",
InternalErr: err,
}
}
val := r.MultipartForm.Value["fleet_id"]
if len(val) > 0 {
fleetID, err := strconv.ParseUint(val[0], 10, 64)
if err != nil {
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode fleet_id in multipart form: %s", err.Error())}
}
// // TODO: do we want to allow end users to specify team_id=0? if so, we'll need to convert it to nil here so that we can
// // use it in the auth layer where team_id=0 is not allowed?
decoded.TeamID = ptr.Uint(uint(fleetID)) // nolint:gosec // ignore G115
}
fhs, ok := r.MultipartForm.File["script"]
if !ok || len(fhs) < 1 {
return nil, &fleet.BadRequestError{Message: "no file headers for script"}
}
decoded.Script = fhs[0]
return &decoded, nil
}
type setSetupExperienceScriptResponse struct {
Err error `json:"error,omitempty"`
}
func (r setSetupExperienceScriptResponse) Error() error { return r.Err }
func setSetupExperienceScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*setSetupExperienceScriptRequest)
scriptFile, err := req.Script.Open()
if err != nil {
return setSetupExperienceScriptResponse{Err: err}, nil
}
defer scriptFile.Close()
if err := svc.SetSetupExperienceScript(ctx, req.TeamID, filepath.Base(req.Script.Filename), scriptFile); err != nil {
return setSetupExperienceScriptResponse{Err: err}, nil
}
return setSetupExperienceScriptResponse{}, nil
}
func (svc *Service) SetSetupExperienceScript(ctx context.Context, teamID *uint, name string, r io.Reader) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
type deleteSetupExperienceScriptRequest struct {
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
}
type deleteSetupExperienceScriptResponse struct {
Err error `json:"error,omitempty"`
}
func (r deleteSetupExperienceScriptResponse) Error() error { return r.Err }
func deleteSetupExperienceScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*deleteSetupExperienceScriptRequest)
// // TODO: do we want to allow end users to specify team_id=0? if so, we'll need convert it to nil here so that we can
// // use it in the auth layer where team_id=0 is not allowed?
if err := svc.DeleteSetupExperienceScript(ctx, req.TeamID); err != nil {
return deleteSetupExperienceScriptResponse{Err: err}, nil
}
return deleteSetupExperienceScriptResponse{}, nil
}
func (svc *Service) DeleteSetupExperienceScript(ctx context.Context, teamID *uint) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}
func (svc *Service) SetupExperienceNextStep(ctx context.Context, host *fleet.Host) (bool, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return false, fleet.ErrMissingLicense
}
func (svc *Service) IsAllSetupExperienceSoftwareRequired(ctx context.Context, host *fleet.Host) (bool, error) {
return isAllSetupExperienceSoftwareRequired(ctx, svc.ds, host)
}
func isAllSetupExperienceSoftwareRequired(ctx context.Context, ds fleet.Datastore, host *fleet.Host) (bool, error) {
// Only macOS and Windows support canceling setup if software fails.
if host.Platform != "darwin" && host.Platform != "windows" {
return false, nil
}
teamID := host.TeamID
if teamID == nil || *teamID == 0 {
ac, err := ds.AppConfig(ctx)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "getting app config")
}
if host.Platform == "windows" {
return ac.MDM.MacOSSetup.RequireAllSoftwareWindows, nil
}
return ac.MDM.MacOSSetup.RequireAllSoftware, nil
}
team, err := ds.TeamLite(ctx, *teamID)
if err != nil {
return false, ctxerr.Wrap(ctx, err, "load team")
}
if host.Platform == "windows" {
return team.Config.MDM.MacOSSetup.RequireAllSoftwareWindows, nil
}
return team.Config.MDM.MacOSSetup.RequireAllSoftware, nil
}
func (svc *Service) MaybeCancelPendingSetupExperienceSteps(ctx context.Context, host *fleet.Host) error {
return maybeCancelPendingSetupExperienceSteps(ctx, svc.ds, host, svc.NewActivity)
}
func maybeCancelPendingSetupExperienceSteps(ctx context.Context, ds fleet.Datastore, host *fleet.Host, newActivityFn fleet.NewActivityFunc) error {
// Only macOS and Windows support canceling setup experience steps.
if host.Platform != "darwin" && host.Platform != "windows" {
return nil
}
requireAllSoftware, err := isAllSetupExperienceSoftwareRequired(ctx, ds, host)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if all software is required")
}
if !requireAllSoftware {
return nil
}
// Gate the cancel cascade on the host being actively in a Fleet-tracked ESP. The authoritative
// signal is mdm_windows_enrollments.awaiting_configuration: it transitions to Pending only when
// the device enrolls via WindowsMDMEnrollTypeAutomatic while the device is in OOBE, advances to Active once ESP commands are enqueued, and returns to
// None on completion/failure/timeout. All non-ESP enrollment paths stay at None, which is
// exactly the set of cases that must skip the cancellation cascade and activity emission:
// - post-OOBE BYOD via work/school account in Settings
// - post-OOBE manual orbit install that triggers programmatic Windows MDM enrollment
// - post-OOBE manual orbit install with no Windows MDM at all (no enrollment row)
// - any host that previously completed/timed-out ESP
//
// The primary lookup is keyed on mdm_windows_enrollments.host_uuid, which osquery's
// directIngestMDMDeviceIDWindows populates lazily on its first poll after enrollment. A
// fast-failing install can race that ingest, so when the primary lookup misses we fall back
// to the most-recent unlinked enrollment whose device_name matches host.ComputerName. Without
// the fallback an OOBE host that fails an install before osquery links the enrollment would
// be misread as having no MDM enrollment and would skip cancellation when it should fire.
// Follow-up bug: https://github.com/fleetdm/fleet/issues/45380
if host.Platform == "windows" {
var awaiting fleet.WindowsMDMAwaitingConfiguration
state, err := ds.GetMDMWindowsHostConfigState(ctx, host.UUID)
if err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "load windows host config state for setup-experience cancel gate")
}
if state != nil {
awaiting = state.AwaitingConfiguration
}
if fleet.IsNotFound(err) && host.ComputerName != "" {
device, err := ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, host.ComputerName)
if err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "load windows enrollment by device name for awaiting_configuration fallback")
}
if device != nil {
awaiting = device.AwaitingConfiguration
}
}
if awaiting != fleet.WindowsMDMAwaitingConfigurationPending &&
awaiting != fleet.WindowsMDMAwaitingConfigurationActive {
return nil
}
}
hostUUID, err := fleet.HostUUIDForSetupExperience(host)
if err != nil {
return ctxerr.Wrap(ctx, err, "failed to get host's UUID for the setup experience")
}
statuses, err := ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, ptr.ValOrZero(host.TeamID))
if err != nil {
return ctxerr.Wrap(ctx, err, "retrieving setup experience status results for next step")
}
for _, status := range statuses {
if err := status.IsValid(); err != nil {
return ctxerr.Wrap(ctx, err, "invalid row")
}
if status.Status != fleet.SetupExperienceStatusPending && status.Status != fleet.SetupExperienceStatusRunning {
continue
}
// Cancel any upcoming software installs, vpp installs or script runs.
var executionID string
switch {
case status.HostSoftwareInstallsExecutionID != nil:
executionID = *status.HostSoftwareInstallsExecutionID
case status.NanoCommandUUID != nil:
executionID = *status.NanoCommandUUID
case status.ScriptExecutionID != nil:
executionID = *status.ScriptExecutionID
default:
continue
}
if _, err := ds.CancelHostUpcomingActivity(ctx, host.ID, executionID); err != nil {
return ctxerr.Wrap(ctx, err, "cancelling upcoming setup experience activity")
}
}
// Cancel any pending setup experience steps for the host in the database.
if err := ds.CancelPendingSetupExperienceSteps(ctx, hostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "cancelling pending setup experience steps")
}
// Emit the canceled_setup_experience activity once at cancellation time.
// Find the software item that failed and triggered this cancellation from the
// already-loaded statuses (no extra DB call).
if newActivityFn != nil {
for _, s := range statuses {
if s.Status == fleet.SetupExperienceStatusFailure && s.IsForSoftware() {
if err := newActivityFn(ctx, nil, fleet.ActivityTypeCanceledSetupExperience{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
SoftwareTitle: s.Name,
SoftwareTitleID: ptr.ValOrZero(s.SoftwareTitleID),
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating canceled setup experience activity")
}
break
}
}
}
return nil
}
// maybeUpdateSetupExperienceStatus attempts to update the status of a setup experience result in
// the database. If the given result is of a supported type (namely SetupExperienceScriptResult,
// SetupExperienceSoftwareInstallResult, and SetupExperienceVPPInstallResult), it returns a boolean
// indicating whether the datastore was updated and an error if one occurred. If the result is not of a
// supported type, it returns false and an error indicated that the type is not supported.
// The datastore will only be updated if the given result status is a terminal status.
func maybeUpdateSetupExperienceStatus(ctx context.Context, ds fleet.Datastore, result any, newActivityFn fleet.NewActivityFunc) (bool, error) {
var updated bool
var err error
var status fleet.SetupExperienceStatusResultStatus
var hostUUID string
switch v := result.(type) {
case fleet.SetupExperienceScriptResult:
status = v.SetupExperienceStatus()
if !status.IsValid() {
return false, fmt.Errorf("invalid status: %s", status)
} else if !status.IsTerminalStatus() {
return false, nil
}
return ds.MaybeUpdateSetupExperienceScriptStatus(ctx, v.HostUUID, v.ExecutionID, status)
case fleet.SetupExperienceSoftwareInstallResult:
status = v.SetupExperienceStatus()
hostUUID = v.HostUUID
if !status.IsValid() {
return false, fmt.Errorf("invalid status: %s", status)
} else if !status.IsTerminalStatus() {
return false, nil
}
updated, err = ds.MaybeUpdateSetupExperienceSoftwareInstallStatus(ctx, v.HostUUID, v.ExecutionID, status)
case fleet.SetupExperienceVPPInstallResult:
// NOTE: this case is also implemented in the CommandAndReportResults method of
// MDMAppleCheckinAndCommandService
status = v.SetupExperienceStatus()
hostUUID = v.HostUUID
if !status.IsValid() {
return false, fmt.Errorf("invalid status: %s", status)
} else if !status.IsTerminalStatus() {
return false, nil
}
updated, err = ds.MaybeUpdateSetupExperienceVPPStatus(ctx, v.HostUUID, v.CommandUUID, status)
default:
return false, fmt.Errorf("unsupported result type: %T", result)
}
// For software / vpp installs, if we updated the status to failure and the host is macOS,
// we may need to cancel the rest of the setup experience.
if updated && err == nil && status == fleet.SetupExperienceStatusFailure {
// Look up the host by UUID to get its platform and team.
host, getHostUUIDErr := ds.HostByIdentifier(ctx, hostUUID)
if getHostUUIDErr != nil {
return updated, fmt.Errorf("getting host by UUID: %w", getHostUUIDErr)
}
cancelErr := maybeCancelPendingSetupExperienceSteps(ctx, ds, host, newActivityFn)
if cancelErr != nil {
return updated, fmt.Errorf("cancel setup experience after software install failure: %w", cancelErr)
}
}
return updated, err
}
func validateSetupExperiencePlatform(platforms string) error {
for platform := range strings.SplitSeq(platforms, ",") {
if platform != "" && !slices.Contains(fleet.SetupExperienceSupportedPlatforms, platform) {
quotedPlatforms := strings.Join(fleet.SetupExperienceSupportedPlatforms, "\", \"")
quotedPlatforms = fmt.Sprintf("\"%s\"", quotedPlatforms)
return badRequestf("platform %q unsupported, platform must be one of %s", platform, quotedPlatforms)
}
}
return nil
}
func transformPlatformForSetupExperience(platform string) string {
if platform == "" || platform == "macos" {
return "darwin"
}
return platform
}
func transformPlatformListForSetupExperience(platforms string) string {
if platforms == "" {
return "darwin"
}
return strings.ReplaceAll(platforms, "macos", "darwin")
}