Trigger Windows MDM host enrollment on device when notified that it is enabled (#12426)

This commit is contained in:
Martin Angers
2023-06-26 12:13:17 -04:00
committed by GitHub
parent 4be1da6724
commit ca02abb660
9 changed files with 414 additions and 8 deletions
@@ -0,0 +1 @@
* Added execution of programmatic Windows MDM enrollment on eligible devices when Windows MDM is enabled.
+8 -2
View File
@@ -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
+9
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package update
func RunMicrosoftMDMEnrollment(args MicrosoftMDMEnrollmentArgs) error {
return nil
}
+125
View File
@@ -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)
}
+86 -4
View File
@@ -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 &microsoftMDMEnrollmentConfigFetcher{
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
}
+139 -2
View File
@@ -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 := &microsoftMDMEnrollmentConfigFetcher{
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 := &microsoftMDMEnrollmentConfigFetcher{
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
}
+17
View File
@@ -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
)
+22
View File
@@ -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)
}