feat: macOS MDM migration updates (#21359)

> Related issue: #19625 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [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/Committing-Changes.md#changes-files)
for more information.
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
- For Orbit and Fleet Desktop changes:
- [x] Orbit runs on macOS, Linux and Windows. Check if the orbit
feature/bugfix should only apply to one platform (`runtime.GOOS`).
- [x] Manual QA must be performed in the three main OSs, macOS, Windows
and Linux.
- [x] Auto-update manual QA, from released version of component to new
version (see [tools/tuf/test](../tools/tuf/test/README.md)).
This commit is contained in:
Jahziel Villasana-Espinoza 2024-08-15 21:21:58 -04:00 committed by GitHub
commit 1fef99af47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1154 additions and 151 deletions

View file

@ -0,0 +1 @@
- update copy on for automica enrollment modal on my device page.

3
changes/20311-migrations Normal file
View file

@ -0,0 +1,3 @@
- Adds ability for MDM migrations if the host is manually enrolled to a 3rd party MDM.
- Adds an offline screen to the macOS MDM migration flow.
- Updates the instructions on "My device" for MDM migrations on pre-Sonoma macOS hosts.

View file

@ -62,7 +62,12 @@ func (svc *Service) TriggerMigrateMDMDevice(ctx context.Context, host *fleet.Hos
return ctxerr.Wrap(ctx, err, "fetching host mdm info")
}
if !fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) {
manualMigrationEligible, err := fleet.IsEligibleForManualMigration(host, mdmInfo, connected)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking manual migration eligibility")
}
if !fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) && !manualMigrationEligible {
bre.InternalErr = ctxerr.New(ctx, "host not eligible for macOS migration")
}
@ -139,9 +144,15 @@ func (svc *Service) GetFleetDesktopSummary(ctx context.Context) (fleet.DesktopSu
sum.Notifications.RenewEnrollmentProfile = true
}
if fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) {
manualMigrationEligible, err := fleet.IsEligibleForManualMigration(host, mdmInfo, connected)
if err != nil {
return sum, ctxerr.Wrap(ctx, err, "checking manual migration eligibility")
}
if fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) || manualMigrationEligible {
sum.Notifications.NeedsMDMMigration = true
}
}
// organization information

View file

@ -23,6 +23,57 @@ const AutoEnrollMdmModal = ({
.map((s) => parseInt(s, 10));
isMacOsSonomaOrLater = major >= 14;
}
const preSonomaBody = (
<>
<p className={`${baseClass}__description`}>
To turn on MDM, Apple Inc. requires you to follow the steps below.
</p>
<ol>
<li>
Open your Mac&apos;s notification center by selecting the date and
time in the top right corner of your screen.
</li>
<li>
Select the <b>Device Enrollment</b> notification. This will open{" "}
<b>System Settings</b>. Select <b>Allow</b>.
</li>
<li>
Enter your password, and select <b>Enroll</b>.
</li>
<li>
Select <b>Done</b> to close this window and select Refetch on your My
device page to tell your organization that MDM is on.
</li>
</ol>
</>
);
const sonomaAndAboveBody = (
<>
<p className={`${baseClass}__description`}>
To turn on MDM, Apple Inc. requires that you install a profile.
</p>
<ol>
<li>
From the Apple menu in the top left corner of your screen, select{" "}
<b>System Settings</b> or <b>System Preferences</b>.
</li>
<li>
In the sidebar menu, select <b>Enroll in Remote Management</b>, and
select <b>Enroll</b>.
</li>
<li>
Enter your password, and select <b>Enroll</b>.
</li>
<li>
Close this window and select <b>Refetch</b> on your My device page to
tell your organization that MDM is on.
</li>
</ol>
</>
);
return (
<Modal
title="Turn on MDM"
@ -31,35 +82,7 @@ const AutoEnrollMdmModal = ({
width="xlarge"
>
<div>
<p className={`${baseClass}__description`}>
To turn on MDM, Apple Inc. requires that you install a profile.
</p>
<ol>
<li>
From the Apple menu in the top left corner of your screen, select{" "}
<b>System Settings</b> or <b>System Preferences</b>.
</li>
<li>
{isMacOsSonomaOrLater ? (
<>
In the sidebar menu, select <b>Enroll in Remote Management</b>,
and select <b>Enroll</b>.
</>
) : (
<>
In the search bar, type Profiles. Select <b>Profiles</b>, find
and select <b>Enrollment Profile</b>, and select <b>Install</b>.
</>
)}
</li>
<li>
Enter your password, and select <b>Enroll</b>.
</li>
<li>
Close this window and select <b>Refetch</b> on your My device page
to tell your organization that MDM is on.
</li>
</ol>
{isMacOsSonomaOrLater ? sonomaAndAboveBody : preSonomaBody}
<div className="modal-cta-wrap">
<Button type="button" onClick={onCancel} variant="brand">
Done

View file

@ -0,0 +1 @@
- Adds ability for MDM migrations if the host is manually enrolled to a 3rd party MDM.

View file

@ -1,6 +1,7 @@
package main
import (
"context"
_ "embed"
"errors"
"fmt"
@ -12,6 +13,7 @@ import (
"fyne.io/systray"
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
"github.com/fleetdm/fleet/v4/orbit/pkg/go-paniclog"
"github.com/fleetdm/fleet/v4/orbit/pkg/migration"
"github.com/fleetdm/fleet/v4/orbit/pkg/profiles"
"github.com/fleetdm/fleet/v4/orbit/pkg/token"
"github.com/fleetdm/fleet/v4/orbit/pkg/update"
@ -59,6 +61,11 @@ 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)
// This context will be used as a general context to handle graceful shutdown in the future.
offlineWatcherCtx, cancelOfflineWatcherCtx := 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
@ -105,6 +112,15 @@ 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{}
var offlineWatcher useraction.MDMOfflineWatcher
// This ticker is used for fetching the desktop summary. It is initialized here because it is
// stopped in `OnExit.`
const checkInterval = 5 * time.Minute
summaryTicker := time.NewTicker(checkInterval)
onReady := func() {
log.Info().Msg("ready")
@ -157,6 +173,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()
@ -168,13 +185,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...")
@ -186,6 +196,44 @@ func main() {
migrateMDMItem.Hide()
}
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")
return
}
fleetdErr := fleet.FleetdError{
ErrorSource: "fleet-desktop",
ErrorSourceVersion: version,
ErrorTimestamp: time.Now(),
ErrorMessage: err.Error(),
ErrorAdditionalInfo: info,
}
if err := client.ReportError(tokenReader.GetCached(), fleetdErr); err != nil {
log.Error().Err(err).EmbedObject(fleetdErr).Msg("reporting error to Fleet server")
}
}
if runtime.GOOS == "darwin" {
m, s, o, err := mdmMigrationSetup(offlineWatcherCtx, tufUpdateRoot, fleetURL, client, &tokenReader)
if err != nil {
go reportError(err, nil)
log.Error().Err(err).Msg("setting up MDM migration resources")
}
mdmMigrator = m
swiftDialogCh = s
offlineWatcher = o
}
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{} {
@ -246,53 +294,15 @@ func main() {
}
}()
if runtime.GOOS == "darwin" {
_, swiftDialogPath, _ := update.LocalTargetPaths(
tufUpdateRoot,
"swiftDialog",
update.SwiftDialogMacOSTarget,
)
mdmMigrator = useraction.NewMDMMigrator(
swiftDialogPath,
15*time.Minute,
&mdmMigrationHandler{
client: client,
tokenReader: &tokenReader,
},
)
}
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")
return
}
fleetdErr := fleet.FleetdError{
ErrorSource: "fleet-desktop",
ErrorSourceVersion: version,
ErrorTimestamp: time.Now(),
ErrorMessage: err.Error(),
ErrorAdditionalInfo: info,
}
if err := client.ReportError(tokenReader.GetCached(), fleetdErr); err != nil {
log.Error().Err(err).EmbedObject(fleetdErr).Msg("reporting error to Fleet server")
}
}
// poll the server to check the policy status of the host and update the
// tray icon accordingly
const checkInterval = 5 * time.Minute
tic := time.NewTicker(checkInterval)
defer tic.Stop()
go func() {
<-deviceEnabledChan
for {
<-tic.C
<-summaryTicker.C
// Reset the ticker to the intended interval, in case we reset it to 1ms
tic.Reset(checkInterval)
summaryTicker.Reset(checkInterval)
sum, err := client.DesktopSummary(tokenReader.GetCached())
switch {
case err == nil:
@ -312,7 +322,19 @@ func main() {
refreshMenuItems(sum.DesktopSummary, selfServiceItem, myDeviceItem)
myDeviceItem.Enable()
shouldRunMigrator := sum.Notifications.NeedsMDMMigration || sum.Notifications.RenewEnrollmentProfile
// Check our file to see if we should migrate
var migrationType string
if runtime.GOOS == "darwin" {
migrationType, err = mdmMigrator.MigrationInProgress()
if err != nil {
go reportError(err, nil)
log.Error().Err(err).Msg("checking if MDM migration is in progress")
}
}
migrationInProgress := migrationType != ""
shouldRunMigrator := sum.Notifications.NeedsMDMMigration || sum.Notifications.RenewEnrollmentProfile || migrationInProgress
if runtime.GOOS == "darwin" && shouldRunMigrator && mdmMigrator.CanRun() {
enrolled, enrollURL, err := profiles.IsEnrolledInMDM()
@ -347,18 +369,28 @@ func main() {
})
// enable tray items
migrateMDMItem.Enable()
migrateMDMItem.Show()
if migrationType != constant.MDMMigrationTypeADE {
migrateMDMItem.Enable()
migrateMDMItem.Show()
}
// if the device is unmanaged or we're in force mode and the device needs
// migration, enable aggressive mode.
if isUnmanaged || forceModeEnabled {
if isUnmanaged || forceModeEnabled || migrationInProgress {
log.Info().Msg("MDM device is unmanaged or force mode enabled, automatically showing dialog")
if err := mdmMigrator.ShowInterval(); err != nil {
go reportError(err, nil)
log.Error().Err(err).Msg("showing MDM migration dialog at interval")
}
}
} else {
// we're done with the migration, so mark it as complete.
if err := mdmMigrator.MarkMigrationCompleted(); err != nil {
go reportError(err, nil)
log.Error().Err(err).Msg("failed to mark MDM migration as completed")
}
migrateMDMItem.Disable()
migrateMDMItem.Hide()
}
} else {
migrateMDMItem.Disable()
@ -376,7 +408,7 @@ func main() {
log.Error().Err(err).Str("url", openURL).Msg("open browser my device")
}
// Also refresh the device status by forcing the polling ticker to fire
tic.Reset(1 * time.Millisecond)
summaryTicker.Reset(1 * time.Millisecond)
case <-transparencyItem.ClickedCh:
openURL := client.BrowserTransparencyURL(tokenReader.GetCached())
if err := open.Browser(openURL); err != nil {
@ -388,8 +420,12 @@ func main() {
log.Error().Err(err).Str("url", openURL).Msg("open browser self-service")
}
// Also refresh the device status by forcing the polling ticker to fire
tic.Reset(1 * time.Millisecond)
summaryTicker.Reset(1 * time.Millisecond)
case <-migrateMDMItem.ClickedCh:
if offline := offlineWatcher.ShowIfOffline(offlineWatcherCtx); offline {
continue
}
if err := mdmMigrator.Show(); err != nil {
go reportError(err, nil)
log.Error().Err(err).Msg("showing MDM migration dialog on user action")
@ -398,11 +434,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()
}
log.Info().Msg("exit")
if swiftDialogCh != nil {
close(swiftDialogCh)
}
summaryTicker.Stop()
cancelOfflineWatcherCtx()
}
systray.Run(onReady, onExit)
@ -574,3 +618,36 @@ func logDir() (string, error) {
return dir, nil
}
func mdmMigrationSetup(ctx context.Context, tufUpdateRoot, fleetURL string, client *service.DeviceClient, tokenReader *token.Reader) (useraction.MDMMigrator, chan struct{}, useraction.MDMOfflineWatcher, error) {
dir, err := migration.Dir()
if err != nil {
return nil, nil, nil, err
}
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,
)
offlineWatcher := useraction.StartMDMMigrationOfflineWatcher(ctx, client, swiftDialogPath, swiftDialogCh, migration.FileWatcher(mrw))
return mdmMigrator, swiftDialogCh, offlineWatcher, nil
}

View file

@ -851,7 +851,7 @@ func main() {
// create the notifications middleware that wraps the orbit client
// (must be shared by all runners that use a ConfigFetcher).
const (
renewEnrollmentProfileCommandFrequency = time.Hour
renewEnrollmentProfileCommandFrequency = 3 * time.Minute
windowsMDMEnrollmentCommandFrequency = time.Hour
windowsMDMBitlockerCommandFrequency = time.Hour
)
@ -864,8 +864,7 @@ func main() {
switch runtime.GOOS {
case "darwin":
orbitClient.RegisterConfigReceiver(update.ApplyRenewEnrollmentProfileConfigFetcherMiddleware(
orbitClient, renewEnrollmentProfileCommandFrequency, fleetURL,
))
orbitClient, renewEnrollmentProfileCommandFrequency, fleetURL))
const nudgeLaunchInterval = 30 * time.Minute
orbitClient.RegisterConfigReceiver(update.ApplyNudgeConfigReceiverMiddleware(update.NudgeConfigFetcherOptions{
UpdateRunner: updateRunner, RootDir: c.String("root-dir"), Interval: nudgeLaunchInterval,

View file

@ -55,4 +55,17 @@ const (
// ServerOverridesFileName is the name of the file in the root directory
// that specifies the override configuration fetched from the server.
ServerOverridesFileName = "server-overrides.json"
// MigrationFileName is the name of the file used by fleetd to determine if the host is
// partially through an MDM migration.
MigrationFileName = "mdm_migration.txt"
// MDMMigrationTypeManual indicates that the MDM migration is for a manually enrolled host.
MDMMigrationTypeManual = "manual"
// MDMMigrationTypeADE indicates that the MDM migration is for an ADE enrolled host.
MDMMigrationTypeADE = "ade"
// MDMMigrationTypePreSonoma indicates that the MDM migration is for a host on a macOS version < 14.
MDMMigrationTypePreSonoma = "pre-sonoma"
// MDMMigrationOfflineWatcherInterval is the interval at which the offline watcher checks for
// the presence of the migration file.
MDMMigrationOfflineWatcherInterval = 3 * time.Minute
SonomaMajorVersion = 14
)

View file

@ -0,0 +1,157 @@
package migration
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
)
type ReadWriter struct {
Path string
FileName string
}
func NewReadWriter(path, filename string) *ReadWriter {
return &ReadWriter{
Path: path,
FileName: filepath.Join(path, filename),
}
}
// SetMigrationFile sets `typ` in the file used to track MDM migration type. This overwrites the
// file if it exists.
func (rw *ReadWriter) SetMigrationFile(typ string) error {
_, err := rw.read()
switch {
case err == nil:
// ensure the file is readable by other processes
if err := rw.setChmod(); err != nil {
return fmt.Errorf("loading migration file, chmod %q: %w", rw.Path, err)
}
case errors.Is(err, os.ErrNotExist):
if err := os.MkdirAll(rw.Path, constant.DefaultDirMode); err != nil {
return fmt.Errorf("creating directory for migration file: %w", err)
}
if err := os.WriteFile(rw.FileName, []byte(typ), constant.DefaultWorldReadableFileMode); err != nil {
return fmt.Errorf("writing migration file: %w", err)
}
default:
return fmt.Errorf("load migration file %q: %w", rw.Path, err)
}
return nil
}
// RemoveFile removes the file used for tracking the MDM migration type.
func (rw *ReadWriter) RemoveFile() error {
if err := os.Remove(rw.FileName); err != nil {
if errors.Is(err, os.ErrNotExist) {
// that's ok, noop
return nil
}
return fmt.Errorf("removing migration file: %w", err)
}
return nil
}
// GetMigrationType returns the contents of the MDM migration file. The contents say what type of
// migration it is.
func (rw *ReadWriter) GetMigrationType() (string, error) {
data, err := rw.read()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
return "", err
}
return data, nil
}
// FileExists returns whether or not the MDM migration file exists on this host.
func (rw *ReadWriter) FileExists() (bool, error) {
_, err := os.Stat(rw.FileName)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
return true, nil
}
// DirExists returns whether or not the directory where the MDM migration file is stored exists.
func (rw *ReadWriter) DirExists() (bool, error) {
_, err := os.Stat(rw.FileName)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
return true, nil
}
func (rw *ReadWriter) read() (string, error) {
data, err := os.ReadFile(rw.FileName)
if err != nil {
return "", err
}
return string(data), nil
}
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
}
// GetMigrationType returns the contents of the MDM migration file which indicate what type of
// migration it is.
func (r *fileWatcher) GetMigrationType() (string, error) {
return r.rw.GetMigrationType()
}
// FileExists returns whether or not the MDM migration file exists on this host.
func (r *fileWatcher) FileExists() (bool, error) {
return r.rw.FileExists()
}
// DirExists returns whether or not the directory where the MDM migration file is stored exists.
func (r *fileWatcher) DirExists() (bool, error) {
return r.rw.DirExists()
}
// Dir returns the path to the directory where the MDM migration file is stored. This path should be
// ~/Library/Caches/com.fleetdm.orbit
func Dir() (string, error) {
homedir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get user's home directory: %w", err)
}
return filepath.Join(homedir, "Library/Caches/com.fleetdm.orbit"), nil
}

View file

@ -127,6 +127,37 @@ func IsEnrolledInMDM() (bool, string, error) {
return true, enrollmentURL, nil
}
func IsManuallyEnrolledInMDM() (bool, error) {
out, err := getMDMInfoFromProfilesCmd()
if err != nil {
return false, fmt.Errorf("calling /usr/bin/profiles: %w", err)
}
// The output of the command is in the form:
//
// ```
// Enrolled via DEP: No
// MDM enrollment: Yes (User Approved)
// MDM server: https://test.example.com/mdm/apple/mdm
// ```
//
// If the host is not enrolled into an MDM, the last line is ommitted,
// so we need to check that:
//
// 1. We've got three rows
// 2. Whether the first line contains "Yes" or "No"
lines := bytes.Split(bytes.TrimSpace(out), []byte("\n"))
if len(lines) < 3 {
return false, nil
}
if strings.Contains(string(lines[0]), "Yes") {
return false, nil
}
return true, nil
}
// getMDMInfoFromProfilesCmd is declared as a variable so it can be overwritten by tests.
var getMDMInfoFromProfilesCmd = func() ([]byte, error) {
cmd := exec.Command("/usr/bin/profiles", "status", "-type", "enrollment")

View file

@ -66,7 +66,7 @@ func (h *renewEnrollmentProfileConfigReceiver) Run(config *fleet.OrbitConfig) er
// enrolled (after the user's manual steps, and osquery reporting the
// updated mdm enrollment).
// See https://github.com/fleetdm/fleet/pull/9409#discussion_r1084382455
if time.Since(h.lastRun) > h.Frequency {
if time.Since(h.lastRun) >= h.Frequency {
// we perform this check locally on the client too to avoid showing the
// dialog if the client is enrolled to an MDM server.
enrollFn := h.checkEnrollmentFn

View file

@ -34,7 +34,10 @@ 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
}

View file

@ -1,6 +1,10 @@
package useraction
import "github.com/fleetdm/fleet/v4/server/fleet"
import (
"context"
"github.com/fleetdm/fleet/v4/server/fleet"
)
// MDMMigrator represents the minimum set of methods a migration must implement
// in order to be used by Fleet Desktop.
@ -19,6 +23,12 @@ type MDMMigrator interface {
ShowInterval() error
// Exit tries to stop any processes started by the migrator.
Exit()
// MigrationInProgress checks if the MDM migration is still in progress (i.e. the host is not
// yet fully enrolled in Fleet MDM). It returns the type of migration that is in progress, if any.
MigrationInProgress() (string, error)
// MarkMigrationCompleted marks the migration as completed. This is currently done by removing
// the migration file.
MarkMigrationCompleted() error
}
// MDMMigratorProps are props required to display the dialog. It's akin to the
@ -26,6 +36,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.
@ -33,3 +45,7 @@ type MDMMigratorHandler interface {
NotifyRemote() error
ShowInstructions() error
}
type MDMOfflineWatcher interface {
ShowIfOffline(ctx context.Context) bool
}

View file

@ -4,6 +4,7 @@ package useraction
import (
"bytes"
"context"
"errors"
"fmt"
"os"
@ -14,9 +15,13 @@ import (
"text/template"
"time"
"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"
)
@ -56,15 +61,23 @@ var mdmMigrationTemplatePreSonoma = template.Must(template.New("mdmMigrationTemp
Select **Start** and look for this notification in your notification center:` +
"\n\n![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-migration-screenshot-notification-2048x480.png)\n\n" +
"After you start, this window will popup every 15-20 minutes until you finish.",
"After you start, this window will popup every 3 minutes until you finish.",
))
var mdmMigrationTemplate = template.Must(template.New("mdmMigrationTemplate").Parse(`
var mdmManualMigrationTemplate = template.Must(template.New("").Parse(`
## Migrate to Fleet
Select **Start** and My device page will appear soon:` +
"\n\n![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-manual-migration-1024x500.png)\n\n" +
"After you start, this dialog will popup every 3 minutes until you finish.",
))
var mdmADEMigrationTemplate = template.Must(template.New("").Parse(`
## Migrate to Fleet
Select **Start** and Remote Management window will appear soon:` +
"\n\n![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-migration-sonoma-1500x938.png)\n\n" +
"After you start, this window will popup every 15-20 minutes until you finish.",
"\n\n![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-ade-migration-1024x500.png)\n\n" +
"After you start, **Remote Management** will popup every minute until you finish.",
))
var errorTemplate = template.Must(template.New("").Parse(`
@ -73,6 +86,14 @@ var errorTemplate = template.Must(template.New("").Parse(`
Please contact your IT admin [here]({{ .ContactURL }}).
`))
var unenrollBody = "## Migrate to Fleet\nUnenrolling you from your old MDM. This could take 90 seconds...\n\n%s"
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 {
@ -110,11 +131,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
@ -166,14 +185,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) 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,
showCh: showCh,
}
}
@ -189,7 +212,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
@ -198,6 +222,8 @@ type swiftDialogMDMMigrator struct {
// the enrollment status of the host
testEnrollmentCheckStatusFn func() (bool, string, error)
unenrollmentRetryInterval time.Duration
mrw *migration.ReadWriter
fleetURL string
}
/**
@ -248,12 +274,23 @@ func (m *swiftDialogMDMMigrator) render(message string, flags ...string) (chan s
return m.baseDialog.render(flags...)
}
func (m *swiftDialogMDMMigrator) renderLoadingSpinner() (chan swiftDialogExitCode, chan error) {
return m.render("## Migrate to Fleet\nUnenrolling you from your old MDM. This could take 90 seconds...",
func (m *swiftDialogMDMMigrator) renderLoadingSpinner(preSonoma, isManual bool) (chan swiftDialogExitCode, chan error) {
var body string
switch true {
case preSonoma:
body = fmt.Sprintf(unenrollBody, "![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-migration-pre-sonoma-unenroll-1024x500.png)")
case isManual:
body = fmt.Sprintf(unenrollBody, "![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-manual-migration-1024x500.png)")
default:
// ADE migration, macOS > 14
body = fmt.Sprintf(unenrollBody, "![Image showing MDM migration notification](https://fleetdm.com/images/permanent/mdm-ade-migration-1024x500.png)")
}
return m.render(body,
"--button1text", "Start",
"--button1disabled",
"--quitkey", "x",
"--height", "220",
"--height", "669",
)
}
@ -275,7 +312,7 @@ func (m *swiftDialogMDMMigrator) renderError() (chan swiftDialogExitCode, chan e
// waitForUnenrollment waits 90 seconds (value determined by product) for the
// device to unenroll from the current MDM solution. If the device doesn't
// unenroll, an error is returned.
func (m *swiftDialogMDMMigrator) waitForUnenrollment() error {
func (m *swiftDialogMDMMigrator) waitForUnenrollment(isADEMigration bool) error {
maxRetries := int(mdmUnenrollmentTotalWaitTime.Seconds() / m.unenrollmentRetryInterval.Seconds())
checkFileFn := m.testEnrollmentCheckFileFn
if checkFileFn == nil {
@ -292,14 +329,16 @@ func (m *swiftDialogMDMMigrator) waitForUnenrollment() error {
return retry.Do(func() error {
var unenrolled bool
fileExists, fileErr := checkFileFn()
if fileErr != nil {
log.Error().Err(fileErr).Msg("checking for existence of cloudConfigProfileInstalled in migration modal")
} else if fileExists {
log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: found")
} else {
log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: not found")
unenrolled = true
if isADEMigration {
fileExists, fileErr := checkFileFn()
if fileErr != nil {
log.Error().Err(fileErr).Msg("checking for existence of cloudConfigProfileInstalled in migration modal")
} else if fileExists {
log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: found")
} else {
log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: not found")
unenrolled = true
}
}
statusEnrolled, serverURL, statusErr := checkStatusFn()
@ -326,7 +365,33 @@ func (m *swiftDialogMDMMigrator) waitForUnenrollment() error {
}
func (m *swiftDialogMDMMigrator) renderMigration() error {
message, flags, err := m.getMessageAndFlags()
log.Debug().Msg("checking current enrollment status")
isCurrentlyManuallyEnrolled, err := profiles.IsManuallyEnrolledInMDM()
if err != nil {
return err
}
// Check what kind of migration was in progress, if any.
previousMigrationType, err := m.mrw.GetMigrationType()
if err != nil {
log.Error().Err(err).Msg("getting migration type")
return fmt.Errorf("getting migration type: %w", err)
}
isManualMigration := isCurrentlyManuallyEnrolled || previousMigrationType == constant.MDMMigrationTypeManual
isADEMigration := previousMigrationType == constant.MDMMigrationTypeADE
log.Debug().Bool("isManualMigration", isManualMigration).Bool("isADEMigration", isADEMigration).Bool("isCurrentlyManuallyEnrolled", isCurrentlyManuallyEnrolled).Str("previousMigrationType", previousMigrationType).Msg("props after assigning")
vers, err := m.getMacOSMajorVersion()
if err != nil {
// log error for debugging and continue with default template
log.Error().Err(err).Msg("getting macOS major version failed: using default migration template")
}
isPreSonoma := vers < constant.SonomaMajorVersion
message, flags, err := m.getMessageAndFlags(vers, isManualMigration)
if err != nil {
return fmt.Errorf("getting mdm migrator message: %w", err)
}
@ -342,9 +407,24 @@ func (m *swiftDialogMDMMigrator) renderMigration() error {
return nil
}
if previousMigrationType == constant.MDMMigrationTypeADE {
// Do nothing; the Remote Management modal will be launched by Orbit every minute.
return nil
}
if previousMigrationType == constant.MDMMigrationTypeManual || previousMigrationType == constant.MDMMigrationTypePreSonoma {
// Launch the "My device" page.
log.Info().Msg("showing instructions")
if err := m.handler.ShowInstructions(); err != nil {
return err
}
return nil
}
if !m.props.IsUnmanaged {
// show the loading spinner
m.renderLoadingSpinner()
m.renderLoadingSpinner(isPreSonoma, isCurrentlyManuallyEnrolled)
// send the API call
if notifyErr := m.handler.NotifyRemote(); notifyErr != nil {
@ -361,7 +441,7 @@ func (m *swiftDialogMDMMigrator) renderMigration() error {
}
log.Info().Msg("webhook sent, checking for unenrollment")
if err := m.waitForUnenrollment(); err != nil {
if err := m.waitForUnenrollment(isADEMigration); err != nil {
m.baseDialog.Exit()
errDialogExitChan, errDialogErrChan := m.renderError()
select {
@ -374,6 +454,35 @@ func (m *swiftDialogMDMMigrator) renderMigration() error {
}
}
switch {
case isPreSonoma:
if err := m.mrw.SetMigrationFile(constant.MDMMigrationTypePreSonoma); err != nil {
log.Error().Str("migration_type", constant.MDMMigrationTypeADE).Err(err).Msg("set migration file")
}
log.Info().Msg("showing instructions after pre-sonoma unenrollment")
if err := m.handler.ShowInstructions(); err != nil {
return err
}
case isManualMigration:
if err := m.mrw.SetMigrationFile(constant.MDMMigrationTypeManual); err != nil {
log.Error().Str("migration_type", constant.MDMMigrationTypeManual).Err(err).Msg("set migration file")
}
log.Info().Msg("showing instructions after manual unenrollment")
if err := m.handler.ShowInstructions(); err != nil {
return err
}
m.frequency = 3 * time.Minute
default:
if err := m.mrw.SetMigrationFile(constant.MDMMigrationTypeADE); err != nil {
log.Error().Str("migration_type", constant.MDMMigrationTypeADE).Err(err).Msg("set migration file")
}
}
// close the spinner
// TODO: maybe it's better to use
// https://github.com/bartreardon/swiftDialog/wiki/Updating-Dialog-with-new-content
@ -381,10 +490,6 @@ func (m *swiftDialogMDMMigrator) renderMigration() error {
m.baseDialog.Exit()
}
log.Info().Msg("showing instructions")
if err := m.handler.ShowInstructions(); err != nil {
return err
}
}
return nil
@ -435,16 +540,14 @@ func (m *swiftDialogMDMMigrator) SetProps(props MDMMigratorProps) {
m.props = props
}
func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string, error) {
vers, err := m.getMacOSMajorVersion()
if err != nil {
// log error for debugging and continue with default template
log.Error().Err(err).Msg("getting macOS major version failed: using default migration template")
func (m *swiftDialogMDMMigrator) getMessageAndFlags(version int, isManualMigration bool) (*bytes.Buffer, []string, error) {
tmpl := mdmADEMigrationTemplate
if isManualMigration {
tmpl = mdmManualMigrationTemplate
}
tmpl := mdmMigrationTemplate
height := "669"
if vers != 0 && vers < 14 {
if version != 0 && version < constant.SonomaMajorVersion {
height = "440"
tmpl = mdmMigrationTemplatePreSonoma
}
@ -454,7 +557,7 @@ func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string,
&message,
m.props,
); err != nil {
return nil, nil, fmt.Errorf("executing migrqation template: %w", err)
return nil, nil, fmt.Errorf("executing migration template: %w", err)
}
flags := []string{
@ -465,6 +568,13 @@ func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string,
"--height", height,
}
if !m.props.DisableTakeover {
flags = append(flags,
"--blurscreen",
"--ontop",
)
}
if m.props.OrgInfo.ContactURL != "" {
flags = append(flags,
// info button
@ -502,3 +612,236 @@ func (m *swiftDialogMDMMigrator) getMacOSMajorVersion() (int, error) {
}
return major, nil
}
func (m *swiftDialogMDMMigrator) MigrationInProgress() (string, error) {
return m.mrw.GetMigrationType()
}
func (m *swiftDialogMDMMigrator) MarkMigrationCompleted() error {
// Reset this to the original frequency.
m.frequency = 15 * time.Minute
return m.mrw.RemoveFile()
}
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
}
// 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) MDMOfflineWatcher {
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.ShowIfOffline(ctx)
}
}
}()
return watcher
}
// ShowIfOffline shows the offline dialog if the host is offline.
// It returns true if the host is offline, and false otherwise.
func (o *offlineWatcher) ShowIfOffline(ctx context.Context) bool {
// try the dialog channel
select {
case o.swiftDialogCh <- struct{}{}:
log.Debug().Msg("occupying dialog channel")
default:
log.Debug().Msg("dialog channel already occupied")
return false
}
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 false
}
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")
}
return true
}
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)
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.Debug().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
}
}
// // NOTE: 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")
m.baseDialog.Exit()
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
}

View file

@ -52,7 +52,7 @@ func TestWaitForUnenrollment(t *testing.T) {
return true, "example.com", nil
}
outErr := m.waitForUnenrollment()
outErr := m.waitForUnenrollment(true)
if c.wantErr {
require.Error(t, outErr)
} else {
@ -71,7 +71,27 @@ func TestWaitForUnenrollment(t *testing.T) {
return false, "", nil
}
outErr := m.waitForUnenrollment()
outErr := m.waitForUnenrollment(true)
require.NoError(t, outErr)
})
t.Run("only check file during ADE enrollment", func(t *testing.T) {
var fileWasChecked bool
m.testEnrollmentCheckFileFn = func() (bool, error) {
fileWasChecked = true
return true, nil
}
m.testEnrollmentCheckStatusFn = func() (bool, string, error) {
return false, "", nil
}
err := m.waitForUnenrollment(false)
require.NoError(t, err)
require.False(t, fileWasChecked)
err = m.waitForUnenrollment(true)
require.NoError(t, err)
require.True(t, fileWasChecked)
})
}

View file

@ -2,16 +2,32 @@
package useraction
import "time"
import (
"context"
"time"
func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler) MDMMigrator {
"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, showCh chan struct{}) MDMMigrator {
return &NoopMDMMigrator{}
}
func StartMDMMigrationOfflineWatcher(ctx context.Context, client *service.DeviceClient, swiftDialogPath string, swiftDialogCh chan struct{}, fileWatcher migration.FileWatcher) MDMOfflineWatcher {
return &NoopOfflineWatcher{}
}
type NoopOfflineWatcher struct{}
func (o *NoopOfflineWatcher) ShowIfOffline(ctx context.Context) bool { return false }
type NoopMDMMigrator struct{}
func (m *NoopMDMMigrator) CanRun() bool { return false }
func (m *NoopMDMMigrator) SetProps(MDMMigratorProps) {}
func (m *NoopMDMMigrator) Show() error { return nil }
func (m *NoopMDMMigrator) ShowInterval() error { return nil }
func (m *NoopMDMMigrator) Exit() {}
func (m *NoopMDMMigrator) CanRun() bool { return false }
func (m *NoopMDMMigrator) SetProps(MDMMigratorProps) {}
func (m *NoopMDMMigrator) Show() error { return nil }
func (m *NoopMDMMigrator) ShowInterval() error { return nil }
func (m *NoopMDMMigrator) Exit() {}
func (m *NoopMDMMigrator) MigrationInProgress() (string, error) { return "", nil }
func (m *NoopMDMMigrator) MarkMigrationCompleted() error { return nil }

View file

@ -7,6 +7,8 @@ import (
"fmt"
"strings"
"time"
"github.com/Masterminds/semver"
)
type HostStatus string
@ -1222,3 +1224,46 @@ func IsEligibleForDEPMigration(host *Host, mdmInfo *HostMDM, isConnectedToFleetM
// the checkout message from the host.
(!isConnectedToFleetMDM || mdmInfo.Name != WellKnownMDMFleet)
}
var macOSADEMigrationOnlyLastVersion = semver.MustParse("14")
// IsEligibleForManualMigration returns true if the host is manually enrolled into a 3rd party MDM
// and is able to migrate to Fleet.
func IsEligibleForManualMigration(host *Host, mdmInfo *HostMDM, isConnectedToFleetMDM bool) (bool, error) {
goodVersion, err := IsMacOSMajorVersionOK(host)
if err != nil {
return false, fmt.Errorf("checking macOS version for manual migration eligibility: %w", err)
}
return goodVersion &&
host.IsOsqueryEnrolled() &&
!host.IsDEPAssignedToFleet() &&
mdmInfo != nil &&
!mdmInfo.InstalledFromDep &&
!mdmInfo.HasJSONProfileAssigned() &&
mdmInfo.Enrolled &&
(!isConnectedToFleetMDM || mdmInfo.Name != WellKnownMDMFleet), nil
}
func IsMacOSMajorVersionOK(host *Host) (bool, error) {
if host == nil {
return false, nil
}
parts := strings.Split(host.OSVersion, " ")
if len(parts) < 2 || parts[0] != "macOS" {
return false, nil
}
version, err := semver.NewVersion(parts[1])
if err != nil {
return false, fmt.Errorf("parsing macOS version \"%s\": %w", parts[1], err)
}
if version.GreaterThan(macOSADEMigrationOnlyLastVersion) {
return true, nil
}
return false, nil
}

View file

@ -222,6 +222,8 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
depProfileResponse DEPAssignProfileResponseStatus
enrolledInThirdPartyMDM bool
expected bool
expectedManual bool
hostOS string
}{
{
name: "Eligible for DEP migration",
@ -230,6 +232,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
depProfileResponse: DEPAssignProfileResponseSuccess,
enrolledInThirdPartyMDM: true,
expected: true,
expectedManual: false,
},
{
name: "Not eligible - osqueryHostID nil",
@ -238,6 +241,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
depProfileResponse: DEPAssignProfileResponseSuccess,
enrolledInThirdPartyMDM: true,
expected: false,
expectedManual: false,
},
{
name: "Not eligible - not DEP assigned to Fleet",
@ -246,6 +250,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
depProfileResponse: DEPAssignProfileResponseSuccess,
enrolledInThirdPartyMDM: true,
expected: false,
expectedManual: false,
},
{
name: "Not eligible - not enrolled in third-party MDM",
@ -254,6 +259,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
depProfileResponse: DEPAssignProfileResponseSuccess,
enrolledInThirdPartyMDM: false,
expected: false,
expectedManual: false,
},
{
name: "Not eligible - not DEP assigned and DEP profile failed",
@ -262,6 +268,8 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
depProfileResponse: DEPAssignProfileResponseNotAccessible,
enrolledInThirdPartyMDM: true,
expected: false,
expectedManual: true,
hostOS: "macOS 14.5",
},
{
name: "Not eligible - DEP assigned and DEP profile failed",
@ -270,6 +278,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
depProfileResponse: DEPAssignProfileResponseFailed,
enrolledInThirdPartyMDM: true,
expected: false,
expectedManual: false,
},
{
name: "Not eligible - DEP assigned but not response yet",
@ -278,6 +287,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
depProfileResponse: "",
enrolledInThirdPartyMDM: true,
expected: false,
expectedManual: false,
},
{
name: "Not eligible - DEP assigned but not accessible",
@ -286,6 +296,27 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
depProfileResponse: DEPAssignProfileResponseNotAccessible,
enrolledInThirdPartyMDM: true,
expected: false,
expectedManual: false,
},
{
name: "Manual migration eligible - enrolled in 3rd party, but not DEP",
osqueryHostID: ptr.String("some-id"),
depAssignedToFleet: ptr.Bool(false),
depProfileResponse: "",
enrolledInThirdPartyMDM: true,
expected: false,
expectedManual: true,
hostOS: "macOS 14.5",
},
{
name: "Manual migration ineligible - enrolled in 3rd party, not DEP, but OS version too low",
osqueryHostID: ptr.String("some-id"),
depAssignedToFleet: ptr.Bool(false),
depProfileResponse: "",
enrolledInThirdPartyMDM: true,
expected: false,
expectedManual: false,
hostOS: "macOS 13.9",
},
}
@ -294,6 +325,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
host := &Host{
OsqueryHostID: tc.osqueryHostID,
DEPAssignedToFleet: tc.depAssignedToFleet,
OSVersion: tc.hostOS,
}
mdmInfo := &HostMDM{
@ -303,6 +335,9 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
}
require.Equal(t, tc.expected, IsEligibleForDEPMigration(host, mdmInfo, false))
manual, err := IsEligibleForManualMigration(host, mdmInfo, false)
require.NoError(t, err)
require.Equal(t, tc.expectedManual, manual)
})
}
}

View file

@ -1019,7 +1019,6 @@ func (s *integrationMDMTestSuite) createAppleMobileHostThenEnrollMDM(platform st
require.NoError(t, err)
return fleetHost, mdmDevice
}
func createWindowsHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testing.T) (*fleet.Host, *mdmtest.TestWindowsMDMClient) {
@ -3432,11 +3431,6 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhook() {
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusBadRequest)
require.False(t, webhookCalled)
// host is not DEP so migration is not allowed
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), h.ID, !isServer, enrolled, mdmURL, !installedFromDEP, mdmName, ""))
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusBadRequest)
require.False(t, webhookCalled)
// host is not enrolled to MDM so migration is not allowed
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), h.ID, !isServer, !enrolled, mdmURL, installedFromDEP, mdmName, ""))
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusBadRequest)
@ -3524,6 +3518,16 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhook() {
require.True(t, webhookCalled)
webhookCalled = false
// host is manually enrolled, which is allowed
h.RefetchCriticalQueriesUntil = ptr.Time(time.Now().Add(-1 * time.Minute))
err = s.ds.UpdateHost(context.Background(), h)
require.NoError(t, err)
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), h.ID, !isServer, enrolled, mdmURL, !installedFromDEP, mdmName, ""))
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusNoContent)
require.True(t, webhookCalled)
webhookCalled = false
// the refetch critical queries timestamp has been updated to the future
h, err = s.ds.Host(context.Background(), h.ID)
require.NoError(t, err)
@ -5500,6 +5504,37 @@ func (s *integrationMDMTestSuite) TestMDMMigration() {
require.True(t, orbitConfigResp.Notifications.NeedsMDMMigration)
require.False(t, orbitConfigResp.Notifications.RenewEnrollmentProfile)
// simulate a device that is manually enrolled to 3rd party
err = s.ds.SetOrUpdateMDMData(
ctx,
host.ID,
false,
true,
"https://simplemdm.com",
false,
fleet.WellKnownMDMSimpleMDM,
"",
)
require.NoError(t, err)
getDesktopResp = fleetDesktopResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/desktop", nil, http.StatusOK)
require.NoError(t, json.NewDecoder(res.Body).Decode(&getDesktopResp))
require.NoError(t, res.Body.Close())
require.NoError(t, getDesktopResp.Err)
require.Zero(t, *getDesktopResp.FailingPolicies)
require.True(t, getDesktopResp.Notifications.NeedsMDMMigration)
require.False(t, getDesktopResp.Notifications.RenewEnrollmentProfile)
require.Equal(t, acResp.OrgInfo.OrgLogoURL, getDesktopResp.Config.OrgInfo.OrgLogoURL)
require.Equal(t, acResp.OrgInfo.OrgLogoURLLightBackground, getDesktopResp.Config.OrgInfo.OrgLogoURLLightBackground)
require.Equal(t, acResp.OrgInfo.ContactURL, getDesktopResp.Config.OrgInfo.ContactURL)
require.Equal(t, acResp.OrgInfo.OrgName, getDesktopResp.Config.OrgInfo.OrgName)
require.Equal(t, acResp.MDM.MacOSMigration.Mode, getDesktopResp.Config.MDM.MacOSMigration.Mode)
orbitConfigResp = orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &orbitConfigResp)
require.True(t, orbitConfigResp.Notifications.NeedsMDMMigration)
require.False(t, orbitConfigResp.Notifications.RenewEnrollmentProfile)
// clean up nano tables
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(), `
@ -9739,7 +9774,6 @@ func (s *integrationMDMTestSuite) TestEnrollAfterDEPSyncIOSIPadOS() {
var listCmdResp listMDMAppleCommandsResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)
require.Empty(t, listCmdResp.Results)
}
func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
@ -9933,7 +9967,6 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
var listCmdResp listMDMAppleCommandsResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)
require.Len(t, listCmdResp.Results, commandsSent)
}
func (s *integrationMDMTestSuite) TestVPPApps() {
@ -10396,15 +10429,18 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
deviceToken string
}{
"iOS app install": {installHost: iOSHost, titleID: iOSTitleID, mdmClient: iOSMdmClient, app: iOSApp, hostCount: 1},
"iPadOS app install": {installHost: iPadOSHost, titleID: iPadOSTitleID, mdmClient: iPadOSMdmClient, app: iPadOSApp,
extraAvailable: 1, hostCount: 1},
"macOS app install": {installHost: selfServiceHost, titleID: macOSTitleID, mdmClient: selfServiceDevice, app: macOSApp,
hostCount: 2, deviceToken: selfServiceToken},
"iPadOS app install": {
installHost: iPadOSHost, titleID: iPadOSTitleID, mdmClient: iPadOSMdmClient, app: iPadOSApp,
extraAvailable: 1, hostCount: 1,
},
"macOS app install": {
installHost: selfServiceHost, titleID: macOSTitleID, mdmClient: selfServiceDevice, app: macOSApp,
hostCount: 2, deviceToken: selfServiceToken,
},
}
for name, install := range installs {
t.Run(name, func(t *testing.T) {
installHost := install.installHost
titleID := install.titleID
mdmClient := install.mdmClient

View file

@ -203,8 +203,13 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro
notifs.RenewEnrollmentProfile = true
}
manualMigrationEligible, err := fleet.IsEligibleForManualMigration(host, mdmInfo, isConnectedToFleetMDM)
if err != nil {
return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "checking manual migration eligibility")
}
if appConfig.MDM.MacOSMigration.Enable &&
fleet.IsEligibleForDEPMigration(host, mdmInfo, isConnectedToFleetMDM) {
(fleet.IsEligibleForDEPMigration(host, mdmInfo, isConnectedToFleetMDM) || manualMigrationEligible) {
notifs.NeedsMDMMigration = true
}

View file

@ -0,0 +1,19 @@
# MicroMDM webhook
A tiny server you can use as a webhook callback for the MDM migration [end user workflow](https://fleetdm.com/docs/using-fleet/mdm-migration-guide#end-user-workflow).
It will try to unenroll the device based on the device UUID/UDID by sending a `RemoveProfile`
command.
## Usage
1. Find the MicroMDM API token. For the Fly.io hosted MicroMDM server it should be in
1Password. If you're having trouble finding it, drop a message in `#g-mdm` on Slack!
2. Get the MicroMDM server URL.
3. Start the server with:
```
go run tools/mdm/migration/micromdm/main.go --api-token=$MICRO_MDM_TOKEN --url=https://micromdm.example.com
```
4. Configure Fleet to send a webhook to this server.

View file

@ -0,0 +1,149 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
)
var (
apiToken = flag.String("api-token", "", "API token for the MicroMDM instance")
url = flag.String("url", "", "URL of the MicroMDM instance")
port = flag.String("port", "4648", "Port used by the webserver")
)
func main() {
flag.Parse()
if *apiToken == "" || *url == "" {
log.Fatal("--api-token and --url are required.")
}
client := newMicroMDMClient(*apiToken, *url)
http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
slog.With("error", err).Error("reading request body")
writer.WriteHeader(http.StatusInternalServerError)
return
}
if len(body) == 0 {
slog.Error("empty request body")
writer.WriteHeader(http.StatusBadRequest)
return
}
slog.With("raw_body", string(body)).Debug("got request")
var deviceInfo struct {
Host struct {
UUID string `json:"uuid"`
} `json:"host"`
}
if err := json.Unmarshal(body, &deviceInfo); err != nil {
slog.With("device_uuid", deviceInfo.Host.UUID, "error", err).Error("failed to unmarshal request body")
writer.WriteHeader(http.StatusBadRequest)
return
}
slog.With("device_uuid", deviceInfo.Host.UUID).Info("attempting to unenroll from MicroMDM")
if err := client.unmanageDevice(deviceInfo.Host.UUID); err != nil {
slog.With("device_uuid", deviceInfo.Host.UUID, "error", err).Error("failed to unenroll device")
writer.WriteHeader(http.StatusBadRequest)
return
}
slog.With("device_uuid", deviceInfo.Host.UUID).Info("device unenrolled")
})
slog.With("address", fmt.Sprintf("http://localhost:%s", *port)).Info("server running")
server := &http.Server{
Addr: fmt.Sprintf(":%s", *port),
ReadHeaderTimeout: 3 * time.Second,
}
if err := server.ListenAndServe(); err != nil {
log.Fatalf(err.Error())
}
}
type microMDMClient struct {
url string
token string
}
func newMicroMDMClient(apiToken, url string) *microMDMClient {
client := &microMDMClient{url: url, token: apiToken}
return client
}
func (m *microMDMClient) doWithRequest(req *http.Request) ([]byte, error) {
client := fleethttp.NewClient()
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode > 299 {
return body, fmt.Errorf("unexpected status code %d", resp.StatusCode)
}
return body, nil
}
func (m *microMDMClient) do(method, path string, data any) ([]byte, error) {
var body []byte
if data != nil {
b, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("marshaling request body: %w", err)
}
body = b
}
makeReq := func() (*http.Request, error) {
if len(body) > 0 {
return http.NewRequest(method, path, bytes.NewBuffer(body))
}
return http.NewRequest(method, path, nil)
}
req, err := makeReq()
if err != nil {
return nil, err
}
req.Header.Add("accept", "application/json")
req.SetBasicAuth("micromdm", m.token)
return m.doWithRequest(req)
}
func (m *microMDMClient) unmanageDevice(UUID string) error {
req := struct {
RequestType string `json:"request_type"`
UDID string `json:"udid"`
Identifier string `json:"identifier"`
}{
RequestType: "RemoveProfile",
UDID: UUID,
Identifier: "com.github.micromdm.micromdm.enroll",
}
_, err := m.do("POST", fmt.Sprintf("%s/v1/commands", m.url), &req)
return err
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB