mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 00:45:19 +00:00
Implement Windows MDM programmatic unenrollment (notification + orbit trigger) (#12505)
This commit is contained in:
parent
e323a3d881
commit
1db2f7646a
1
changes/issue-12342-trigger-windows-mdm-unenrollment
Normal file
1
changes/issue-12342-trigger-windows-mdm-unenrollment
Normal file
@ -0,0 +1 @@
|
||||
* Added notification and execution of programmatic Windows MDM unenrollment on eligible devices when Windows MDM is disabled.
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -5,3 +5,7 @@ package update
|
||||
func RunWindowsMDMEnrollment(args WindowsMDMEnrollmentArgs) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func RunWindowsMDMUnenrollment(args WindowsMDMEnrollmentArgs) error {
|
||||
return nil
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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(`
|
||||
|
@ -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() {
|
||||
|
@ -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()
|
||||
|
@ -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")
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user