diff --git a/changes/20311-migrations b/changes/20311-migrations new file mode 100644 index 0000000000..9df1836851 --- /dev/null +++ b/changes/20311-migrations @@ -0,0 +1 @@ +- Adds ability for MDM migrations if the host is manually enrolled to a 3rd party MDM. \ No newline at end of file diff --git a/ee/server/service/devices.go b/ee/server/service/devices.go index 590067e916..2721676e1d 100644 --- a/ee/server/service/devices.go +++ b/ee/server/service/devices.go @@ -62,7 +62,7 @@ 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) { + if !fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) && !fleet.IsEligibleForManualMigration(host, mdmInfo, connected) { bre.InternalErr = ctxerr.New(ctx, "host not eligible for macOS migration") } @@ -139,9 +139,10 @@ func (svc *Service) GetFleetDesktopSummary(ctx context.Context) (fleet.DesktopSu sum.Notifications.RenewEnrollmentProfile = true } - if fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) { + if fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) || fleet.IsEligibleForManualMigration(host, mdmInfo, connected) { sum.Notifications.NeedsMDMMigration = true } + } // organization information diff --git a/orbit/changes/20311-migrations b/orbit/changes/20311-migrations new file mode 100644 index 0000000000..aae7c75c62 --- /dev/null +++ b/orbit/changes/20311-migrations @@ -0,0 +1 @@ +- Adds ability for MDM migrations if the host is manually enrolled to a 3rd party MDM. diff --git a/orbit/cmd/desktop/desktop.go b/orbit/cmd/desktop/desktop.go index b423936061..f63568ca08 100644 --- a/orbit/cmd/desktop/desktop.go +++ b/orbit/cmd/desktop/desktop.go @@ -12,6 +12,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" @@ -247,6 +248,12 @@ func main() { }() if runtime.GOOS == "darwin" { + dir, err := migrationFileDir() + 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", @@ -259,6 +266,7 @@ func main() { client: client, tokenReader: &tokenReader, }, + mrw, ) } @@ -341,7 +349,15 @@ func main() { } myDeviceItem.Enable() - shouldRunMigrator := sum.Notifications.NeedsMDMMigration || sum.Notifications.RenewEnrollmentProfile + // Check our file to see if we should migrate + migrationInProgress, err := mdmMigrator.MigrationInProgress() + if err != nil { + go reportError(err, nil) + log.Error().Err(err).Msg("checking if MDM migration is in progress") + } + // if we have the file, but we're enrolled to Fleet, then we need to remove the file + // and not run the migrator as we're already in Fleet + shouldRunMigrator := sum.Notifications.NeedsMDMMigration || sum.Notifications.RenewEnrollmentProfile || migrationInProgress if runtime.GOOS == "darwin" && shouldRunMigrator && mdmMigrator.CanRun() { enrolled, enrollURL, err := profiles.IsEnrolledInMDM() @@ -381,13 +397,19 @@ func main() { // 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") + } } } else { migrateMDMItem.Disable() @@ -563,3 +585,12 @@ func logDir() (string, error) { return dir, nil } + +func migrationFileDir() (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 +} diff --git a/orbit/pkg/constant/constant.go b/orbit/pkg/constant/constant.go index 8a11160e5b..3b7ab9c600 100644 --- a/orbit/pkg/constant/constant.go +++ b/orbit/pkg/constant/constant.go @@ -55,4 +55,11 @@ 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" ) diff --git a/orbit/pkg/migration/readwriter.go b/orbit/pkg/migration/readwriter.go new file mode 100644 index 0000000000..1caf94da46 --- /dev/null +++ b/orbit/pkg/migration/readwriter.go @@ -0,0 +1,107 @@ +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), + } +} + +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 +} + +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 +} + +func (rw *ReadWriter) GetMigrationType() (string, error) { + data, err := rw.read() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + } + + return data, nil +} + +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 +} + +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) +} diff --git a/orbit/pkg/profiles/profiles_darwin.go b/orbit/pkg/profiles/profiles_darwin.go index 93d197a587..8f4dd296f7 100644 --- a/orbit/pkg/profiles/profiles_darwin.go +++ b/orbit/pkg/profiles/profiles_darwin.go @@ -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") diff --git a/orbit/pkg/update/swift_dialog.go b/orbit/pkg/update/swift_dialog.go index eebd68477b..3d62c86f12 100644 --- a/orbit/pkg/update/swift_dialog.go +++ b/orbit/pkg/update/swift_dialog.go @@ -35,6 +35,7 @@ func (s *SwiftDialogDownloader) Run(cfg *fleet.OrbitConfig) error { } 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 27480b6ab6..56e8412fc3 100644 --- a/orbit/pkg/useraction/mdm_migration.go +++ b/orbit/pkg/useraction/mdm_migration.go @@ -19,6 +19,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). + MigrationInProgress() (bool, 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 diff --git a/orbit/pkg/useraction/mdm_migration_darwin.go b/orbit/pkg/useraction/mdm_migration_darwin.go index ba4c574d6e..0b0956f53a 100644 --- a/orbit/pkg/useraction/mdm_migration_darwin.go +++ b/orbit/pkg/useraction/mdm_migration_darwin.go @@ -14,6 +14,8 @@ 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" @@ -67,6 +69,14 @@ Select **Start** and Remote Management window will appear soon:` + "After you start, this window will popup every 15-20 minutes until you finish.", )) +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 window will popup every 15 minutes until you finish.", +)) + var errorTemplate = template.Must(template.New("").Parse(` ### Something's gone wrong. @@ -166,7 +176,7 @@ 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) MDMMigrator { return &swiftDialogMDMMigrator{ handler: handler, baseDialog: newBaseDialog(path), @@ -174,6 +184,7 @@ func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHan unenrollmentRetryInterval: defaultUnenrollmentRetryInterval, // set a buffer size of 1 to allow one Show without blocking showCh: make(chan struct{}, 1), + mrw: mrw, } } @@ -198,6 +209,7 @@ type swiftDialogMDMMigrator struct { // the enrollment status of the host testEnrollmentCheckStatusFn func() (bool, string, error) unenrollmentRetryInterval time.Duration + mrw *migration.ReadWriter } /** @@ -326,7 +338,21 @@ func (m *swiftDialogMDMMigrator) waitForUnenrollment() error { } func (m *swiftDialogMDMMigrator) renderMigration() error { - message, flags, err := m.getMessageAndFlags() + log.Debug().Msg("checking manual enrollment status") + manualProfileCheck, err := profiles.IsManuallyEnrolledInMDM() + if err != nil { + return err + } + + // Check if we're in a manual migration. + migrationType, err := m.mrw.GetMigrationType() + if err != nil { + log.Error().Err(err).Msg("getting migration type") + } + + isManual := manualProfileCheck || migrationType == constant.MDMMigrationTypeManual + + message, flags, err := m.getMessageAndFlags(isManual) if err != nil { return fmt.Errorf("getting mdm migrator message: %w", err) } @@ -342,6 +368,22 @@ func (m *swiftDialogMDMMigrator) renderMigration() error { return nil } + // If we have the migration file and this is a manual migration, we should just send the + // user straight to the My device page + + switch migrationType { + case constant.MDMMigrationTypeManual: + // The migration file only exists if we successfully hit the webhook + log.Info().Msg("showing instructions") + + if err := m.handler.ShowInstructions(); err != nil { + return err + } + return nil + case constant.MDMMigrationTypeADE: + default: + } + if !m.props.IsUnmanaged { // show the loading spinner m.renderLoadingSpinner() @@ -374,6 +416,17 @@ func (m *swiftDialogMDMMigrator) renderMigration() error { } } + if err := m.mrw.SetMigrationFile(constant.MDMMigrationTypeManual); err != nil { + log.Error().Err(err).Msg("set migration file") + } + + if isManual { + log.Info().Msg("showing instructions after unenrollment") + if err := m.handler.ShowInstructions(); err != nil { + return err + } + } + // close the spinner // TODO: maybe it's better to use // https://github.com/bartreardon/swiftDialog/wiki/Updating-Dialog-with-new-content @@ -381,10 +434,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,7 +484,7 @@ func (m *swiftDialogMDMMigrator) SetProps(props MDMMigratorProps) { m.props = props } -func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string, error) { +func (m *swiftDialogMDMMigrator) getMessageAndFlags(isManual bool) (*bytes.Buffer, []string, error) { vers, err := m.getMacOSMajorVersion() if err != nil { // log error for debugging and continue with default template @@ -443,6 +492,10 @@ func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string, } tmpl := mdmMigrationTemplate + if isManual { + tmpl = mdmManualMigrationTemplate + } + height := "669" if vers != 0 && vers < 14 { height = "440" @@ -454,7 +507,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{ @@ -502,3 +555,11 @@ func (m *swiftDialogMDMMigrator) getMacOSMajorVersion() (int, error) { } return major, nil } + +func (m *swiftDialogMDMMigrator) MigrationInProgress() (bool, error) { + return m.mrw.FileExists() +} + +func (m *swiftDialogMDMMigrator) MarkMigrationCompleted() error { + return m.mrw.RemoveFile() +} diff --git a/orbit/pkg/useraction/mdm_migration_notdarwin.go b/orbit/pkg/useraction/mdm_migration_notdarwin.go index 98615a193c..c3ecec61e4 100644 --- a/orbit/pkg/useraction/mdm_migration_notdarwin.go +++ b/orbit/pkg/useraction/mdm_migration_notdarwin.go @@ -2,16 +2,22 @@ package useraction -import "time" +import ( + "time" -func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler) MDMMigrator { + "github.com/fleetdm/fleet/v4/orbit/pkg/migration" +) + +func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler, mrw *migration.ReadWriter) MDMMigrator { return &NoopMDMMigrator{} } 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() (bool, error) { return false, nil } +func (m *NoopMDMMigrator) MarkMigrationCompleted() error { return nil } diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index ac0e86723d..4221356d2c 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -1222,3 +1222,15 @@ func IsEligibleForDEPMigration(host *Host, mdmInfo *HostMDM, isConnectedToFleetM // the checkout message from the host. (!isConnectedToFleetMDM || mdmInfo.Name != WellKnownMDMFleet) } + +// 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 { + return host.IsOsqueryEnrolled() && + !host.IsDEPAssignedToFleet() && + mdmInfo != nil && + !mdmInfo.InstalledFromDep && + !mdmInfo.HasJSONProfileAssigned() && + mdmInfo.Enrolled && + (!isConnectedToFleetMDM || mdmInfo.Name != WellKnownMDMFleet) +} diff --git a/server/fleet/hosts_test.go b/server/fleet/hosts_test.go index 94d0cd40a0..e6deb677c7 100644 --- a/server/fleet/hosts_test.go +++ b/server/fleet/hosts_test.go @@ -222,6 +222,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse DEPAssignProfileResponseStatus enrolledInThirdPartyMDM bool expected bool + expectedManual bool }{ { name: "Eligible for DEP migration", @@ -230,6 +231,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: DEPAssignProfileResponseSuccess, enrolledInThirdPartyMDM: true, expected: true, + expectedManual: false, }, { name: "Not eligible - osqueryHostID nil", @@ -238,6 +240,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: DEPAssignProfileResponseSuccess, enrolledInThirdPartyMDM: true, expected: false, + expectedManual: false, }, { name: "Not eligible - not DEP assigned to Fleet", @@ -246,6 +249,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 +258,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 +267,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: DEPAssignProfileResponseNotAccessible, enrolledInThirdPartyMDM: true, expected: false, + expectedManual: true, }, { name: "Not eligible - DEP assigned and DEP profile failed", @@ -270,6 +276,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 +285,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { depProfileResponse: "", enrolledInThirdPartyMDM: true, expected: false, + expectedManual: false, }, { name: "Not eligible - DEP assigned but not accessible", @@ -286,6 +294,16 @@ 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, }, } @@ -303,6 +321,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) { } require.Equal(t, tc.expected, IsEligibleForDEPMigration(host, mdmInfo, false)) + require.Equal(t, tc.expectedManual, IsEligibleForManualMigration(host, mdmInfo, false)) }) } } diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 4ccd1cba50..7e2b345c05 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -1004,7 +1004,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) { @@ -3417,11 +3416,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) @@ -3509,6 +3503,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) @@ -5485,6 +5489,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(), ` @@ -9703,7 +9738,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() { @@ -9897,7 +9931,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() { @@ -10322,13 +10355,14 @@ func (s *integrationMDMTestSuite) TestVPPApps() { extraAvailable int }{ "iOS app install": {installHost: iOSHost, titleID: iOSTitleID, mdmClient: iOSMdmClient, app: iOSApp}, - "iPadOS app install": {installHost: iPadOSHost, titleID: iPadOSTitleID, mdmClient: iPadOSMdmClient, app: iPadOSApp, - extraAvailable: 1}, + "iPadOS app install": { + installHost: iPadOSHost, titleID: iPadOSTitleID, mdmClient: iPadOSMdmClient, app: iPadOSApp, + extraAvailable: 1, + }, } for name, install := range installs { t.Run(name, func(t *testing.T) { - installHost := install.installHost titleID := install.titleID mdmClient := install.mdmClient diff --git a/server/service/orbit.go b/server/service/orbit.go index e6241a0640..30d8b628ff 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -204,7 +204,7 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro } if appConfig.MDM.MacOSMigration.Enable && - fleet.IsEligibleForDEPMigration(host, mdmInfo, isConnectedToFleetMDM) { + (fleet.IsEligibleForDEPMigration(host, mdmInfo, isConnectedToFleetMDM) || fleet.IsEligibleForManualMigration(host, mdmInfo, isConnectedToFleetMDM)) { notifs.NeedsMDMMigration = true } diff --git a/tools/mdm/migration/micromdm/README.md b/tools/mdm/migration/micromdm/README.md new file mode 100644 index 0000000000..da6d073b8b --- /dev/null +++ b/tools/mdm/migration/micromdm/README.md @@ -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. \ No newline at end of file diff --git a/tools/mdm/migration/micromdm/main.go b/tools/mdm/migration/micromdm/main.go new file mode 100644 index 0000000000..87e73246b9 --- /dev/null +++ b/tools/mdm/migration/micromdm/main.go @@ -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 := µMDMClient{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 +} diff --git a/website/assets/images/permanent/mdm-manual-migration-1024x500.png b/website/assets/images/permanent/mdm-manual-migration-1024x500.png new file mode 100644 index 0000000000..f7700d4bde Binary files /dev/null and b/website/assets/images/permanent/mdm-manual-migration-1024x500.png differ