Use webview in MacOS setup experience (#33884)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Scott Gress
2025-10-08 17:51:26 +01:00
committed by GitHub
parent bad6c91c42
commit be7e0045a9
8 changed files with 509 additions and 238 deletions
@@ -0,0 +1 @@
- Updated the MacOS setup experience to use the new web UI
+51 -106
View File
@@ -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"))
+76 -122
View File
@@ -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,
+6 -1
View File
@@ -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
+115 -4
View File
@@ -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)
})
}
}
+70 -5
View File
@@ -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)
}
+22
View File
@@ -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.
+168
View File
@@ -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 <private_key> --host-uuid <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)
}