feat: manual MDM migration updates (#21115)

> Related issue: #20311

# 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:
- [ ] Orbit runs on macOS, Linux and Windows. Check if the orbit
feature/bugfix should only apply to one platform (`runtime.GOOS`).
- [ ] 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-08 15:46:42 -04:00
committed by GitHub
parent 051ba6f780
commit 6816bc89f0
18 changed files with 517 additions and 31 deletions
+1
View File
@@ -0,0 +1 @@
- Adds ability for MDM migrations if the host is manually enrolled to a 3rd party MDM.
+3 -2
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
- Adds ability for MDM migrations if the host is manually enrolled to a 3rd party MDM.
+33 -2
View File
@@ -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
}
+7
View File
@@ -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"
)
+107
View File
@@ -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)
}
+31
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")
+1
View File
@@ -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
}
+6
View File
@@ -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
+69 -8
View File
@@ -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()
}
@@ -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 }
+12
View File
@@ -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)
}
+19
View File
@@ -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))
})
}
}
+45 -11
View File
@@ -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
+1 -1
View File
@@ -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
}
+19
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.
+149
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: 312 KiB