diff --git a/changes/issue-12260-trigger-windows-mdm-enrollment b/changes/issue-12260-trigger-windows-mdm-enrollment new file mode 100644 index 0000000000..771bed72e6 --- /dev/null +++ b/changes/issue-12260-trigger-windows-mdm-enrollment @@ -0,0 +1 @@ +* Added execution of programmatic Windows MDM enrollment on eligible devices when Windows MDM is enabled. diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 55fa3f1982..1474e76137 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -614,10 +614,14 @@ func main() { // create the notifications middleware that wraps the orbit client // (must be shared by all runners that use a ConfigFetcher). - const renewEnrollmentProfileCommandFrequency = time.Hour + const ( + renewEnrollmentProfileCommandFrequency = time.Hour + microsoftMDMEnrollmentCommandFrequency = time.Hour + ) configFetcher := update.ApplyRenewEnrollmentProfileConfigFetcherMiddleware(orbitClient, renewEnrollmentProfileCommandFrequency) - if runtime.GOOS == "darwin" { + switch runtime.GOOS { + case "darwin": // add middleware to handle nudge installation and updates const nudgeLaunchInterval = 30 * time.Minute configFetcher = update.ApplyNudgeConfigFetcherMiddleware(configFetcher, update.NudgeConfigFetcherOptions{ @@ -626,6 +630,8 @@ func main() { configFetcher = update.ApplyDiskEncryptionRunnerMiddleware(configFetcher) configFetcher = update.ApplySwiftDialogDownloaderMiddleware(configFetcher, updateRunner) + case "windows": + configFetcher = update.ApplyMicrosoftMDMEnrollmentFetcherMiddleware(configFetcher, microsoftMDMEnrollmentCommandFrequency, orbitHostInfo.HardwareUUID) } const orbitFlagsUpdateInterval = 30 * time.Second diff --git a/orbit/pkg/update/execwinapi.go b/orbit/pkg/update/execwinapi.go new file mode 100644 index 0000000000..78107ddbff --- /dev/null +++ b/orbit/pkg/update/execwinapi.go @@ -0,0 +1,9 @@ +package update + +// 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 used +// from outside this package. +type MicrosoftMDMEnrollmentArgs struct { + DiscoveryURL string + HostUUID string +} diff --git a/orbit/pkg/update/execwinapi_stub.go b/orbit/pkg/update/execwinapi_stub.go new file mode 100644 index 0000000000..a40713a815 --- /dev/null +++ b/orbit/pkg/update/execwinapi_stub.go @@ -0,0 +1,7 @@ +//go:build !windows + +package update + +func RunMicrosoftMDMEnrollment(args MicrosoftMDMEnrollmentArgs) error { + return nil +} diff --git a/orbit/pkg/update/execwinapi_windows.go b/orbit/pkg/update/execwinapi_windows.go new file mode 100644 index 0000000000..a1a9bc4970 --- /dev/null +++ b/orbit/pkg/update/execwinapi_windows.go @@ -0,0 +1,125 @@ +//go:build windows + +package update + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "syscall" + "unsafe" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/rs/zerolog/log" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +var ( + dllMDMRegistration *windows.LazyDLL = windows.NewLazySystemDLL("mdmregistration.dll") + + // 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") +) + +// 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 RunMicrosoftMDMEnrollment(args MicrosoftMDMEnrollmentArgs) error { + installType, err := readInstallationType() + if err != nil { + return err + } + if strings.ToLower(installType) == "server" { + // do not enroll, it is a server + return errIsWindowsServer + } + return enrollHostToMDM(args) +} + +func readInstallationType() (string, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE) + if err != nil { + return "", err + } + defer k.Close() + + s, _, err := k.GetStringValue("InstallationType") + if err != nil { + return "", err + } + return s, nil +} + +// TODO(mna): refactor to a Windows-specific package to constrain usage of +// unsafe to that package, once https://github.com/fleetdm/fleet/pull/12387 +// lands. + +// Perform the host MDM enrollment process using MS-MDE protocol: +// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mde/5c841535-042e-489e-913c-9d783d741267 +func enrollHostToMDM(args MicrosoftMDMEnrollmentArgs) error { + discoveryURLPtr, err := syscall.UTF16PtrFromString(args.DiscoveryURL) + if err != nil { + return fmt.Errorf("discovery URL to UTF16 pointer: %w", err) + } + + // we use an empty user UPN, it is not a required argument + userPtr, err := syscall.UTF16PtrFromString("") + if err != nil { + return fmt.Errorf("user UPN to UTF16 pointer: %w", err) + } + + accessTok, err := generateWindowsMDMAccessTokenPayload(args) + if err != nil { + return fmt.Errorf("generate access token payload: %w", err) + } + accessTokPtr, err := syscall.UTF16PtrFromString(string(accessTok)) + if err != nil { + return fmt.Errorf("access token to UTF16 pointer: %w", err) + } + + // pre-load the DLL and pre-find the procedure, to return a more meaningful + // message if those steps fail and avoid a panic. + if err := dllMDMRegistration.Load(); err != nil { + return fmt.Errorf("load MDM dll: %w", err) + } + if err := procRegisterDeviceWithManagement.Find(); err != nil { + return fmt.Errorf("find MDM RegisterDeviceWithManagement procedure: %w", err) + } + + code, _, err := procRegisterDeviceWithManagement.Call( + uintptr(unsafe.Pointer(userPtr)), + uintptr(unsafe.Pointer(discoveryURLPtr)), + uintptr(unsafe.Pointer(accessTokPtr)), + ) + 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 nil +} + +func generateWindowsMDMAccessTokenPayload(args MicrosoftMDMEnrollmentArgs) ([]byte, error) { + var pld fleet.MicrosoftMDMAccessTokenPayload + + pld.Type = fleet.MicrosoftMDMProgrammaticEnrollmentType // 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 9d15c76c05..edfe908b83 100644 --- a/orbit/pkg/update/notifications.go +++ b/orbit/pkg/update/notifications.go @@ -1,6 +1,7 @@ package update import ( + "errors" "sync" "time" @@ -10,7 +11,7 @@ import ( type runCmdFunc func() error -// RenewEnrollmentProfileConfigFetcher is a kind of middleware that wraps an +// renewEnrollmentProfileConfigFetcher is a kind of middleware that wraps an // OrbitConfigFetcher and detects if the fleet server sent a notification to // renew the enrollment profile. If so, it runs the command (as root) to // bootstrap the renewal of the profile on the device (the user still needs to @@ -18,7 +19,7 @@ type runCmdFunc func() error // // It ensures only one renewal command is executed at any given time, and that // it doesn't re-execute the command until a certain amount of time has passed. -type RenewEnrollmentProfileConfigFetcher struct { +type renewEnrollmentProfileConfigFetcher struct { // Fetcher is the OrbitConfigFetcher that will be wrapped. It is responsible // for actually returning the orbit configuration or an error. Fetcher OrbitConfigFetcher @@ -36,13 +37,13 @@ type RenewEnrollmentProfileConfigFetcher struct { } func ApplyRenewEnrollmentProfileConfigFetcherMiddleware(fetcher OrbitConfigFetcher, frequency time.Duration) OrbitConfigFetcher { - return &RenewEnrollmentProfileConfigFetcher{Fetcher: fetcher, Frequency: frequency} + return &renewEnrollmentProfileConfigFetcher{Fetcher: fetcher, Frequency: frequency} } // GetConfig calls the wrapped Fetcher's GetConfig method, and if the fleet // server set the renew enrollment profile flag to true, executes the command // to renew the enrollment profile. -func (h *RenewEnrollmentProfileConfigFetcher) GetConfig() (*fleet.OrbitConfig, error) { +func (h *renewEnrollmentProfileConfigFetcher) GetConfig() (*fleet.OrbitConfig, error) { cfg, err := h.Fetcher.GetConfig() // TODO: download and use swiftDialog following the same patterns we @@ -83,3 +84,84 @@ func (h *RenewEnrollmentProfileConfigFetcher) GetConfig() (*fleet.OrbitConfig, e } return cfg, err } + +type execWinAPIFunc func(MicrosoftMDMEnrollmentArgs) error + +type microsoftMDMEnrollmentConfigFetcher struct { + // Fetcher is the OrbitConfigFetcher that will be wrapped. It is responsible + // for actually returning the orbit configuration or an error. + Fetcher OrbitConfigFetcher + // Frequency is the minimum amount of time that must pass between two + // executions of the windows MDM enrollment attempt. + Frequency time.Duration + // HostUUID is the current host's UUID. + HostUUID string + + // for tests, to be able to mock command execution. If nil, will use + // RunMicrosoftMDMEnrollment. + execWinAPIFn execWinAPIFunc + + // ensures only one command runs at a time, protects access to lastRun and + // isWindowsServer. + mu sync.Mutex + lastRun time.Time + isWindowsServer bool +} + +func ApplyMicrosoftMDMEnrollmentFetcherMiddleware( + fetcher OrbitConfigFetcher, + frequency time.Duration, + hostUUID string, +) OrbitConfigFetcher { + return µsoftMDMEnrollmentConfigFetcher{ + Fetcher: fetcher, + Frequency: frequency, + HostUUID: hostUUID, + } +} + +var errIsWindowsServer = errors.New("device is a Windows Server") + +// GetConfig calls the wrapped Fetcher's GetConfig method, and if the fleet +// server set the "needs windows enrollment" flag to true, executes the command +// to enroll into Windows MDM (or not, if the device is a Windows Server). +func (w *microsoftMDMEnrollmentConfigFetcher) GetConfig() (*fleet.OrbitConfig, error) { + cfg, err := w.Fetcher.GetConfig() + + if err == nil && cfg.Notifications.NeedsProgrammaticMicrosoftMDMEnrollment { + if cfg.Notifications.MicrosoftMDMDiscoveryEndpoint == "" { + 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 = RunMicrosoftMDMEnrollment + } + args := MicrosoftMDMEnrollmentArgs{ + DiscoveryURL: cfg.Notifications.MicrosoftMDMDiscoveryEndpoint, + 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") + } + } + } + return cfg, err +} diff --git a/orbit/pkg/update/notifications_test.go b/orbit/pkg/update/notifications_test.go index a065b6d9c1..d0ad149247 100644 --- a/orbit/pkg/update/notifications_test.go +++ b/orbit/pkg/update/notifications_test.go @@ -39,7 +39,7 @@ func TestRenewEnrollmentProfile(t *testing.T) { } var cmdGotCalled bool - renewFetcher := &RenewEnrollmentProfileConfigFetcher{ + renewFetcher := &renewEnrollmentProfileConfigFetcher{ Fetcher: fetcher, Frequency: time.Hour, // doesn't matter for this test runCmdFn: func() error { @@ -71,7 +71,7 @@ func TestRenewEnrollmentProfilePrevented(t *testing.T) { var cmdCallCount int chProceed := make(chan struct{}) - renewFetcher := &RenewEnrollmentProfileConfigFetcher{ + renewFetcher := &renewEnrollmentProfileConfigFetcher{ Fetcher: fetcher, Frequency: 2 * time.Second, // just to be safe with slow environments (CI) runCmdFn: func() error { @@ -120,3 +120,140 @@ func TestRenewEnrollmentProfilePrevented(t *testing.T) { require.Equal(t, 2, cmdCallCount) // the initial call and the one after sleep } + +func TestWindowsMDMEnrollment(t *testing.T) { + var logBuf bytes.Buffer + + oldLog := log.Logger + log.Logger = log.Output(&logBuf) + t.Cleanup(func() { log.Logger = oldLog }) + + cases := []struct { + desc string + enrollFlag 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"}, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + logBuf.Reset() + + fetcher := &dummyConfigFetcher{ + cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{ + NeedsProgrammaticMicrosoftMDMEnrollment: c.enrollFlag, + MicrosoftMDMDiscoveryEndpoint: c.discoveryURL, + }}, + } + + var apiGotCalled bool + enrollFetcher := µsoftMDMEnrollmentConfigFetcher{ + Fetcher: fetcher, + Frequency: time.Hour, // doesn't matter for this test + execWinAPIFn: func(args MicrosoftMDMEnrollmentArgs) error { + apiGotCalled = true + return c.apiErr + }, + } + + cfg, err := enrollFetcher.GetConfig() + 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) + require.Contains(t, logBuf.String(), c.wantLog) + }) + } +} + +func TestWindowsMDMEnrollmentPrevented(t *testing.T) { + var logBuf bytes.Buffer + + oldLog := log.Logger + log.Logger = log.Output(&logBuf) + t.Cleanup(func() { log.Logger = oldLog }) + + fetcher := &dummyConfigFetcher{ + cfg: &fleet.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{ + NeedsProgrammaticMicrosoftMDMEnrollment: true, + MicrosoftMDMDiscoveryEndpoint: "http://example.com", + }}, + } + + var ( + apiCallCount int + apiErr error + ) + chProceed := make(chan struct{}) + enrollFetcher := µsoftMDMEnrollmentConfigFetcher{ + Fetcher: fetcher, + Frequency: 2 * time.Second, // just to be safe with slow environments (CI) + execWinAPIFn: func(args MicrosoftMDMEnrollmentArgs) 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 + }, + } + + assertResult := func(cfg *fleet.OrbitConfig, err error) { + require.NoError(t, err) + require.Equal(t, fetcher.cfg, cfg) + } + + 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/microsoft_mdm.go b/server/fleet/microsoft_mdm.go index d878ac8ec8..c765ac55bf 100644 --- a/server/fleet/microsoft_mdm.go +++ b/server/fleet/microsoft_mdm.go @@ -341,3 +341,20 @@ type SoapFault struct { Reason Reason `xml:"s:reason"` OriginalMessageType int `xml:"-"` } + +// MicrosoftMDMAccessTokenPayload is the payload that gets encoded as JSON and +// provided as opaque access token to the RegisterDeviceWithManagement API. +type MicrosoftMDMAccessTokenPayload struct { + // Type is the enrollment type, such as "programmatic". + Type MicrosoftMDMEnrollmentType `json:"type"` + Payload struct { + HostUUID string `json:"host_uuid"` + } `json:"payload"` +} + +type MicrosoftMDMEnrollmentType int + +// List of supported Microsoft MDM enrollment types. +const ( + MicrosoftMDMProgrammaticEnrollmentType MicrosoftMDMEnrollmentType = 1 +) diff --git a/tools/windows-mdm-enroll/main.go b/tools/windows-mdm-enroll/main.go new file mode 100644 index 0000000000..b2a05adc75 --- /dev/null +++ b/tools/windows-mdm-enroll/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/fleetdm/fleet/v4/orbit/pkg/update" +) + +func main() { + var ( + discoveryURL = flag.String("discovery-url", "", "The Windows MDM discovery URL") + hostUUID = flag.String("host-uuid", "", "The Host UUID") + ) + flag.Parse() + + err := update.RunMicrosoftMDMEnrollment(update.MicrosoftMDMEnrollmentArgs{ + DiscoveryURL: *discoveryURL, + HostUUID: *hostUUID, + }) + fmt.Println(err) +}