Implement Windows MDM programmatic unenrollment (notification + orbit trigger) (#12505)

This commit is contained in:
Martin Angers 2023-06-28 09:13:37 -04:00 committed by GitHub
parent e323a3d881
commit 1db2f7646a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 390 additions and 171 deletions

View File

@ -0,0 +1 @@
* Added notification and execution of programmatic Windows MDM unenrollment on eligible devices when Windows MDM is disabled.

View File

@ -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
}
}

View File

@ -5,3 +5,7 @@ package update
func RunWindowsMDMEnrollment(args WindowsMDMEnrollmentArgs) error {
return nil
}
func RunWindowsMDMUnenrollment(args WindowsMDMEnrollmentArgs) error {
return nil
}

View File

@ -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)

View File

@ -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")
}
}

View File

@ -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
}

View File

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

View File

@ -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 {

View File

@ -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(`

View File

@ -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() {

View File

@ -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()

View File

@ -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")
}

View File

@ -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
}

View File

@ -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)
}