diff --git a/changes/issue-12342-trigger-windows-mdm-unenrollment b/changes/issue-12342-trigger-windows-mdm-unenrollment new file mode 100644 index 0000000000..78f4d9003f --- /dev/null +++ b/changes/issue-12342-trigger-windows-mdm-unenrollment @@ -0,0 +1 @@ +* Added notification and execution of programmatic Windows MDM unenrollment on eligible devices when Windows MDM is disabled. diff --git a/ee/server/service/devices.go b/ee/server/service/devices.go index cfbe1fb78c..b2b5370792 100644 --- a/ee/server/service/devices.go +++ b/ee/server/service/devices.go @@ -39,7 +39,7 @@ func (svc *Service) TriggerMigrateMDMDevice(ctx context.Context, host *fleet.Hos bre.InternalErr = ctxerr.New(ctx, "macOS migration not enabled") case ac.MDM.MacOSMigration.WebhookURL == "": bre.InternalErr = ctxerr.New(ctx, "macOS migration webhook URL not configured") - case !host.IsElegibleForDEPMigration(): + case !host.IsEligibleForDEPMigration(): bre.InternalErr = ctxerr.New(ctx, "host not eligible for macOS migration") case host.RefetchCriticalQueriesUntil != nil && host.RefetchCriticalQueriesUntil.After(svc.clock.Now()): // the webhook has already been triggered successfully recently (within the @@ -102,7 +102,7 @@ func (svc *Service) GetFleetDesktopSummary(ctx context.Context) (fleet.DesktopSu sum.Notifications.RenewEnrollmentProfile = true } - if host.IsElegibleForDEPMigration() { + if host.IsEligibleForDEPMigration() { sum.Notifications.NeedsMDMMigration = true } } diff --git a/orbit/pkg/update/execwinapi_stub.go b/orbit/pkg/update/execwinapi_stub.go index a53270adbd..e4957bc2cd 100644 --- a/orbit/pkg/update/execwinapi_stub.go +++ b/orbit/pkg/update/execwinapi_stub.go @@ -5,3 +5,7 @@ package update func RunWindowsMDMEnrollment(args WindowsMDMEnrollmentArgs) error { return nil } + +func RunWindowsMDMUnenrollment(args WindowsMDMEnrollmentArgs) error { + return nil +} diff --git a/orbit/pkg/update/execwinapi_windows.go b/orbit/pkg/update/execwinapi_windows.go index 9dcfd0f6b9..2c6be6ab45 100644 --- a/orbit/pkg/update/execwinapi_windows.go +++ b/orbit/pkg/update/execwinapi_windows.go @@ -22,6 +22,10 @@ var ( // 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 @@ -39,6 +43,21 @@ func RunWindowsMDMEnrollment(args WindowsMDMEnrollmentArgs) error { 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 { @@ -81,7 +100,8 @@ func enrollHostToMDM(args WindowsMDMEnrollmentArgs) error { } // pre-load the DLL and pre-find the procedure, to return a more meaningful - // message if those steps fail and avoid a panic. + // 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) } @@ -96,29 +116,60 @@ func enrollHostToMDM(args WindowsMDMEnrollmentArgs) error { ) log.Debug().Msgf("RegisterDeviceWithManagement returned code: %#x ; message: %v", code, err) if code != uintptr(windows.ERROR_SUCCESS) { - // 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 >= 0 && httpCode < 200 { - // status bad request is 400, so if error code is between 400 and < 600. - err = fmt.Errorf("using discovery URL %q: HTTP error code %d", args.DiscoveryURL, http.StatusBadRequest+httpCode) - } - return fmt.Errorf("RegisterDeviceWithManagement failed: %s (%#x - %[2]d)", err, code) + 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 >= 0 && 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.HostUUID = args.HostUUID return json.Marshal(pld) diff --git a/orbit/pkg/update/notifications.go b/orbit/pkg/update/notifications.go index 87012ec05a..bad5526559 100644 --- a/orbit/pkg/update/notifications.go +++ b/orbit/pkg/update/notifications.go @@ -97,14 +97,16 @@ type windowsMDMEnrollmentConfigFetcher struct { // HostUUID is the current host's UUID. HostUUID string - // for tests, to be able to mock command execution. If nil, will use - // RunWindowstMDMEnrollment. - execWinAPIFn execWinAPIFunc + // 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 lastRun and + // ensures only one command runs at a time, protects access to lastXxxRun and // isWindowsServer. mu sync.Mutex - lastRun time.Time + lastEnrollRun time.Time + lastUnenrollRun time.Time isWindowsServer bool } @@ -128,40 +130,92 @@ var errIsWindowsServer = errors.New("device is a Windows Server") func (w *windowsMDMEnrollmentConfigFetcher) GetConfig() (*fleet.OrbitConfig, error) { cfg, err := w.Fetcher.GetConfig() - if err == nil && cfg.Notifications.NeedsProgrammaticWindowsMDMEnrollment { - if cfg.Notifications.WindowsMDMDiscoveryEndpoint == "" { - log.Info().Err(errors.New("discovery endpoint is missing")).Msg("skipping enrollment, discovery endpoint is empty") - } else 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 && time.Since(w.lastRun) > w.Frequency { - fn := w.execWinAPIFn - if fn == nil { - fn = RunWindowsMDMEnrollment - } - args := WindowsMDMEnrollmentArgs{ - DiscoveryURL: cfg.Notifications.WindowsMDMDiscoveryEndpoint, - HostUUID: w.HostUUID, - } - 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") - } - } else { - w.lastRun = time.Now() - log.Info().Msg("successfully called RegisterDeviceWithManagement to enroll Windows device") - } - } else if w.isWindowsServer { - log.Debug().Msg("skipped calling RegisterDeviceWithManagement to enroll Windows device, device is a server") - } else { - log.Debug().Msg("skipped calling RegisterDeviceWithManagement to enroll Windows device, last run was too recent") - } + if err == nil { + if cfg.Notifications.NeedsProgrammaticWindowsMDMEnrollment { + w.attemptEnrollment(cfg.Notifications) + } else if cfg.Notifications.NeedsProgrammaticWindowsMDMUnenrollment { + w.attemptUnenrollment() } } return cfg, err } + +func (w *windowsMDMEnrollmentConfigFetcher) 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 + } + + fn := w.execEnrollFn + if fn == nil { + fn = RunWindowsMDMEnrollment + } + args := WindowsMDMEnrollmentArgs{ + DiscoveryURL: notifs.WindowsMDMDiscoveryEndpoint, + HostUUID: w.HostUUID, + } + 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 *windowsMDMEnrollmentConfigFetcher) attemptUnenrollment() { + 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().Msg("skipped calling UnregisterDeviceWithManagement to unenroll Windows device, device is a server") + return + } + if time.Since(w.lastUnenrollRun) <= w.Frequency { + log.Debug().Msg("skipped calling UnregisterDeviceWithManagement to unenroll Windows device, last run was too recent") + return + } + + fn := w.execUnenrollFn + if fn == nil { + fn = RunWindowsMDMUnenrollment + } + args := WindowsMDMEnrollmentArgs{ + HostUUID: w.HostUUID, + } + if err := fn(args); err != nil { + if errors.Is(err, errIsWindowsServer) { + w.isWindowsServer = true + log.Info().Msg("device is a Windows Server, skipping unenrollment") + } else { + log.Info().Err(err).Msg("calling UnregisterDeviceWithManagement to unenroll Windows device failed") + } + return + } + + w.lastUnenrollRun = time.Now() + log.Info().Msg("successfully called UnregisterDeviceWithManagement to unenroll Windows device") + } +} diff --git a/orbit/pkg/update/notifications_test.go b/orbit/pkg/update/notifications_test.go index 4a60319edf..784f5094ea 100644 --- a/orbit/pkg/update/notifications_test.go +++ b/orbit/pkg/update/notifications_test.go @@ -2,11 +2,13 @@ package update import ( "bytes" + "fmt" "io" "testing" "time" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/rs/zerolog/log" "github.com/stretchr/testify/require" ) @@ -130,36 +132,52 @@ func TestWindowsMDMEnrollment(t *testing.T) { cases := []struct { desc string - enrollFlag bool + enrollFlag *bool + unenrollFlag *bool discoveryURL string apiErr error wantAPICalled bool wantLog string }{ - {"enroll=false", false, "", nil, false, ""}, - {"enroll=true,discovery=''", true, "", nil, false, "discovery endpoint is empty"}, - {"enroll=true,discovery!='',success", true, "http://example.com", nil, true, "successfully called RegisterDeviceWithManagement"}, - {"enroll=true,discovery!='',fail", true, "http://example.com", io.ErrUnexpectedEOF, true, "enroll Windows device failed"}, - {"enroll=true,discovery!='',server", true, "http://example.com", errIsWindowsServer, true, "device is a Windows Server, skipping enrollment"}, + {"enroll=false", ptr.Bool(false), nil, "", nil, false, ""}, + {"enroll=true,discovery=''", ptr.Bool(true), nil, "", nil, false, "discovery endpoint is empty"}, + {"enroll=true,discovery!='',success", ptr.Bool(true), nil, "http://example.com", nil, true, "successfully called RegisterDeviceWithManagement"}, + {"enroll=true,discovery!='',fail", ptr.Bool(true), nil, "http://example.com", io.ErrUnexpectedEOF, true, "enroll Windows device failed"}, + {"enroll=true,discovery!='',server", ptr.Bool(true), nil, "http://example.com", errIsWindowsServer, true, "device is a Windows Server, skipping enrollment"}, + + {"unenroll=false", nil, ptr.Bool(false), "", nil, false, ""}, + {"unenroll=true,success", nil, ptr.Bool(true), "", nil, true, "successfully called UnregisterDeviceWithManagement"}, + {"unenroll=true,fail", nil, ptr.Bool(true), "", io.ErrUnexpectedEOF, true, "unenroll Windows device failed"}, + {"unenroll=true,server", nil, ptr.Bool(true), "", errIsWindowsServer, true, "device is a Windows Server, skipping unenrollment"}, } for _, c := range cases { t.Run(c.desc, func(t *testing.T) { logBuf.Reset() + var ( + enroll = c.enrollFlag != nil && *c.enrollFlag + unenroll = c.unenrollFlag != nil && *c.unenrollFlag + isUnenroll = c.unenrollFlag != nil + ) fetcher := &dummyConfigFetcher{ cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{ - NeedsProgrammaticWindowsMDMEnrollment: c.enrollFlag, - WindowsMDMDiscoveryEndpoint: c.discoveryURL, + NeedsProgrammaticWindowsMDMEnrollment: enroll, + NeedsProgrammaticWindowsMDMUnenrollment: unenroll, + WindowsMDMDiscoveryEndpoint: c.discoveryURL, }}, } - var apiGotCalled bool + var enrollGotCalled, unenrollGotCalled bool enrollFetcher := &windowsMDMEnrollmentConfigFetcher{ Fetcher: fetcher, Frequency: time.Hour, // doesn't matter for this test - execWinAPIFn: func(args WindowsMDMEnrollmentArgs) error { - apiGotCalled = true + execEnrollFn: func(args WindowsMDMEnrollmentArgs) error { + enrollGotCalled = true + return c.apiErr + }, + execUnenrollFn: func(args WindowsMDMEnrollmentArgs) error { + unenrollGotCalled = true return c.apiErr }, } @@ -168,7 +186,13 @@ func TestWindowsMDMEnrollment(t *testing.T) { require.NoError(t, err) // the dummy fetcher never returns an error require.Equal(t, fetcher.cfg, cfg) // the enrollment wrapper properly returns the expected config - require.Equal(t, c.wantAPICalled, apiGotCalled) + if isUnenroll { + require.Equal(t, c.wantAPICalled, unenrollGotCalled) + require.False(t, enrollGotCalled) + } else { + require.Equal(t, c.wantAPICalled, enrollGotCalled) + require.False(t, unenrollGotCalled) + } require.Contains(t, logBuf.String(), c.wantLog) }) } @@ -181,79 +205,103 @@ func TestWindowsMDMEnrollmentPrevented(t *testing.T) { log.Logger = log.Output(&logBuf) t.Cleanup(func() { log.Logger = oldLog }) - fetcher := &dummyConfigFetcher{ - cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{ + cfgs := []fleet.OrbitConfigNotifications{ + { NeedsProgrammaticWindowsMDMEnrollment: true, WindowsMDMDiscoveryEndpoint: "http://example.com", - }}, - } - - var ( - apiCallCount int - apiErr error - ) - chProceed := make(chan struct{}) - enrollFetcher := &windowsMDMEnrollmentConfigFetcher{ - Fetcher: fetcher, - Frequency: 2 * time.Second, // just to be safe with slow environments (CI) - execWinAPIFn: func(args WindowsMDMEnrollmentArgs) error { - <-chProceed // will be unblocked only when allowed - apiCallCount++ // no need for sync, single-threaded call of this func is guaranteed by the fetcher's mutex - return apiErr + }, + { + NeedsProgrammaticWindowsMDMUnenrollment: true, }, } + for _, cfg := range cfgs { + t.Run(fmt.Sprintf("%+v", cfg), func(t *testing.T) { + baseFetcher := &dummyConfigFetcher{ + cfg: &fleet.OrbitConfig{Notifications: cfg}, + } - assertResult := func(cfg *fleet.OrbitConfig, err error) { - require.NoError(t, err) - require.Equal(t, fetcher.cfg, cfg) + var ( + apiCallCount int + apiErr error + ) + chProceed := make(chan struct{}) + fetcher := &windowsMDMEnrollmentConfigFetcher{ + Fetcher: baseFetcher, + Frequency: 2 * time.Second, // just to be safe with slow environments (CI) + } + if cfg.NeedsProgrammaticWindowsMDMEnrollment { + fetcher.execEnrollFn = func(args WindowsMDMEnrollmentArgs) error { + <-chProceed // will be unblocked only when allowed + apiCallCount++ // no need for sync, single-threaded call of this func is guaranteed by the fetcher's mutex + return apiErr + } + fetcher.execUnenrollFn = func(args WindowsMDMEnrollmentArgs) error { + panic("should not be called") + } + } else { + fetcher.execUnenrollFn = func(args WindowsMDMEnrollmentArgs) error { + <-chProceed // will be unblocked only when allowed + apiCallCount++ // no need for sync, single-threaded call of this func is guaranteed by the fetcher's mutex + return apiErr + } + fetcher.execEnrollFn = func(args WindowsMDMEnrollmentArgs) error { + panic("should not be called") + } + } + + assertResult := func(cfg *fleet.OrbitConfig, err error) { + require.NoError(t, err) + require.Equal(t, baseFetcher.cfg, cfg) + } + + started := make(chan struct{}) + go func() { + close(started) + + // the first call will block in enroll/unenroll func + cfg, err := fetcher.GetConfig() + assertResult(cfg, err) + }() + + <-started + // this call will happen while the first call is blocked in + // enroll/unenrollfn, so it won't call the API (won't be able to lock the + // mutex). However it will still complete successfully without being + // blocked by the other call in progress. + cfg, err := fetcher.GetConfig() + assertResult(cfg, err) + + // unblock the first call and wait for it to complete + close(chProceed) + time.Sleep(100 * time.Millisecond) + + // this next call won't execute the command because of the frequency + // restriction (it got called less than N seconds ago) + cfg, err = fetcher.GetConfig() + assertResult(cfg, err) + + // wait for the fetcher's frequency to pass + time.Sleep(fetcher.Frequency) + + // this call executes the command, and it returns the Is Windows Server error + apiErr = errIsWindowsServer + cfg, err = fetcher.GetConfig() + assertResult(cfg, err) + + // this next call won't execute the command (both due to frequency and the + // detection of windows server) + cfg, err = fetcher.GetConfig() + assertResult(cfg, err) + + // wait for the fetcher's frequency to pass + time.Sleep(fetcher.Frequency) + + // this next call still won't execute the command (due to the detection of + // windows server) + cfg, err = fetcher.GetConfig() + assertResult(cfg, err) + + require.Equal(t, 2, apiCallCount) // the initial call and the one that returned errIsWindowsServer after first sleep + }) } - - started := make(chan struct{}) - go func() { - close(started) - - // the first call will block in execWinAPIFn - cfg, err := enrollFetcher.GetConfig() - assertResult(cfg, err) - }() - - <-started - // this call will happen while the first call is blocked in execWinAPIFn, so it - // won't call the API (won't be able to lock the mutex). However it will - // still complete successfully without being blocked by the other call in - // progress. - cfg, err := enrollFetcher.GetConfig() - assertResult(cfg, err) - - // unblock the first call and wait for it to complete - close(chProceed) - time.Sleep(100 * time.Millisecond) - - // this next call won't execute the command because of the frequency - // restriction (it got called less than N seconds ago) - cfg, err = enrollFetcher.GetConfig() - assertResult(cfg, err) - - // wait for the fetcher's frequency to pass - time.Sleep(enrollFetcher.Frequency) - - // this call executes the command, and it returns the Is Windows Server error - apiErr = errIsWindowsServer - cfg, err = enrollFetcher.GetConfig() - assertResult(cfg, err) - - // this next call won't execute the command (both due to frequency and the - // detection of windows server) - cfg, err = enrollFetcher.GetConfig() - assertResult(cfg, err) - - // wait for the fetcher's frequency to pass - time.Sleep(enrollFetcher.Frequency) - - // this next call still won't execute the command (due to the detection of - // windows server) - cfg, err = enrollFetcher.GetConfig() - assertResult(cfg, err) - - require.Equal(t, 2, apiCallCount) // the initial call and the one that returned errIsWindowsServer after first sleep } diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 3cc63a2710..bfa9d5b8f0 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -538,9 +538,9 @@ func (h *Host) IsDEPAssignedToFleet() bool { return h.DEPAssignedToFleet != nil && *h.DEPAssignedToFleet } -// IsElegibleForDEPMigration returns true if the host fulfills all requirements +// IsEligibleForDEPMigration returns true if the host fulfills all requirements // for DEP migration from a third-party provider into Fleet. -func (h *Host) IsElegibleForDEPMigration() bool { +func (h *Host) IsEligibleForDEPMigration() bool { return h.IsOsqueryEnrolled() && h.IsDEPAssignedToFleet() && h.MDMInfo.IsEnrolledInThirdPartyMDM() @@ -555,9 +555,9 @@ func (h *Host) NeedsDEPEnrollment() bool { h.IsDEPAssignedToFleet() } -// IsElegibleForWindowsMDMEnrollment returns true if the host can be enrolled -// in Fleet's Windows MDM (if Windows MDM was enabled). -func (h *Host) IsElegibleForWindowsMDMEnrollment() bool { +// IsEligibleForWindowsMDMEnrollment returns true if the host can be enrolled +// in Fleet's Windows MDM (if it was enabled). +func (h *Host) IsEligibleForWindowsMDMEnrollment() bool { return h.FleetPlatform() == "windows" && h.IsOsqueryEnrolled() && !h.MDMInfo.IsEnrolledInThirdPartyMDM() && @@ -565,6 +565,15 @@ func (h *Host) IsElegibleForWindowsMDMEnrollment() bool { (h.MDMInfo == nil || !h.MDMInfo.IsServer) } +// IsEligibleForWindowsMDMUnenrollment returns true if the host must be +// unenrolled from Fleet's Windows MDM (if it MDM was disabled). +func (h *Host) IsEligibleForWindowsMDMUnenrollment() bool { + return h.FleetPlatform() == "windows" && + h.IsOsqueryEnrolled() && + h.MDMInfo.IsFleetEnrolled() && + (h.MDMInfo == nil || !h.MDMInfo.IsServer) +} + // DisplayName returns ComputerName if it isn't empty. Otherwise, it returns Hostname if it isn't // empty. If Hostname is empty and both HardwareSerial and HardwareModel are not empty, it returns a // composite string with HardwareModel and HardwareSerial. If all else fails, it returns an empty diff --git a/server/fleet/orbit.go b/server/fleet/orbit.go index ba549ec9d9..0c850a0e43 100644 --- a/server/fleet/orbit.go +++ b/server/fleet/orbit.go @@ -6,11 +6,24 @@ import "encoding/json" // fleetd (orbit) so that it can run commands or more generally react to this // information. type OrbitConfigNotifications struct { - RenewEnrollmentProfile bool `json:"renew_enrollment_profile,omitempty"` - RotateDiskEncryptionKey bool `json:"rotate_disk_encryption_key,omitempty"` - NeedsMDMMigration bool `json:"needs_mdm_migration,omitempty"` - NeedsProgrammaticWindowsMDMEnrollment bool `json:"needs_programmatic_microsoft_mdm_enrollment,omitempty"` - WindowsMDMDiscoveryEndpoint string `json:"microsoft_mdm_discovery_endpoint,omitempty"` + RenewEnrollmentProfile bool `json:"renew_enrollment_profile,omitempty"` + RotateDiskEncryptionKey bool `json:"rotate_disk_encryption_key,omitempty"` + NeedsMDMMigration bool `json:"needs_mdm_migration,omitempty"` + + // NeedsProgrammaticWindowsMDMEnrollment is sent as true if Windows MDM is + // enabled and the device should be enrolled as far as the server knows (e.g. + // it is running Windows, is not already enrolled, etc., see + // host.IsEligibleForWindowsMDMEnrollment for the list of conditions). + NeedsProgrammaticWindowsMDMEnrollment bool `json:"needs_programmatic_windows_mdm_enrollment,omitempty"` + // WindowsMDMDiscoveryEndpoint is the URL to use as Windows MDM discovery. It + // must be sent when NeedsProgrammaticWindowsMDMEnrollment is true so that + // the device knows where to enroll. + WindowsMDMDiscoveryEndpoint string `json:"windows_mdm_discovery_endpoint,omitempty"` + + // NeedsProgrammaticWindowsMDMUnenrollment is sent as true if Windows MDM is + // disabled and the device was enrolled in Fleet's MDM (see + // host.IsEligibleForWindowsMDMUnenrollment for the list of conditions). + NeedsProgrammaticWindowsMDMUnenrollment bool `json:"needs_programmatic_windows_mdm_unenrollment,omitempty"` } type OrbitConfig struct { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index cec5fcecde..e61b1bf003 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -2801,6 +2801,7 @@ func (s *integrationEnterpriseTestSuite) TestOrbitConfigNudgeSettings() { require.Empty(t, resp.NudgeConfig) require.False(t, resp.Notifications.NeedsProgrammaticWindowsMDMEnrollment) require.Empty(t, resp.Notifications.WindowsMDMDiscoveryEndpoint) + require.False(t, resp.Notifications.NeedsProgrammaticWindowsMDMUnenrollment) // set macos_updates s.applyConfig([]byte(` diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 303e446687..1776e92cf5 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -214,7 +214,6 @@ func (s *integrationMDMTestSuite) TearDownSuite() { appConf, err := s.ds.AppConfig(context.Background()) require.NoError(s.T(), err) appConf.MDM.EnabledAndConfigured = false - appConf.MDM.WindowsEnabledAndConfigured = false err = s.ds.SaveAppConfig(context.Background(), appConf) require.NoError(s.T(), err) } @@ -233,12 +232,12 @@ func (s *integrationMDMTestSuite) TearDownTest() { s.token = s.getTestAdminToken() appCfg := s.getConfig() - if appCfg.MDM.MacOSSettings.EnableDiskEncryption { - // ensure global disk encryption is disabled on exit - s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "macos_settings": { "enable_disk_encryption": false } } - }`), http.StatusOK) - } + // ensure windows mdm is always enabled for the next test + appCfg.MDM.WindowsEnabledAndConfigured = true + // ensure global disk encryption is disabled on exit + appCfg.MDM.MacOSSettings.EnableDiskEncryption = false + err := s.ds.SaveAppConfig(ctx, &appCfg.AppConfig) + require.NoError(t, err) s.withServer.commonTearDownTest(t) @@ -5093,12 +5092,34 @@ func (s *integrationMDMTestSuite) TestAppConfigWindowsMDM() { json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hostsBySuffix[meta.suffix].OrbitNodeKey)), http.StatusOK, &resp) require.Equal(t, meta.shouldEnroll, resp.Notifications.NeedsProgrammaticWindowsMDMEnrollment) + require.False(t, resp.Notifications.NeedsProgrammaticWindowsMDMUnenrollment) if meta.shouldEnroll { require.Contains(t, resp.Notifications.WindowsMDMDiscoveryEndpoint, microsoft_mdm.MDE2DiscoveryPath) } else { require.Empty(t, resp.Notifications.WindowsMDMDiscoveryEndpoint) } } + + // disable Microsoft MDM + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "windows_enabled_and_configured": false } + }`), http.StatusOK, &acResp) + assert.False(t, acResp.MDM.WindowsEnabledAndConfigured) + + // set the win-no-team host as enrolled in Windows MDM + noTeamHost := hostsBySuffix["win-no-team"] + err = s.ds.SetOrUpdateMDMData(ctx, noTeamHost.ID, false, true, "https://example.com", false, fleet.WellKnownMDMFleet) + require.NoError(t, err) + + // get the orbit config for win-no-team should return true for the + // unenrollment notification + var resp orbitGetConfigResponse + s.DoJSON("POST", "/api/fleet/orbit/config", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *noTeamHost.OrbitNodeKey)), + http.StatusOK, &resp) + require.True(t, resp.Notifications.NeedsProgrammaticWindowsMDMUnenrollment) + require.False(t, resp.Notifications.NeedsProgrammaticWindowsMDMEnrollment) + require.Empty(t, resp.Notifications.WindowsMDMDiscoveryEndpoint) } func (s *integrationMDMTestSuite) TestValidDiscoveryRequest() { diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 5c11f401e1..3176d0578d 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -131,7 +131,7 @@ func TestVerifyMDMAppleConfigured(t *testing.T) { } // TODO: update this test with the correct config option -func TestVerifyMDMMicrosoftConfigured(t *testing.T) { +func TestVerifyMDMWindowsConfigured(t *testing.T) { ds := new(mock.Store) license := &fleet.LicenseInfo{Tier: fleet.TierPremium} cfg := config.TestConfig() diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index 0c78b3056a..9a622fa762 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -499,8 +499,8 @@ func validateBinarySecurityToken(ctx context.Context, encodedBinarySecToken stri return fmt.Errorf("binarySecurityTokenValidation: host data cannot be found %v", err) } - // This ensures that only hosts that are elegible for Windows enrollment can be enrolled - if !host.IsElegibleForWindowsMDMEnrollment() { + // This ensures that only hosts that are eligible for Windows enrollment can be enrolled + if !host.IsEligibleForWindowsMDMEnrollment() { return errors.New("binarySecurityTokenValidation: host is not elegible for Windows MDM enrollment") } diff --git a/server/service/orbit.go b/server/service/orbit.go index 372a010d3e..0265a796d8 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/fleetdm/fleet/v4/server" + "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/contexts/logging" @@ -179,19 +180,23 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro return fleet.OrbitConfig{Notifications: notifs}, orbitError{message: "internal error: missing host from request context"} } - config, err := svc.ds.AppConfig(ctx) + appConfig, err := svc.ds.AppConfig(ctx) if err != nil { return fleet.OrbitConfig{Notifications: notifs}, err } // set the host's orbit notifications for macOS MDM - if config.MDM.EnabledAndConfigured && host.IsOsqueryEnrolled() { + if appConfig.MDM.EnabledAndConfigured && host.IsOsqueryEnrolled() { + // TODO(mna): all those notifications implied a macos hosts, but none of + // the checks enforce that (only indirectly in some cases, like + // IsDEPAssignedToFleet), should we add such a platform check? + if host.NeedsDEPEnrollment() { notifs.RenewEnrollmentProfile = true } - if config.MDM.MacOSMigration.Enable && - host.IsElegibleForDEPMigration() { + if appConfig.MDM.MacOSMigration.Enable && + host.IsEligibleForDEPMigration() { notifs.NeedsMDMMigration = true } @@ -207,9 +212,9 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro } // set the host's orbit notifications for Windows MDM - if config.MDM.WindowsEnabledAndConfigured { - if host.IsElegibleForWindowsMDMEnrollment() { - discoURL, err := microsoft_mdm.ResolveWindowsMDMDiscovery(config.ServerSettings.ServerURL) + if appConfig.MDM.WindowsEnabledAndConfigured { + if host.IsEligibleForWindowsMDMEnrollment() { + discoURL, err := microsoft_mdm.ResolveWindowsMDMDiscovery(appConfig.ServerSettings.ServerURL) if err != nil { return fleet.OrbitConfig{Notifications: notifs}, err } @@ -217,6 +222,11 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro notifs.NeedsProgrammaticWindowsMDMEnrollment = true } } + if config.IsMDMFeatureFlagEnabled() && !appConfig.MDM.WindowsEnabledAndConfigured { + if host.IsEligibleForWindowsMDMUnenrollment() { + notifs.NeedsProgrammaticWindowsMDMUnenrollment = true + } + } // team ID is not nil, get team specific flags and options if host.TeamID != nil { @@ -257,16 +267,16 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro // team ID is nil, get global flags and options var opts fleet.AgentOptions - if config.AgentOptions != nil { - if err := json.Unmarshal(*config.AgentOptions, &opts); err != nil { + if appConfig.AgentOptions != nil { + if err := json.Unmarshal(*appConfig.AgentOptions, &opts); err != nil { return fleet.OrbitConfig{Notifications: notifs}, err } } var nudgeConfig *fleet.NudgeConfig - if config.MDM.MacOSUpdates.Deadline.Value != "" && - config.MDM.MacOSUpdates.MinimumVersion.Value != "" { - nudgeConfig, err = fleet.NewNudgeConfig(config.MDM.MacOSUpdates) + if appConfig.MDM.MacOSUpdates.Deadline.Value != "" && + appConfig.MDM.MacOSUpdates.MinimumVersion.Value != "" { + nudgeConfig, err = fleet.NewNudgeConfig(appConfig.MDM.MacOSUpdates) if err != nil { return fleet.OrbitConfig{Notifications: notifs}, err } diff --git a/tools/windows-mdm-enroll/main.go b/tools/windows-mdm-enroll/main.go index 6c457ce796..44de7dca62 100644 --- a/tools/windows-mdm-enroll/main.go +++ b/tools/windows-mdm-enroll/main.go @@ -11,12 +11,19 @@ func main() { var ( discoveryURL = flag.String("discovery-url", "", "The Windows MDM discovery URL") hostUUID = flag.String("host-uuid", "", "The Host UUID") + unenroll = flag.Bool("unenroll", false, "Unenroll from MDM instead of enrolling") ) flag.Parse() + if *unenroll { + err := update.RunWindowsMDMUnenrollment(update.WindowsMDMEnrollmentArgs{}) + fmt.Println("unenrollment: ", err) + return + } + err := update.RunWindowsMDMEnrollment(update.WindowsMDMEnrollmentArgs{ DiscoveryURL: *discoveryURL, HostUUID: *hostUUID, }) - fmt.Println(err) + fmt.Println("enrollment: ", err) }