From 15b60c1f417f8a7208796f8836a5456119bdbd4a Mon Sep 17 00:00:00 2001 From: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:14:00 -0500 Subject: [PATCH] Delete iOS host refetch commands on MDM re-enrollment (#30158) --- changes/25827-ios-refetch | 2 + server/datastore/mysql/apple_mdm.go | 14 +- .../service/integration_mdm_lifecycle_test.go | 133 ++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 changes/25827-ios-refetch diff --git a/changes/25827-ios-refetch b/changes/25827-ios-refetch new file mode 100644 index 0000000000..f02f1b68aa --- /dev/null +++ b/changes/25827-ios-refetch @@ -0,0 +1,2 @@ +- Fixed issue where iOS devices were not refetching at the expected cadence when re-enrolled without first + deleting the host. diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 91a5f8cfd8..589425cfb5 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -4719,7 +4719,19 @@ func (ds *Datastore) MDMResetEnrollment(ctx context.Context, hostUUID string, sc return ctxerr.Wrap(ctx, err, "resetting disk encryption key information for host") } - if host.Platform == "darwin" { + // Do platform-specific cleanup. + switch host.Platform { + case "ios", "ipados": + // Clear refetch commands for iOS and iPadOS hosts. + // FIXME: Do we care about wipe/lock commands? How can we consolidate this with host deletion? See https://github.com/fleetdm/fleet/pull/29283/files#r2098735905 + _, err = tx.ExecContext(ctx, ` + DELETE FROM host_mdm_commands + WHERE host_id = ? AND instr(command_type, ?)`, host.ID, fleet.RefetchBaseCommandUUIDPrefix) + if err != nil { + return ctxerr.Wrap(ctx, err, "resetting host_mdm_commands for host") + } + + case "darwin": // Deleting the matching entry on this table will cause // the aggregate report to show this host as 'pending' to // install the bootstrap package. diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index 1d9292f660..3184759350 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/fleetdm/fleet/v4/pkg/fleetdbase" "github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest" @@ -951,3 +952,135 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { }) require.True(t, stillMigrated) } + +func (s *integrationMDMTestSuite) TestRefetchAfterReenrollIOSNoDelete() { + t := s.T() + + checkInstallFleetdCommandSent := func(mdmDevice *mdmtest.TestAppleMDMClient, wantCommand bool) { + foundInstallFleetdCommand := false + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + for cmd != nil { + var fullCmd micromdm.CommandPayload + require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) + if manifest := fullCmd.Command.InstallEnterpriseApplication.ManifestURL; manifest != nil { + foundInstallFleetdCommand = true + require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType) + require.Contains(t, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL, fleetdbase.GetPKGManifestURL()) + } + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + require.Equal(t, wantCommand, foundInstallFleetdCommand) + } + + triggerRefetchCron := func(hostID uint, expectCmds int) { + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(context.Background(), `UPDATE hosts SET detail_updated_at = DATE_SUB(NOW(), INTERVAL 2 HOUR) WHERE id = ?`, hostID) + return err + }) + trigger := triggerRequest{ + Name: string(fleet.CronAppleMDMIPhoneIPadRefetcher), + } + s.Do("POST", "/api/latest/fleet/trigger", trigger, http.StatusOK) + + // Wait until MDM commands are set up + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for range ticker.C { + commands, err := s.ds.GetHostMDMCommands(context.Background(), hostID) + require.NoError(t, err) + if len(commands) >= expectCmds { + done <- struct{}{} + return + } + } + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Error("Timeout: MDM commands not queued up") + } + } + + // create a global enroll secret + globalSecret := "global_secret" + var applyResp applyEnrollSecretSpecResponse + s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ + Spec: &fleet.EnrollSecretSpec{ + Secrets: []*fleet.EnrollSecret{{Secret: globalSecret}}, + }, + }, http.StatusOK, &applyResp) + + hwModel := "iPad13,16" + mdmDevice := mdmtest.NewTestMDMClientAppleOTA( + s.server.URL, + "global_secret", + hwModel, + ) + // enrollTime := time.Now().UTC().Truncate(time.Second) + require.NoError(t, mdmDevice.Enroll()) + s.runWorker() + checkInstallFleetdCommandSent(mdmDevice, false) + + hostByIdentifierResp := getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp) + require.Equal(t, hwModel, hostByIdentifierResp.Host.HardwareModel) + require.Equal(t, "ipados", hostByIdentifierResp.Host.Platform) + require.False(t, hostByIdentifierResp.Host.RefetchRequested) + hostID := hostByIdentifierResp.Host.ID + + triggerRefetchCron(hostID, 3) + + hostByIdentifierResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp) + require.Equal(t, hwModel, hostByIdentifierResp.Host.HardwareModel) + require.Equal(t, "ipados", hostByIdentifierResp.Host.Platform) + require.False(t, hostByIdentifierResp.Host.RefetchRequested) + + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + for cmd != nil { + switch cmd.Command.RequestType { + case "InstalledApplicationList": + cmd, err = mdmDevice.AcknowledgeInstalledApplicationList(mdmDevice.UUID, cmd.CommandUUID, []fleet.Software{}) + require.NoError(t, err) + case "CertificateList": + cmd, err = mdmDevice.AcknowledgeCertificateList(mdmDevice.UUID, cmd.CommandUUID, []*x509.Certificate{}) + require.NoError(t, err) + case "DeviceInformation": + cmd, err = mdmDevice.AcknowledgeDeviceInformation(mdmDevice.UUID, cmd.CommandUUID, "Test Name", "iPhone 16") + require.NoError(t, err) + default: + require.Fail(t, "unexpected command", cmd.Command.RequestType) + } + } + + commands, err := s.ds.GetHostMDMCommands(context.Background(), hostID) + require.NoError(t, err) + require.Len(t, commands, 0) + + triggerRefetchCron(hostID, 3) + + commands, err = s.ds.GetHostMDMCommands(context.Background(), hostID) + require.NoError(t, err) + require.Len(t, commands, 3) + cmdTypes := make([]string, 0, len(commands)) + for _, cmd := range commands { + cmdTypes = append(cmdTypes, cmd.CommandType) + } + require.ElementsMatch(t, []string{fleet.RefetchDeviceCommandUUIDPrefix, fleet.RefetchAppsCommandUUIDPrefix, fleet.RefetchCertsCommandUUIDPrefix}, cmdTypes) + + // re-enroll the device + require.NoError(t, mdmDevice.Enroll()) + + commands, err = s.ds.GetHostMDMCommands(context.Background(), hostID) + require.NoError(t, err) + require.Len(t, commands, 0) + + triggerRefetchCron(hostID, 3) + + // TODO: Do we care about manually triggered host refetch (where refetch_requested=true)? +}