From be7e0045a9181fa784f84ae7d579dd5d96ab17f1 Mon Sep 17 00:00:00 2001 From: Scott Gress Date: Wed, 8 Oct 2025 17:51:26 +0100 Subject: [PATCH] Use webview in MacOS setup experience (#33884) **Related issue:** For #33111 # Details This PR updates the setup experience for MacOS to use a web view pointed at the device's "Setting up your device" page rather than using native MacOS UI elements, bringing it more in line with Linux and Windows setup experiences. This covers only the new web UI for the setup experience progress, _not_ the UI for the new case of blocking the device when a piece of software fails to install. I'll add that in a separate PR. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [X] Added/updated automated tests Added tests for the updates to the token rotation code. - [X] QA'd all new/changed functionality manually A new tool is provided to allow testing this code against a virtual machine if a separate host that you can wipe and run setup on is not available. See https://github.com/fleetdm/fleet/blob/sgress454/new-setup-experience/tools/mdm/apple/setupexperience/README.md for details. ## Summary by CodeRabbit - New Features - macOS setup experience moved to a new web-based UI. - Automatic device token rotation during setup to keep sessions valid. - Bug Fixes - More reliable setup flow with improved dialog lifecycle and cleaner handoff to web content. - Dialog elements hidden/cleared appropriately when transitioning to the browser. - Documentation - Added guide and tool to simulate the macOS setup experience on a VM, with prerequisites and usage steps. --- changes/33111-update-macos-setup-experience | 1 + orbit/cmd/orbit/orbit.go | 157 +++++--------- .../pkg/setup_experience/setup_experience.go | 198 +++++++----------- orbit/pkg/swiftdialog/run.go | 7 +- orbit/pkg/token/readwriter.go | 119 ++++++++++- orbit/pkg/token/readwriter_test.go | 75 ++++++- tools/mdm/apple/setupexperience/README.md | 22 ++ tools/mdm/apple/setupexperience/main.go | 168 +++++++++++++++ 8 files changed, 509 insertions(+), 238 deletions(-) create mode 100644 changes/33111-update-macos-setup-experience create mode 100644 tools/mdm/apple/setupexperience/README.md create mode 100644 tools/mdm/apple/setupexperience/main.go diff --git a/changes/33111-update-macos-setup-experience b/changes/33111-update-macos-setup-experience new file mode 100644 index 0000000000..20daeeec24 --- /dev/null +++ b/changes/33111-update-macos-setup-experience @@ -0,0 +1 @@ +- Updated the MacOS setup experience to use the new web UI diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 6a8810d8a9..b309ce2b40 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -1136,6 +1136,56 @@ func main() { ) orbitClient.RegisterConfigReceiver(scriptConfigReceiver) + var trw *token.ReadWriter + var deviceClient *service.DeviceClient + // Note that the deviceClient used by orbit must not define a retry on + // invalid token, because its goal is to detect invalid tokens when + // making requests with this client. + deviceClient, err = service.NewDeviceClient( + fleetURL, + c.Bool("insecure"), + c.String("fleet-certificate"), + fleetClientCertificate, + c.String("fleet-desktop-alternative-browser-host"), + ) + if err != nil { + return fmt.Errorf("initializing client: %w", err) + } + + // Create a new token read/writer that will store the token on disk. + // This token will be used to identify this desktop to the Fleet server. + trw = token.NewReadWriter(filepath.Join(c.String("root-dir"), constant.DesktopTokenFileName), deviceClient.CheckToken) + if err := trw.LoadOrGenerate(); err != nil { + return fmt.Errorf("initializing token read writer: %w", err) + } + + // we enable remote updates only if the server supports them by setting + // this function. + trw.SetRemoteUpdateFunc( + func(token string) error { + return orbitClient.SetOrUpdateDeviceToken(token) + }, + ) + + // Check if the token is not expired and still good. + // If not, rotate the token iff the server is reachable. + if serverIsReachable { + expired, _ := trw.HasExpired() + if expired || deviceClient.CheckToken(trw.GetCached()) != nil { + if err := trw.Rotate(); err != nil { + return fmt.Errorf("rotating token: %w", err) + } + } + } + + if c.Bool("fleet-desktop") { + // Ensure that the token rotation checker is started, + // so that we have a valid token to launch the + // My Device page. + stopRotation := trw.StartRotation() + defer stopRotation() + } + switch runtime.GOOS { case "darwin": orbitClient.RegisterConfigReceiver(update.ApplyRenewEnrollmentProfileConfigFetcherMiddleware( @@ -1144,7 +1194,7 @@ func main() { orbitClient.RegisterConfigReceiver(update.ApplyNudgeConfigReceiverMiddleware(update.NudgeConfigFetcherOptions{ UpdateRunner: updateRunner, RootDir: c.String("root-dir"), Interval: nudgeLaunchInterval, })) - setupExperiencer := setupexperience.NewSetupExperiencer(orbitClient, c.String("root-dir")) + setupExperiencer := setupexperience.NewSetupExperiencer(orbitClient, deviceClient, c.String("root-dir"), trw) orbitClient.RegisterConfigReceiver(setupExperiencer) orbitClient.RegisterConfigReceiver(update.ApplySwiftDialogDownloaderMiddleware(updateRunner)) @@ -1232,111 +1282,6 @@ func main() { interrupt: orbitClient.InterruptConfigReceivers, }) - var trw *token.ReadWriter - var deviceClient *service.DeviceClient - if c.Bool("fleet-desktop") { - trw = token.NewReadWriter(filepath.Join(c.String("root-dir"), constant.DesktopTokenFileName)) - if err := trw.LoadOrGenerate(); err != nil { - return fmt.Errorf("initializing token read writer: %w", err) - } - - log.Info().Msg("token rotation is enabled") - - // we enable remote updates only if the server supports them by setting - // this function. - trw.SetRemoteUpdateFunc( - func(token string) error { - return orbitClient.SetOrUpdateDeviceToken(token) - }, - ) - - // Note that the deviceClient used by orbit must not define a retry on - // invalid token, because its goal is to detect invalid tokens when - // making requests with this client. - deviceClient, err = service.NewDeviceClient( - fleetURL, - c.Bool("insecure"), - c.String("fleet-certificate"), - fleetClientCertificate, - c.String("fleet-desktop-alternative-browser-host"), - ) - if err != nil { - return fmt.Errorf("initializing client: %w", err) - } - - // Check if the token is not expired and still good. - // If not, rotate the token iff the server is reachable. - if serverIsReachable { - expired, _ := trw.HasExpired() - if expired || deviceClient.CheckToken(trw.GetCached()) != nil { - if err := trw.Rotate(); err != nil { - return fmt.Errorf("rotating token: %w", err) - } - } - } - - go func() { - // This timer is used to check if the token should be rotated if at - // least one hour has passed since the last modification of the token - // file. - // - // This is better than using a ticker that ticks every hour because the - // we can't ensure the tick actually runs every hour (eg: the computer is - // asleep). - localCheckDuration := 30 * time.Second - localCheckTicker := time.NewTicker(localCheckDuration) - defer localCheckTicker.Stop() - - // This timer is used to periodically check if the token is valid. The - // server might deem a toked as invalid for reasons out of our control, - // for example if the database is restored to a back-up or if somebody - // manually invalidates the token in the db. - remoteCheckDuration := 5 * time.Minute - remoteCheckTicker := time.NewTicker(remoteCheckDuration) - defer remoteCheckTicker.Stop() - - for { - select { - case <-localCheckTicker.C: - localCheckTicker.Reset(localCheckDuration) - - log.Debug().Msgf("initiating local token check, cached mtime: %s", trw.GetMtime()) - hasChanged, err := trw.HasChanged() - if err != nil { - log.Error().Err(err).Msg("error checking if token has changed") - } - - exp, remain := trw.HasExpired() - - // rotate if the token file has been modified, if the token is - // expired or if it is very close to expire. - if hasChanged || exp || remain <= time.Second { - log.Info().Msg("token TTL expired, rotating token") - - if err := trw.Rotate(); err != nil { - log.Error().Err(err).Msg("error rotating token") - } - } else if remain > 0 && remain < localCheckDuration { - // check again when the token will expire, which will happen - // before the next rotation check - localCheckTicker.Reset(remain) - log.Debug().Msgf("token will expire soon, checking again in: %s", remain) - } - - case <-remoteCheckTicker.C: - log.Debug().Msgf("initiating remote token check after %s", remoteCheckDuration) - if err := deviceClient.CheckToken(trw.GetCached()); err != nil { - log.Info().Err(err).Msg("periodic check of token failed, initiating rotation") - - if err := trw.Rotate(); err != nil { - log.Error().Err(err).Msg("error rotating token") - } - } - } - } - }() - } - // On Windows, where augeas doesn't work, we have a stubbed CopyLenses that always returns // `"", nil`. Therefore there's no platform-specific stuff required here augeasPath, err := augeas.CopyLenses(c.String("root-dir")) diff --git a/orbit/pkg/setup_experience/setup_experience.go b/orbit/pkg/setup_experience/setup_experience.go index 06f29fa7a5..258443a749 100644 --- a/orbit/pkg/setup_experience/setup_experience.go +++ b/orbit/pkg/setup_experience/setup_experience.go @@ -11,43 +11,50 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/constant" "github.com/fleetdm/fleet/v4/orbit/pkg/swiftdialog" + "github.com/fleetdm/fleet/v4/orbit/pkg/token" "github.com/fleetdm/fleet/v4/orbit/pkg/update" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/rs/zerolog/log" ) -const doneMessage = `### Setup is complete\n\nPlease contact your IT Administrator if there were any errors.` - -// Client is the minimal interface needed to communicate with the Fleet server. -type Client interface { +// OrbitClient is the minimal interface needed to communicate with the Fleet server. +type OrbitClient interface { GetSetupExperienceStatus() (*fleet.SetupExperienceStatusPayload, error) } +// DeviceClient is the minimal interface needed to get the device's browser URL. +type DeviceClient interface { + BrowserDeviceURL(token string) string +} + // SetupExperiencer is the type that manages the Fleet setup experience flow during macOS Setup // Assistant. It uses swiftDialog as a UI for showing the status of software installations and // script execution that are configured to run before the user has full access to the device. // If the setup experience is supposed to run, it will launch a single swiftDialog instance and then // update that instance based on the results from the /orbit/setup_experience/status endpoint. type SetupExperiencer struct { - OrbitClient Client - closeChan chan struct{} - rootDirPath string + OrbitClient OrbitClient + DeviceClient DeviceClient + closeChan chan struct{} + rootDirPath string // Note: this object is not safe for concurrent use. Since the SetupExperiencer is a singleton, // its Run method is called within a WaitGroup, // and no other parts of Orbit need access to this field (or any other parts of the // SetupExperiencer), it's OK to not protect this with a lock. - sd *swiftdialog.SwiftDialog - uiSteps map[string]swiftdialog.ListItem - started bool + sd *swiftdialog.SwiftDialog + started bool + trw *token.ReadWriter + stopTokenRotation func() } -func NewSetupExperiencer(client Client, rootDirPath string) *SetupExperiencer { +func NewSetupExperiencer(orbitClient OrbitClient, deviceClient DeviceClient, rootDirPath string, trw *token.ReadWriter) *SetupExperiencer { return &SetupExperiencer{ - OrbitClient: client, - closeChan: make(chan struct{}), - uiSteps: make(map[string]swiftdialog.ListItem), - rootDirPath: rootDirPath, + OrbitClient: orbitClient, + DeviceClient: deviceClient, + closeChan: make(chan struct{}), + rootDirPath: rootDirPath, + trw: trw, } } @@ -57,6 +64,12 @@ func (s *SetupExperiencer) Run(oc *fleet.OrbitConfig) error { return nil } + // Ensure that the token rotation checker is started, so that we have a valid token + // when we need to show or refresh the My Device URL in the webview. + if s.stopTokenRotation == nil { + s.stopTokenRotation = s.trw.StartRotation() + } + _, binaryPath, _ := update.LocalTargetPaths( s.rootDirPath, "swiftDialog", @@ -75,6 +88,13 @@ func (s *SetupExperiencer) Run(oc *fleet.OrbitConfig) error { if err != nil { return err } + // Marshall the payload for logging + payloadBytes, err := json.Marshal(payload) + if err != nil { + log.Error().Err(err).Msg("marshalling setup experience payload for logging") + } else { + log.Debug().Msgf("setup experience payload: %s", string(payloadBytes)) + } // If swiftDialog isn't up yet, then launch it orgLogo := payload.OrgLogoURL @@ -125,6 +145,39 @@ func (s *SetupExperiencer) Run(oc *fleet.OrbitConfig) error { } } + // If we got this far, then we can hand the UI over to the webview. + + // Clear the dialog message. + if err := s.sd.HideMessage(); err != nil { + log.Error().Err(err).Msg("clearing message in setup experience UI") + } + // Remove the icon. + if err := s.sd.HideIcon(); err != nil { + log.Error().Err(err).Msg("clearing icon in setup experience UI") + } + // Hide the title. + if err := s.sd.HideTitle(); err != nil { + log.Error().Err(err).Msg("hiding title in setup experience UI") + } + // Hide the progress. + if err := s.sd.HideProgress(); err != nil { + log.Error().Err(err).Msg("hiding progress in setup experience UI") + } + // Get the device token. + token, err := s.trw.Read() + if err != nil { + return fmt.Errorf("getting device token: %w", err) + } + // Get the My Device URL. + browserURL := s.DeviceClient.BrowserDeviceURL(token) + // log out the url + log.Debug().Msgf("setup experience: opening web content URL: %s", browserURL) + // Set the web content URL. + if err := s.sd.SetWebContent(browserURL + "?setup_only=1"); err != nil { + log.Error().Err(err).Msg("setting web content URL in setup experience UI") + return nil + } + // Note that we are setting this based on the current payload only just in case something // was removed from the payload that was there earlier(e.g. a deleted software title). allStepsDone := true @@ -133,8 +186,6 @@ func (s *SetupExperiencer) Run(oc *fleet.OrbitConfig) error { if len(payload.Software) > 0 || payload.Script != nil { log.Info().Msg("setup experience: rendering software and script UI") - var stepsDone int - var prog uint var steps []*fleet.SetupExperienceStatusResult if len(payload.Software) > 0 { steps = payload.Software @@ -144,93 +195,16 @@ func (s *SetupExperiencer) Run(oc *fleet.OrbitConfig) error { steps = append(steps, payload.Script) } - // Check for any items that were in the payload that are no longer there. This can happen - // if a software title was deleted, for instance - for uiStepName, uiStep := range s.uiSteps { - uiStepExistsInPayload := false - for _, step := range steps { - if uiStep.Title == step.Name { - uiStepExistsInPayload = true - break - } - } - if !uiStepExistsInPayload { - log.Info().Msgf("Setup Experience: list item %s removed from payload", uiStep.Title) - err = s.sd.DeleteListItemByTitle(uiStep.Title) - if err != nil { - log.Info().Err(err).Msg("deleting list item removed from payload from setup experience UI") - } - delete(s.uiSteps, uiStepName) - } - } - for _, step := range steps { - currentStepState := resultToListItem(step) - if priorStepState, ok := s.uiSteps[step.Name]; ok { - if currentStepState != priorStepState { - // We only want to resend on change so we're not unnecessarily scrolling the UI - err = s.sd.UpdateListItemByTitle(currentStepState.Title, currentStepState.StatusText, currentStepState.Status) - if err != nil { - log.Info().Err(err).Msg("updating list item in setup experience UI") - } - } else { - log.Info().Msgf("setup experience: no change in status for %s", step.Name) - } - } else { - err = s.sd.AddListItem(currentStepState) - if err != nil { - log.Info().Err(err).Msg("adding list item in setup experience UI") - } - s.uiSteps[step.Name] = currentStepState - } - - if step.Status == fleet.SetupExperienceStatusFailure || step.Status == fleet.SetupExperienceStatusSuccess { - stepsDone++ - // The swiftDialog progress bar is out of 100 - for range int(float32(1) / float32(len(steps)) * 100) { - prog++ - } - } else { + if step.Status != fleet.SetupExperienceStatusFailure && step.Status != fleet.SetupExperienceStatusSuccess { allStepsDone = false } } - - if err = s.sd.UpdateProgress(prog); err != nil { - log.Info().Err(err).Msg("updating progress bar in setup experience UI") - } - - if err := s.sd.ShowList(); err != nil { - log.Info().Err(err).Msg("showing progress bar in setup experience UI") - } - - if err := s.sd.UpdateProgressText(fmt.Sprintf("%.0f%%", float32(stepsDone)/float32(len(steps))*100)); err != nil { - log.Info().Err(err).Msg("updating progress text in setup experience UI") - } - } - // If we get here, we can render the "done" UI. - + // If we get here, we can close the webview. + // It will likely already be displaying a "done" message. if allStepsDone { - if err := s.sd.SetMessage(doneMessage); err != nil { - log.Info().Err(err).Msg("setting message in setup experience UI") - } - - if err := s.sd.CompleteProgress(); err != nil { - log.Info().Err(err).Msg("completing progress bar in setup experience UI") - } - - if len(payload.Software) > 0 || payload.Script != nil { - // need to call this because SetMessage removes the list from the view for some reason :( - if err := s.sd.ShowList(); err != nil { - log.Info().Err(err).Msg("showing list in setup experience UI") - } - } - - if err := s.sd.UpdateProgressText("100%"); err != nil { - log.Info().Err(err).Msg("updating progress text in setup experience UI") - } - if err := s.sd.EnableButton1(true); err != nil { log.Info().Err(err).Msg("enabling close button in setup experience UI") } @@ -242,6 +216,9 @@ func (s *SetupExperiencer) Run(oc *fleet.OrbitConfig) error { if err := s.sd.Quit(); err != nil { log.Info().Err(err).Msg("quitting setup experience UI on completion") } + + // Stop the token rotation checker since we're done with the setup experience. + s.stopTokenRotation() } return nil @@ -320,37 +297,14 @@ func (s *SetupExperiencer) startSwiftDialog(binaryPath, orgLogo string) error { return nil } -func resultToListItem(result *fleet.SetupExperienceStatusResult) swiftdialog.ListItem { - statusText := "Pending" - status := swiftdialog.StatusWait - - switch result.Status { - case fleet.SetupExperienceStatusFailure: - status = swiftdialog.StatusFail - statusText = "Failed" - case fleet.SetupExperienceStatusSuccess: - status = swiftdialog.StatusSuccess - statusText = "Installed" - if result.IsForScript() { - statusText = "Ran" - } - } - - return swiftdialog.ListItem{ - Title: result.Name, - Status: status, - StatusText: statusText, - } -} - // LinuxSetupExperiencer runs the setup experience on Linux hosts. type LinuxSetupExperiencer struct { - orbitClient Client + orbitClient OrbitClient rootDir string } // NewLinuxSetupExperiencer creates a config receiver to run the setup experience on Linux hosts. -func NewLinuxSetupExperiencer(client Client, rootDir string) *LinuxSetupExperiencer { +func NewLinuxSetupExperiencer(client OrbitClient, rootDir string) *LinuxSetupExperiencer { return &LinuxSetupExperiencer{ orbitClient: client, rootDir: rootDir, diff --git a/orbit/pkg/swiftdialog/run.go b/orbit/pkg/swiftdialog/run.go index 8a594bdc9e..3c6defe96d 100644 --- a/orbit/pkg/swiftdialog/run.go +++ b/orbit/pkg/swiftdialog/run.go @@ -271,6 +271,11 @@ func (s *SwiftDialog) SetMessageKeepListItems(message string) error { return s.sendMultiCommand(fmt.Sprintf("message: %s", sanitize(message)), "list: show") } +// HideMessage hides the message area. +func (s *SwiftDialog) HideMessage() error { + return s.sendCommand("message", "none") +} + /////////// // Image // /////////// @@ -453,7 +458,7 @@ func (s *SwiftDialog) SetIconAlignment(alignment Alignment) error { // Hide the icon func (s *SwiftDialog) HideIcon() error { - return s.sendCommand("icon", "hide") + return s.sendCommand("icon", "none") } // Changes the size of the displayed icon diff --git a/orbit/pkg/token/readwriter.go b/orbit/pkg/token/readwriter.go index fb06556ca1..37f9f3e8a4 100644 --- a/orbit/pkg/token/readwriter.go +++ b/orbit/pkg/token/readwriter.go @@ -4,23 +4,34 @@ import ( "errors" "fmt" "os" + "slices" + "sync" "time" "github.com/fleetdm/fleet/v4/orbit/pkg/constant" "github.com/fleetdm/fleet/v4/pkg/retry" "github.com/google/uuid" + "github.com/rs/zerolog/log" ) type remoteUpdaterFunc func(token string) error type ReadWriter struct { *Reader - remoteUpdate remoteUpdaterFunc + remoteUpdate remoteUpdaterFunc + rotationWatchers []chan struct{} + checkTokenFunc func(token string) error + localCheckDuration time.Duration + remoteCheckDuration time.Duration + rotationStopCh chan struct{} } -func NewReadWriter(path string) *ReadWriter { +func NewReadWriter(path string, checkTokenFunc func(token string) error) *ReadWriter { return &ReadWriter{ - Reader: &Reader{Path: path}, + Reader: &Reader{Path: path}, + checkTokenFunc: checkTokenFunc, + localCheckDuration: 30 * time.Second, + remoteCheckDuration: 5 * time.Minute, } } @@ -58,7 +69,6 @@ func (rw *ReadWriter) Rotate() error { err = retry.Do(func() error { return rw.Write(id) }, retry.WithMaxAttempts(attempts), retry.WithInterval(interval)) - if err != nil { return fmt.Errorf("saving token after %d attempts: %w", attempts, err) } @@ -110,3 +120,104 @@ func (rw *ReadWriter) Write(id string) error { func (rw *ReadWriter) setChmod() error { return os.Chmod(rw.Path, constant.DefaultWorldReadableFileMode) } + +func (rw *ReadWriter) StartRotation() func() { + // Create a channel that this caller can use to signal they want to stop + // watching for rotations. + stopCh := make(chan struct{}) + // Append it to the list of watchers. + rw.rotationWatchers = append(rw.rotationWatchers, stopCh) + + if len(rw.rotationWatchers) == 1 { + log.Info().Msg("token rotation is enabled") + + // Create a channel we can use to stop the rotation goroutine. + rw.rotationStopCh = make(chan struct{}) + + go func() { + // This timer is used to check if the token should be rotated if at + // least one hour has passed since the last modification of the token + // file. + // + // This is better than using a ticker that ticks every hour because the + // we can't ensure the tick actually runs every hour (eg: the computer is + // asleep). + localCheckDuration := rw.localCheckDuration + localCheckTicker := time.NewTicker(localCheckDuration) + defer localCheckTicker.Stop() + + // This timer is used to periodically check if the token is valid. The + // server might deem a toked as invalid for reasons out of our control, + // for example if the database is restored to a back-up or if somebody + // manually invalidates the token in the db. + remoteCheckDuration := rw.remoteCheckDuration + remoteCheckTicker := time.NewTicker(remoteCheckDuration) + defer remoteCheckTicker.Stop() + + for { + select { + case <-rw.rotationStopCh: + log.Info().Msg("token rotation stopped") + return + case <-localCheckTicker.C: + localCheckTicker.Reset(localCheckDuration) + + log.Debug().Msgf("initiating local token check, cached mtime: %s", rw.GetMtime()) + hasChanged, err := rw.HasChanged() + if err != nil { + log.Error().Err(err).Msg("error checking if token has changed") + } + + exp, remain := rw.HasExpired() + + // rotate if the token file has been modified, if the token is + // expired or if it is very close to expire. + if hasChanged || exp || remain <= time.Second { + log.Info().Msg("token TTL expired, rotating token") + + if err := rw.Rotate(); err != nil { + log.Error().Err(err).Msg("error rotating token") + } + } else if remain > 0 && remain < localCheckDuration { + // check again when the token will expire, which will happen + // before the next rotation check + localCheckTicker.Reset(remain) + log.Debug().Msgf("token will expire soon, checking again in: %s", remain) + } + + case <-remoteCheckTicker.C: + log.Debug().Msgf("initiating remote token check after %s", remoteCheckDuration) + if err := rw.checkTokenFunc(rw.GetCached()); err != nil { + log.Info().Err(err).Msg("periodic check of token failed, initiating rotation") + + if err := rw.Rotate(); err != nil { + log.Error().Err(err).Msg("error rotating token") + } + } + } + } + }() + } + + // Start goroutine to handle this caller's stop signal. + go func() { + <-stopCh + // Remove this caller's stop channel from the list of watchers. + rw.rotationWatchers = slices.DeleteFunc(rw.rotationWatchers, func(ch chan struct{}) bool { + return ch == stopCh + }) + + // If all callers have signaled to stop, signal the main rotation goroutine to stop. + if len(rw.rotationWatchers) == 0 { + close(rw.rotationStopCh) + } + }() + + // Return a function that the caller can use to stop watching for rotations. + var closeOnce sync.Once + return func() { + closeOnce.Do(func() { + close(stopCh) + }) + } +} diff --git a/orbit/pkg/token/readwriter_test.go b/orbit/pkg/token/readwriter_test.go index 4b1535dc5f..4981189d8d 100644 --- a/orbit/pkg/token/readwriter_test.go +++ b/orbit/pkg/token/readwriter_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/fleetdm/fleet/v4/orbit/pkg/constant" "github.com/stretchr/testify/require" @@ -15,7 +16,7 @@ func TestLoadOrGenerate(t *testing.T) { file := filepath.Join(dir, "identifier") defer os.Remove(file) - rw := NewReadWriter(file) + rw := NewReadWriter(file, nil) require.NoError(t, rw.LoadOrGenerate()) token, err := rw.Read() require.NoError(t, err) @@ -36,7 +37,7 @@ func TestLoadOrGenerate(t *testing.T) { require.NoError(t, err) oldMtime := stat.ModTime() - rw := NewReadWriter(file.Name()) + rw := NewReadWriter(file.Name(), nil) err = rw.LoadOrGenerate() require.NoError(t, err) token, err := rw.Read() @@ -62,7 +63,7 @@ func TestLoadOrGenerate(t *testing.T) { require.NoError(t, err) require.Equal(t, os.FileMode(constant.DefaultFileMode), stat.Mode()) - rw := NewReadWriter(file.Name()) + rw := NewReadWriter(file.Name(), nil) err = rw.LoadOrGenerate() require.NoError(t, err) token, err := rw.Read() @@ -82,7 +83,7 @@ func TestLoadOrGenerate(t *testing.T) { require.NoError(t, file.Chmod(0x600)) defer os.Remove(file.Name()) - rw := NewReadWriter(file.Name()) + rw := NewReadWriter(file.Name(), nil) token, err := rw.Read() require.Error(t, err) require.Empty(t, token) @@ -93,7 +94,7 @@ func TestRotate(t *testing.T) { file, err := os.CreateTemp("", t.Name()) require.NoError(t, err) defer os.Remove(file.Name()) - rw := NewReadWriter(file.Name()) + rw := NewReadWriter(file.Name(), nil) token, err := rw.Read() require.NoError(t, err) @@ -118,3 +119,67 @@ func TestRotate(t *testing.T) { require.NoError(t, err) require.Equal(t, os.FileMode(constant.DefaultWorldReadableFileMode), stat.Mode()) } + +func TestRotater(t *testing.T) { + var numRemoteChecks int + file, err := os.CreateTemp("", "identifier") + require.NoError(t, err) + _, err = file.WriteString("test") + require.NoError(t, err) + rw := NewReadWriter(file.Name(), func(token string) error { + numRemoteChecks++ + return nil + }) + rw.localCheckDuration = 100 * time.Millisecond + rw.remoteCheckDuration = 200 * time.Millisecond + + err = rw.LoadOrGenerate() + require.NoError(t, err) + + var numUpdates int + rw.SetRemoteUpdateFunc(func(token string) error { + numUpdates++ + return nil + }) + + // Set the token's mtime to more than an hour ago so that it + // will be considered expired and trigger a rotation. + rw.mu.Lock() + rw.mtime = time.Now().Add(-2 * time.Hour) + rw.mu.Unlock() + + stop1 := rw.StartRotation() + stop2 := rw.StartRotation() + + time.Sleep(150 * time.Millisecond) + require.Equal(t, 1, numUpdates) + + // Close the first stop channel, this should not stop the rotation. + stop1() + // Do it again to prove that closing multiple times is safe. + stop1() + + // Set the token's mtime to more than an hour ago again. + rw.mu.Lock() + rw.mtime = time.Now().Add(-2 * time.Hour) + rw.mu.Unlock() + + // Now wait enough time for the remote check to trigger a rotation. + time.Sleep(209 * time.Millisecond) + require.Equal(t, 2, numUpdates) + require.Equal(t, 1, numRemoteChecks) + + // Reset the mtime one more time. + rw.mu.Lock() + rw.mtime = time.Now().Add(-2 * time.Hour) + rw.mu.Unlock() + + // Now close the second stop channel, this should stop the rotation. + stop2() + + // Wait enough time to ensure that if the rotation was still running + // we would have done another remote check. + time.Sleep(250 * time.Millisecond) + require.Equal(t, 2, numUpdates) + require.Equal(t, 1, numRemoteChecks) +} diff --git a/tools/mdm/apple/setupexperience/README.md b/tools/mdm/apple/setupexperience/README.md new file mode 100644 index 0000000000..cdd2d81a37 --- /dev/null +++ b/tools/mdm/apple/setupexperience/README.md @@ -0,0 +1,22 @@ +# MacOS setup experience on a Virtual Machine + +This is a quick and dirty tool that does some direct SQL queries to set up the necessary state, and therefore comes with some inherent brittleness. + +To use: + +1. Start a local server [with MDM enabled](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/getting-started/testing-and-local-development.md#mdm-setup-and-testing). +2. Ensure that end-user validation is _disabled_ in setup experience config. +3. Ensure no bootstrap package is uploaded. +4. Ensure no custom setup profile is uploaded. +5. Add some software and/or scripts to the setup experience config. +6. Enroll your macOS VM into a team. +7. Get the UUID of the VM, either via a live query on Fleet (`SELECT uuid FROM osquery_info`), by inspecting the API response from the `/fleet/device/:token` endpoint on the My Device page, or querying the `hosts` table of the MySQL database directly. +7. Run this tool with the appropriate flags to set up the necessary database records, e.g.: + +```bash +go run main.go -server-private-key=$(cat ~/path/to/private/key) -host-uuid="your-enrolled-host-uuid" +``` + +If the setup dialog doesn't appear on the VM, or it remains on the initial setup screen, try running the tool again and waiting. + +Note that the setup experience dialog may not auto-dismiss after completing. You can dismiss manually it by pressing Command-Shift-X. To test the dialog again, run this tool again and restart Orbit on the device. diff --git a/tools/mdm/apple/setupexperience/main.go b/tools/mdm/apple/setupexperience/main.go new file mode 100644 index 0000000000..552771093d --- /dev/null +++ b/tools/mdm/apple/setupexperience/main.go @@ -0,0 +1,168 @@ +// This tool allows you to simulate Apple DEP enrollment on a virtual machine +// for the purposes of testing the macOS setup experience feature in Fleet. +// It connects to a MySQL database, inserts necessary records to simulate +// MDM enrollment, and enqueues setup experience items for a specified host. +// +// Usage: +// +// go run main.go --server-private-key --host-uuid +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + + "github.com/WatchBeam/clock" + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/datastore/mysql" + "github.com/jmoiron/sqlx" + + kitlog "github.com/go-kit/log" +) + +func main() { + mysqlAddr := flag.String("mysql", "localhost:3306", "mysql address") + serverPrivateKey := flag.String("server-private-key", "", "fleet server's private key (to decrypt MDM assets)") + hostUUID := flag.String("host-uuid", "", "the host serial # to enqueue setup items for") + + flag.Parse() + + if *serverPrivateKey == "" { + log.Fatal("must provide -server-private-key") + } + + if len(*serverPrivateKey) > 32 { + // We truncate to 32 bytes because AES-256 requires a 32 byte (256 bit) PK, but some + // infra setups generate keys that are longer than 32 bytes. + truncatedServerPrivateKey := (*serverPrivateKey)[:32] + serverPrivateKey = &truncatedServerPrivateKey + } + + mysqlConf := config.MysqlConfig{ + Protocol: "tcp", + Address: *mysqlAddr, + Database: "fleet", + Username: "fleet", + Password: "insecure", + MaxOpenConns: 50, + MaxIdleConns: 50, + ConnMaxLifetime: 0, + } + + // Connect to MySQL directly using sqlx for the setup steps. + // TODO -- use Fleet Datastore methods to do these steps, if possible? + dsn := fmt.Sprintf("%s:%s@%s(%s)/%s", mysqlConf.Username, mysqlConf.Password, mysqlConf.Protocol, mysqlConf.Address, mysqlConf.Database) + + db, err := sqlx.Open("mysql", dsn) // or your traced driver name + if err != nil { + log.Fatal("failed to connect to database:", err) + } + // Pool tuning similar to Fleet + db.SetMaxIdleConns(10) + db.SetMaxOpenConns(50) + // db.SetConnMaxLifetime(time.Second * time.Duration(conf.ConnMaxLifetime)) + if err := db.Ping(); err != nil { + log.Fatal("failed to ping database:", err) + } + + ctx := context.Background() + + var teamID uint + + // Get the host ID and team ID for the provided host UUID. + type HostInfo struct { + ID uint `db:"id"` + TeamID *uint `db:"team_id"` + } + var hostInfo HostInfo + err = db.GetContext(ctx, &hostInfo, `SELECT id, team_id FROM hosts WHERE uuid = ?`, *hostUUID) + if err != nil { + log.Fatalf("failed to query host info for UUID %s: %v", *hostUUID, err) + } + if hostInfo.TeamID == nil { + log.Fatalf("host must belong to a team") + } + teamID = *hostInfo.TeamID + hostID := &hostInfo.ID + + // Get the apple mdm profiles that will need to be inserted by querying + // the mdm_apple_configuration_profiles table, getting the identifier, + // profile_uuid, name and checksum columns. + type mdmProfile struct { + ProfileIdentifier string `db:"identifier"` + ProfileUUID string `db:"profile_uuid"` + Name string `db:"name"` + Checksum string `db:"checksum"` + } + var mdmProfiles []mdmProfile + err = db.SelectContext(ctx, &mdmProfiles, `SELECT identifier, profile_uuid, name, checksum FROM mdm_apple_configuration_profiles WHERE team_id = ?`, teamID) + if err != nil { + log.Fatal("failed to query mdm_apple_configuration_profiles:", err) + } + if len(mdmProfiles) == 0 { + log.Fatal("no mdm_apple_configuration_profiles found; must have at least one") + } + + // Insert nano_devices and nano_enrollments rows for the host UUID if they don't exist + _, err = db.ExecContext(ctx, `INSERT IGNORE INTO host_mdm (host_id, enrolled) VALUES (?, 1) ON DUPLICATE KEY UPDATE enrolled = 1`, *hostID) + if err != nil { + log.Fatalf("failed to insert host_mdm for host %d: %v", *hostID, err) + } + _, err = db.ExecContext(ctx, `INSERT IGNORE INTO nano_devices (id, platform, authenticate) VALUES (?, 'darwin', 0)`, *hostUUID) + if err != nil { + log.Fatalf("failed to insert nano_devices for host UUID %s: %v", *hostUUID, err) + } + _, err = db.ExecContext(ctx, ` + INSERT INTO nano_enrollments ( + id, device_id, user_id, type, topic, push_magic, token_hex, enabled, + token_update_tally, last_seen_at, enrolled_from_migration + ) VALUES ( + ?, ?, NULL, 'Device', 'com.example.mdm', 'magic-token', 'deadbeef', 1, + 1, NOW(), 0 + ) ON DUPLICATE KEY UPDATE enabled = 1`, *hostUUID, *hostUUID) + if err != nil { + log.Fatalf("failed to insert nano_enrollments for host UUID %s: %v", *hostUUID, err) + } + _, err = db.ExecContext(ctx, ` + INSERT INTO host_mdm_apple_awaiting_configuration (host_uuid, awaiting_configuration) VALUES (?, 1) ON DUPLICATE KEY UPDATE awaiting_configuration = 1 + `, *hostUUID) + if err != nil { + log.Fatalf("failed to insert host_mdm_apple_awaiting_configuration for host %s: %v", *hostUUID, err) + } + + // For each profile, insert a row into host_mdm_apple_profiles if one doesn't already exist. + for _, p := range mdmProfiles { + _, err = db.ExecContext(ctx, ` + INSERT INTO host_mdm_apple_profiles ( + host_uuid, profile_identifier, profile_uuid, profile_name, checksum, status, operation_type, command_uuid + ) VALUES (?, ?, ?, ?, ?, 'verified', 'install', '') ON DUPLICATE KEY UPDATE status = 'verified', operation_type = 'install', command_uuid = ''; + `, *hostUUID, p.ProfileIdentifier, p.ProfileUUID, p.Name, p.Checksum) + if err != nil { + log.Fatalf("failed to insert host_mdm_apple_profiles for profile %s: %v", p.ProfileIdentifier, err) + } + } + + logger := kitlog.NewLogfmtLogger(os.Stderr) + opts := []mysql.DBOption{ + mysql.Logger(logger), + mysql.WithFleetConfig(&config.FleetConfig{ + Server: config.ServerConfig{ + PrivateKey: *serverPrivateKey, + }, + }), + } + mds, err := mysql.New(mysqlConf, clock.C, opts...) + if err != nil { + log.Fatal(err) + } + + _, err = mds.EnqueueSetupExperienceItems(ctx, "darwin", *hostUUID, teamID) + if err != nil { + log.Fatalf("failed to enqueue setup experience items for host %s: %v", *hostUUID, err) + } + + fmt.Printf("Successfully enqueued setup experience items for host UUID %s\n", *hostUUID) +}