Update MDM migration flow with offline dialog (#21274)

This commit is contained in:
Sarah Gillespie
2024-08-13 12:59:50 -05:00
committed by GitHub
parent 666482245a
commit 73658ddeb8
7 changed files with 343 additions and 44 deletions
+57 -33
View File
@@ -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)
+3
View File
@@ -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
)
+27 -1
View File
@@ -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 {
+2
View File
@@ -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
+2
View File
@@ -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.
+245 -9
View File
@@ -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
}
@@ -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 }