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:
@@ -0,0 +1 @@
|
||||
- Adds ability for MDM migrations if the host is manually enrolled to a 3rd party MDM.
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- Adds ability for MDM migrations if the host is manually enrolled to a 3rd party MDM.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 312 KiB |
Reference in New Issue
Block a user