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