From 73658ddeb8d3aa4079d348089e16633177a82afa Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Tue, 13 Aug 2024 12:59:50 -0500 Subject: [PATCH] Update MDM migration flow with offline dialog (#21274) --- orbit/cmd/desktop/desktop.go | 90 ++++--- orbit/pkg/constant/constant.go | 3 + orbit/pkg/migration/readwriter.go | 28 +- orbit/pkg/update/swift_dialog.go | 2 + orbit/pkg/useraction/mdm_migration.go | 2 + orbit/pkg/useraction/mdm_migration_darwin.go | 254 +++++++++++++++++- .../pkg/useraction/mdm_migration_notdarwin.go | 8 +- 7 files changed, 343 insertions(+), 44 deletions(-) diff --git a/orbit/cmd/desktop/desktop.go b/orbit/cmd/desktop/desktop.go index b34053ca9eb0..ecff1cebbbb0 100644 --- a/orbit/cmd/desktop/desktop.go +++ b/orbit/cmd/desktop/desktop.go @@ -1,6 +1,7 @@ package main import ( + "context" _ "embed" "errors" "fmt" @@ -60,6 +61,10 @@ func setupRunners() { } func main() { + // FIXME: we need to do a better job of graceful shutdown, releasing resources, stopping + // tickers, etc. (https://github.com/fleetdm/fleet/issues/21256) + ctx, cancel := context.WithCancel(context.Background()) + // Orbits uses --version to get the fleet-desktop version. Logs do not need to be set up when running this. if len(os.Args) > 1 && os.Args[1] == "--version" { // Must work with update.GetVersion @@ -106,6 +111,9 @@ func main() { go setupRunners() var mdmMigrator useraction.MDMMigrator + // swiftDialogCh is a channel shared by the migrator and the offline watcher to + // coordinate the display of the dialog and ensure only one dialog is shown at a time. + var swiftDialogCh chan struct{} // This ticker is used for fetching the desktop summary. It is initialized here because it is // stopped in `OnExit.` @@ -163,6 +171,7 @@ func main() { if err != nil { log.Fatal().Err(err).Msg("unable to initialize request client") } + client.WithInvalidTokenRetry(func() string { log.Debug().Msg("refetching token from disk for API retry") newToken, err := tokenReader.Read() @@ -174,13 +183,6 @@ func main() { return newToken }) - refetchToken := func() { - if _, err := tokenReader.Read(); err != nil { - log.Error().Err(err).Msg("refetch token") - } - log.Debug().Msg("successfully refetched the token from disk") - } - disableTray := func() { log.Debug().Msg("disabling tray items") myDeviceItem.SetTitle("Connecting...") @@ -192,6 +194,46 @@ func main() { migrateMDMItem.Hide() } + // TODO: we can probably extract this into a function that sets up both the migrator and the + // offline watcher + if runtime.GOOS == "darwin" { + dir, err := migration.Dir() + if err != nil { + log.Fatal().Err(err).Msg("getting directory for MDM migration file") + } + + mrw := migration.NewReadWriter(dir, constant.MigrationFileName) + + // we use channel buffer size of 1 to allow one dialog at a time with non-blocking sends. + swiftDialogCh = make(chan struct{}, 1) + + _, swiftDialogPath, _ := update.LocalTargetPaths( + tufUpdateRoot, + "swiftDialog", + update.SwiftDialogMacOSTarget, + ) + mdmMigrator = useraction.NewMDMMigrator( + swiftDialogPath, + 15*time.Minute, + &mdmMigrationHandler{ + client: client, + tokenReader: &tokenReader, + }, + mrw, + fleetURL, + swiftDialogCh, + ) + + useraction.StartMDMMigrationOfflineWatcher(ctx, client, swiftDialogPath, swiftDialogCh, migration.FileWatcher(mrw)) + } + + refetchToken := func() { + if _, err := tokenReader.Read(); err != nil { + log.Error().Err(err).Msg("refetch token") + } + log.Debug().Msg("successfully refetched the token from disk") + } + // checkToken performs API test calls to enable the "My device" item as // soon as the device auth token is registered by Fleet. checkToken := func() <-chan interface{} { @@ -252,30 +294,6 @@ func main() { } }() - if runtime.GOOS == "darwin" { - dir, err := migration.Dir() - if err != nil { - log.Fatal().Err(err).Msg("getting directory for MDM migration file") - } - - mrw := migration.NewReadWriter(dir, constant.MigrationFileName) - _, swiftDialogPath, _ := update.LocalTargetPaths( - tufUpdateRoot, - "swiftDialog", - update.SwiftDialogMacOSTarget, - ) - mdmMigrator = useraction.NewMDMMigrator( - swiftDialogPath, - 15*time.Minute, - &mdmMigrationHandler{ - client: client, - tokenReader: &tokenReader, - }, - mrw, - fleetURL, - ) - } - reportError := func(err error, info map[string]any) { if !client.GetServerCapabilities().Has(fleet.CapabilityErrorReporting) { log.Info().Msg("skipped reporting error to the server as it doesn't have the capability enabled") @@ -425,13 +443,19 @@ func main() { } }() } + + // FIXME: it doesn't look like this is actually triggering, at least when desktop gets + // killed (https://github.com/fleetdm/fleet/issues/21256) onExit := func() { + log.Info().Msg("exit") if mdmMigrator != nil { mdmMigrator.Exit() } + if swiftDialogCh != nil { + close(swiftDialogCh) + } summaryTicker.Stop() - - log.Info().Msg("exit") + cancel() } systray.Run(onReady, onExit) diff --git a/orbit/pkg/constant/constant.go b/orbit/pkg/constant/constant.go index 3b7ab9c60090..36bbba483fab 100644 --- a/orbit/pkg/constant/constant.go +++ b/orbit/pkg/constant/constant.go @@ -62,4 +62,7 @@ const ( MDMMigrationTypeManual = "manual" // MDMMigrationTypeADE indicates that the MDM migration is for an ADE enrolled host. MDMMigrationTypeADE = "ade" + // MDMMigrationOfflineWatcherInterval is the interval at which the offline watcher checks for + // the presence of the migration file. + MDMMigrationOfflineWatcherInterval = 3 * time.Minute ) diff --git a/orbit/pkg/migration/readwriter.go b/orbit/pkg/migration/readwriter.go index 9121e7bc7842..9ca8630a3393 100644 --- a/orbit/pkg/migration/readwriter.go +++ b/orbit/pkg/migration/readwriter.go @@ -57,7 +57,7 @@ func (rw *ReadWriter) RemoveFile() error { } func (rw *ReadWriter) GetMigrationType() (string, error) { - data, err := rw.read() + data, err := rw.read() // TODO: confirm error handling with jahziel, what about other errors? if err != nil { if errors.Is(err, os.ErrNotExist) { return "", nil @@ -108,6 +108,32 @@ func (rw *ReadWriter) setChmod() error { return os.Chmod(rw.FileName, constant.DefaultWorldReadableFileMode) } +func (rw *ReadWriter) NewFileWatcher() FileWatcher { + return &fileWatcher{rw: rw} +} + +type FileWatcher interface { + GetMigrationType() (string, error) + FileExists() (bool, error) + DirExists() (bool, error) +} + +type fileWatcher struct { + rw *ReadWriter +} + +func (r *fileWatcher) GetMigrationType() (string, error) { + return r.rw.GetMigrationType() +} + +func (r *fileWatcher) FileExists() (bool, error) { + return r.rw.FileExists() +} + +func (r *fileWatcher) DirExists() (bool, error) { + return r.rw.DirExists() +} + func Dir() (string, error) { homedir, err := os.UserHomeDir() if err != nil { diff --git a/orbit/pkg/update/swift_dialog.go b/orbit/pkg/update/swift_dialog.go index 3d62c86f1274..bdbfde5e3ee3 100644 --- a/orbit/pkg/update/swift_dialog.go +++ b/orbit/pkg/update/swift_dialog.go @@ -34,6 +34,8 @@ func (s *SwiftDialogDownloader) Run(cfg *fleet.OrbitConfig) error { return nil } + // TODO: we probably want to ensure that swiftDialog is always installed if we're going to be + // using it offline. if !cfg.Notifications.NeedsMDMMigration && !cfg.Notifications.RenewEnrollmentProfile { log.Debug().Msg("got false needs migration and false renew enrollment") return nil diff --git a/orbit/pkg/useraction/mdm_migration.go b/orbit/pkg/useraction/mdm_migration.go index 56e8412fc3de..9602e95f52b8 100644 --- a/orbit/pkg/useraction/mdm_migration.go +++ b/orbit/pkg/useraction/mdm_migration.go @@ -32,6 +32,8 @@ type MDMMigrator interface { type MDMMigratorProps struct { OrgInfo fleet.DesktopOrgInfo IsUnmanaged bool + // DisableTakeover is used to disable the blur and always on top features of the dialog. + DisableTakeover bool } // MDMMigratorHandler handles remote actions/callbacks that the migrator calls. diff --git a/orbit/pkg/useraction/mdm_migration_darwin.go b/orbit/pkg/useraction/mdm_migration_darwin.go index 1d822ffb6bb0..96bed0d9d4c5 100644 --- a/orbit/pkg/useraction/mdm_migration_darwin.go +++ b/orbit/pkg/useraction/mdm_migration_darwin.go @@ -4,6 +4,7 @@ package useraction import ( "bytes" + "context" "errors" "fmt" "os" @@ -16,9 +17,11 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/constant" "github.com/fleetdm/fleet/v4/orbit/pkg/migration" + "github.com/fleetdm/fleet/v4/orbit/pkg/profiles" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/retry" + "github.com/fleetdm/fleet/v4/server/service" "github.com/rs/zerolog/log" ) @@ -91,6 +94,12 @@ var errorTemplate = template.Must(template.New("").Parse(` Please contact your IT admin [here]({{ .ContactURL }}). `)) +var mdmMigrationTemplateOffline = template.Must(template.New("").Parse(` +## Migrate to Fleet + +🛜🚫 No internet connection. Please connect to internet to continue.`, +)) + // baseDialog implements the basic building blocks to render dialogs using // swiftDialog. type baseDialog struct { @@ -128,11 +137,9 @@ func (b *baseDialog) render(flags ...string) (chan swiftDialogExitCode, chan err exitCodeCh := make(chan swiftDialogExitCode, 1) errCh := make(chan error, 1) go func() { - // all dialogs should always be blurred and on top + // all dialogs should always be centered flags = append( flags, - "--blurscreen", - "--ontop", "--messageposition", "center", ) cmd := exec.Command(b.path, flags...) //nolint:gosec @@ -184,16 +191,18 @@ func (b *baseDialog) render(flags ...string) (chan swiftDialogExitCode, chan err } // NewMDMMigrator creates a new swiftDialogMDMMigrator with the right internal state. -func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler, mrw *migration.ReadWriter, fleetURL string) MDMMigrator { +func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler, mrw *migration.ReadWriter, fleetURL string, showCh chan struct{}) MDMMigrator { + if cap(showCh) != 1 { + log.Fatal().Msg("swift dialog channel must have a buffer size of 1") + } return &swiftDialogMDMMigrator{ handler: handler, baseDialog: newBaseDialog(path), frequency: frequency, unenrollmentRetryInterval: defaultUnenrollmentRetryInterval, - // set a buffer size of 1 to allow one Show without blocking - showCh: make(chan struct{}, 1), - mrw: mrw, - fleetURL: fleetURL, + mrw: mrw, + fleetURL: fleetURL, + showCh: showCh, } } @@ -209,7 +218,8 @@ type swiftDialogMDMMigrator struct { // lastShown lastShown time.Time lastShownMu sync.RWMutex - showCh chan struct{} + // showCh is shared with the offline watcher and used to ensure only one dialog is open at a time + showCh chan struct{} // testEnrollmentCheckFileFn is used in tests to mock the call to verify // the enrollment status of the host @@ -536,6 +546,13 @@ func (m *swiftDialogMDMMigrator) getMessageAndFlags(isManualMigration bool) (*by "--height", height, } + if !m.props.DisableTakeover { + flags = append(flags, + "--blurscreen", + "--ontop", + ) + } + if m.props.OrgInfo.ContactURL != "" { flags = append(flags, // info button @@ -581,3 +598,222 @@ func (m *swiftDialogMDMMigrator) MigrationInProgress() (bool, error) { func (m *swiftDialogMDMMigrator) MarkMigrationCompleted() error { return m.mrw.RemoveFile() } + +// StartMDMMigrationOfflineWatcher starts a watcher running on a 3-minute loop that checks if the +// device goes offline in the process of migrating to Fleet's MDM and offline. If so, it shows a +// dialog to prompt the user to connect to the internet. +func StartMDMMigrationOfflineWatcher(ctx context.Context, client *service.DeviceClient, swiftDialogPath string, swiftDialogCh chan struct{}, fileWatcher migration.FileWatcher) { + if cap(swiftDialogCh) != 1 { + log.Fatal().Msg("swift dialog channel must have a buffer size of 1") + } + + watcher := &offlineWatcher{ + client: client, + swiftDialogPath: swiftDialogPath, + swiftDialogCh: swiftDialogCh, + fileWatcher: fileWatcher, + } + + // start loop with 3-minute interval to ping server and show dialog if offline + go func() { + ticker := time.NewTicker(constant.MDMMigrationOfflineWatcherInterval) + defer ticker.Stop() + + log.Info().Msg("starting watcher loop") + for { + select { + case <-ctx.Done(): + log.Debug().Msg("stopping offline dialog loop") + return + case <-ticker.C: + log.Debug().Msg("offline dialog, got tick") + go watcher.processTick(ctx) + } + } + }() +} + +type offlineWatcher struct { + client *service.DeviceClient + swiftDialogPath string + // swiftDialogCh is shared with the migrator and used to ensure only one dialog is open at a time + swiftDialogCh chan struct{} + fileWatcher migration.FileWatcher +} + +func (o *offlineWatcher) processTick(ctx context.Context) { + // try the dialog channel + select { + case o.swiftDialogCh <- struct{}{}: + log.Debug().Msg("occupying dialog channel") + default: + log.Debug().Msg("dialog channel already occupied") + return + } + + defer func() { + // non-blocking release of dialog channel + select { + case <-o.swiftDialogCh: + log.Debug().Msg("releasing dialog channel") + default: + // this shouldn't happen so log for debugging + log.Debug().Msg("dialog channel already released") + } + }() + + if !o.isUnmanaged() || !o.isOffline() { + return + } + + log.Info().Msg("showing offline dialog") + if err := o.showSwiftDialogMDMMigrationOffline(ctx); err != nil { + log.Error().Err(err).Msg("error showing offline dialog") + } else { + log.Info().Msg("done showing offline dialog") + } +} + +func (o *offlineWatcher) isUnmanaged() bool { + mt, err := o.fileWatcher.GetMigrationType() + if err != nil { + log.Error().Err(err).Msg("getting migration type") + } + + if mt == "" { + log.Debug().Msg("offline dialog, no migration type found, do nothing") + return false + } + + log.Debug().Msgf("offline dialog, device is unmanaged, migration type %s", mt) + + // TODO: Maybe check show profiles and skip showing the dialog if the device is managed? + + return true +} + +func (o *offlineWatcher) isOffline() bool { + err := o.client.Ping() + if err == nil { + log.Debug().Msg("offline dialog, ping ok, device is online") + return false + } + if !isOfflineError(err) { + log.Error().Err(err).Msg("offline dialog, error pinging server does not contain dial tcp or no such host, assuming device is online") + return false + } + log.Error().Err(err).Msg("offline dialog, error pinging server, assuming device is offline") + + return true +} + +func isOfflineError(err error) bool { + if err == nil { + return false + } + offlineMsgs := []string{"no such host", "dial tcp", "no route to host"} + for _, msg := range offlineMsgs { + if strings.Contains(err.Error(), msg) { + return true + } + } + + // // TODO: We're starting with basic string matching and planning to improve error matching + // // in future iterations. Here's some ideas for stuff to add in addition to strings.Contains: + // if urlErr, ok := err.(*url.Error); ok { + // log.Info().Msg("is url error") + // if urlErr.Timeout() { + // log.Info().Msg("is timeout") + // return true + // } + // // Check for no such host error + // if opErr, ok := urlErr.Err.(*net.OpError); ok { + // log.Info().Msg("is net op error") + // if dnsErr, ok := opErr.Err.(*net.DNSError); ok { + // log.Info().Msg("is dns error") + // if dnsErr.Err == "no such host" { + // log.Info().Msg("is dns no such host") + // return true + // } + // } + // } + // } + + return false +} + +// ShowDialogMDMMigrationOffline displays the dialog every time is called +func (o *offlineWatcher) showSwiftDialogMDMMigrationOffline(ctx context.Context) error { + props := MDMMigratorProps{ + DisableTakeover: true, + } + m := swiftDialogMDMMigrationOffline{ + baseDialog: newBaseDialog(o.swiftDialogPath), + props: props, + } + + flags, err := m.getFlags() + if err != nil { + return fmt.Errorf("getting flags for offline dialog: %w", err) + } + + exitCodeCh, errCh := m.render(flags...) + + select { + case <-ctx.Done(): + log.Debug().Msg("dialog context canceled") + // TODO: do we care about this? anything we need to clean up? + return nil + case err := <-errCh: + return fmt.Errorf("showing offline dialog: %w", err) + case <-exitCodeCh: + // there's only one button, so we don't need to check the exit code + log.Info().Msg("closing offline dialog") + return nil + } +} + +type swiftDialogMDMMigrationOffline struct { + *baseDialog + props MDMMigratorProps +} + +func (m *swiftDialogMDMMigrationOffline) render(flags ...string) (chan swiftDialogExitCode, chan error) { + return m.baseDialog.render(flags...) +} + +func (m *swiftDialogMDMMigrationOffline) getFlags() ([]string, error) { + tmpl := mdmMigrationTemplateOffline + var message bytes.Buffer + if err := tmpl.Execute( + &message, + nil, + ); err != nil { + return nil, fmt.Errorf("executing migration template: %w", err) + } + + // disable the built-in title and icon so we have full control over content + title := "none" + icon := "none" + + flags := []string{ + "--height", "124", + "--alignment", "center", + "--title", title, + "--icon", icon, + // modal content + "--message", message.String(), + "--messagefont", "size=16", + // main button + "--button1text", "Close", + } + + if !m.props.DisableTakeover { + flags = append(flags, + "--blurscreen", + "--ontop", + ) + } + + return flags, nil +} diff --git a/orbit/pkg/useraction/mdm_migration_notdarwin.go b/orbit/pkg/useraction/mdm_migration_notdarwin.go index 4aa8db76bc77..b7f266c17609 100644 --- a/orbit/pkg/useraction/mdm_migration_notdarwin.go +++ b/orbit/pkg/useraction/mdm_migration_notdarwin.go @@ -3,15 +3,21 @@ package useraction import ( + "context" "time" "github.com/fleetdm/fleet/v4/orbit/pkg/migration" + "github.com/fleetdm/fleet/v4/server/service" ) -func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler, mrw *migration.ReadWriter, fleetURL string) MDMMigrator { +func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler, mrw *migration.ReadWriter, fleetURL string, showCh chan struct{}) MDMMigrator { return &NoopMDMMigrator{} } +func StartMDMMigrationOfflineWatcher(ctx context.Context, client *service.DeviceClient, swiftDialogPath string, swiftDialogCh chan struct{}, fileWatcher migration.FileWatcher) { + return +} + type NoopMDMMigrator struct{} func (m *NoopMDMMigrator) CanRun() bool { return false }