mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 01:18:42 +00:00
feat: macOS MDM migration updates (#21359)
> Related issue: #19625 # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: - [x] Orbit runs on macOS, Linux and Windows. Check if the orbit feature/bugfix should only apply to one platform (`runtime.GOOS`). - [x] Manual QA must be performed in the three main OSs, macOS, Windows and Linux. - [x] Auto-update manual QA, from released version of component to new version (see [tools/tuf/test](../tools/tuf/test/README.md)).
This commit is contained in:
commit
1fef99af47
25 changed files with 1154 additions and 151 deletions
1
changes/20310-update-my-device-copy
Normal file
1
changes/20310-update-my-device-copy
Normal file
|
|
@ -0,0 +1 @@
|
|||
- update copy on for automica enrollment modal on my device page.
|
||||
3
changes/20311-migrations
Normal file
3
changes/20311-migrations
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
- Adds ability for MDM migrations if the host is manually enrolled to a 3rd party MDM.
|
||||
- Adds an offline screen to the macOS MDM migration flow.
|
||||
- Updates the instructions on "My device" for MDM migrations on pre-Sonoma macOS hosts.
|
||||
|
|
@ -62,7 +62,12 @@ func (svc *Service) TriggerMigrateMDMDevice(ctx context.Context, host *fleet.Hos
|
|||
return ctxerr.Wrap(ctx, err, "fetching host mdm info")
|
||||
}
|
||||
|
||||
if !fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) {
|
||||
manualMigrationEligible, err := fleet.IsEligibleForManualMigration(host, mdmInfo, connected)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "checking manual migration eligibility")
|
||||
}
|
||||
|
||||
if !fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) && !manualMigrationEligible {
|
||||
bre.InternalErr = ctxerr.New(ctx, "host not eligible for macOS migration")
|
||||
}
|
||||
|
||||
|
|
@ -139,9 +144,15 @@ func (svc *Service) GetFleetDesktopSummary(ctx context.Context) (fleet.DesktopSu
|
|||
sum.Notifications.RenewEnrollmentProfile = true
|
||||
}
|
||||
|
||||
if fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) {
|
||||
manualMigrationEligible, err := fleet.IsEligibleForManualMigration(host, mdmInfo, connected)
|
||||
if err != nil {
|
||||
return sum, ctxerr.Wrap(ctx, err, "checking manual migration eligibility")
|
||||
}
|
||||
|
||||
if fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) || manualMigrationEligible {
|
||||
sum.Notifications.NeedsMDMMigration = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// organization information
|
||||
|
|
|
|||
|
|
@ -23,6 +23,57 @@ const AutoEnrollMdmModal = ({
|
|||
.map((s) => parseInt(s, 10));
|
||||
isMacOsSonomaOrLater = major >= 14;
|
||||
}
|
||||
|
||||
const preSonomaBody = (
|
||||
<>
|
||||
<p className={`${baseClass}__description`}>
|
||||
To turn on MDM, Apple Inc. requires you to follow the steps below.
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
Open your Mac's notification center by selecting the date and
|
||||
time in the top right corner of your screen.
|
||||
</li>
|
||||
<li>
|
||||
Select the <b>Device Enrollment</b> notification. This will open{" "}
|
||||
<b>System Settings</b>. Select <b>Allow</b>.
|
||||
</li>
|
||||
<li>
|
||||
Enter your password, and select <b>Enroll</b>.
|
||||
</li>
|
||||
<li>
|
||||
Select <b>Done</b> to close this window and select Refetch on your My
|
||||
device page to tell your organization that MDM is on.
|
||||
</li>
|
||||
</ol>
|
||||
</>
|
||||
);
|
||||
|
||||
const sonomaAndAboveBody = (
|
||||
<>
|
||||
<p className={`${baseClass}__description`}>
|
||||
To turn on MDM, Apple Inc. requires that you install a profile.
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
From the Apple menu in the top left corner of your screen, select{" "}
|
||||
<b>System Settings</b> or <b>System Preferences</b>.
|
||||
</li>
|
||||
<li>
|
||||
In the sidebar menu, select <b>Enroll in Remote Management</b>, and
|
||||
select <b>Enroll</b>.
|
||||
</li>
|
||||
<li>
|
||||
Enter your password, and select <b>Enroll</b>.
|
||||
</li>
|
||||
<li>
|
||||
Close this window and select <b>Refetch</b> on your My device page to
|
||||
tell your organization that MDM is on.
|
||||
</li>
|
||||
</ol>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Turn on MDM"
|
||||
|
|
@ -31,35 +82,7 @@ const AutoEnrollMdmModal = ({
|
|||
width="xlarge"
|
||||
>
|
||||
<div>
|
||||
<p className={`${baseClass}__description`}>
|
||||
To turn on MDM, Apple Inc. requires that you install a profile.
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
From the Apple menu in the top left corner of your screen, select{" "}
|
||||
<b>System Settings</b> or <b>System Preferences</b>.
|
||||
</li>
|
||||
<li>
|
||||
{isMacOsSonomaOrLater ? (
|
||||
<>
|
||||
In the sidebar menu, select <b>Enroll in Remote Management</b>,
|
||||
and select <b>Enroll</b>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
In the search bar, type “Profiles.” Select <b>Profiles</b>, find
|
||||
and select <b>Enrollment Profile</b>, and select <b>Install</b>.
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
Enter your password, and select <b>Enroll</b>.
|
||||
</li>
|
||||
<li>
|
||||
Close this window and select <b>Refetch</b> on your My device page
|
||||
to tell your organization that MDM is on.
|
||||
</li>
|
||||
</ol>
|
||||
{isMacOsSonomaOrLater ? sonomaAndAboveBody : preSonomaBody}
|
||||
<div className="modal-cta-wrap">
|
||||
<Button type="button" onClick={onCancel} variant="brand">
|
||||
Done
|
||||
|
|
|
|||
1
orbit/changes/20311-migrations
Normal file
1
orbit/changes/20311-migrations
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Adds ability for MDM migrations if the host is manually enrolled to a 3rd party MDM.
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -12,6 +13,7 @@ import (
|
|||
"fyne.io/systray"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/go-paniclog"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/migration"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/profiles"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/token"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/update"
|
||||
|
|
@ -59,6 +61,11 @@ func setupRunners() {
|
|||
}
|
||||
|
||||
func main() {
|
||||
// FIXME: we need to do a better job of graceful shutdown, releasing resources, stopping
|
||||
// tickers, etc. (https://github.com/fleetdm/fleet/issues/21256)
|
||||
// This context will be used as a general context to handle graceful shutdown in the future.
|
||||
offlineWatcherCtx, cancelOfflineWatcherCtx := context.WithCancel(context.Background())
|
||||
|
||||
// Orbits uses --version to get the fleet-desktop version. Logs do not need to be set up when running this.
|
||||
if len(os.Args) > 1 && os.Args[1] == "--version" {
|
||||
// Must work with update.GetVersion
|
||||
|
|
@ -105,6 +112,15 @@ func main() {
|
|||
go setupRunners()
|
||||
|
||||
var mdmMigrator useraction.MDMMigrator
|
||||
// swiftDialogCh is a channel shared by the migrator and the offline watcher to
|
||||
// coordinate the display of the dialog and ensure only one dialog is shown at a time.
|
||||
var swiftDialogCh chan struct{}
|
||||
var offlineWatcher useraction.MDMOfflineWatcher
|
||||
|
||||
// This ticker is used for fetching the desktop summary. It is initialized here because it is
|
||||
// stopped in `OnExit.`
|
||||
const checkInterval = 5 * time.Minute
|
||||
summaryTicker := time.NewTicker(checkInterval)
|
||||
|
||||
onReady := func() {
|
||||
log.Info().Msg("ready")
|
||||
|
|
@ -157,6 +173,7 @@ func main() {
|
|||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("unable to initialize request client")
|
||||
}
|
||||
|
||||
client.WithInvalidTokenRetry(func() string {
|
||||
log.Debug().Msg("refetching token from disk for API retry")
|
||||
newToken, err := tokenReader.Read()
|
||||
|
|
@ -168,13 +185,6 @@ func main() {
|
|||
return newToken
|
||||
})
|
||||
|
||||
refetchToken := func() {
|
||||
if _, err := tokenReader.Read(); err != nil {
|
||||
log.Error().Err(err).Msg("refetch token")
|
||||
}
|
||||
log.Debug().Msg("successfully refetched the token from disk")
|
||||
}
|
||||
|
||||
disableTray := func() {
|
||||
log.Debug().Msg("disabling tray items")
|
||||
myDeviceItem.SetTitle("Connecting...")
|
||||
|
|
@ -186,6 +196,44 @@ func main() {
|
|||
migrateMDMItem.Hide()
|
||||
}
|
||||
|
||||
reportError := func(err error, info map[string]any) {
|
||||
if !client.GetServerCapabilities().Has(fleet.CapabilityErrorReporting) {
|
||||
log.Info().Msg("skipped reporting error to the server as it doesn't have the capability enabled")
|
||||
return
|
||||
}
|
||||
|
||||
fleetdErr := fleet.FleetdError{
|
||||
ErrorSource: "fleet-desktop",
|
||||
ErrorSourceVersion: version,
|
||||
ErrorTimestamp: time.Now(),
|
||||
ErrorMessage: err.Error(),
|
||||
ErrorAdditionalInfo: info,
|
||||
}
|
||||
|
||||
if err := client.ReportError(tokenReader.GetCached(), fleetdErr); err != nil {
|
||||
log.Error().Err(err).EmbedObject(fleetdErr).Msg("reporting error to Fleet server")
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
m, s, o, err := mdmMigrationSetup(offlineWatcherCtx, tufUpdateRoot, fleetURL, client, &tokenReader)
|
||||
if err != nil {
|
||||
go reportError(err, nil)
|
||||
log.Error().Err(err).Msg("setting up MDM migration resources")
|
||||
}
|
||||
|
||||
mdmMigrator = m
|
||||
swiftDialogCh = s
|
||||
offlineWatcher = o
|
||||
}
|
||||
|
||||
refetchToken := func() {
|
||||
if _, err := tokenReader.Read(); err != nil {
|
||||
log.Error().Err(err).Msg("refetch token")
|
||||
}
|
||||
log.Debug().Msg("successfully refetched the token from disk")
|
||||
}
|
||||
|
||||
// checkToken performs API test calls to enable the "My device" item as
|
||||
// soon as the device auth token is registered by Fleet.
|
||||
checkToken := func() <-chan interface{} {
|
||||
|
|
@ -246,53 +294,15 @@ func main() {
|
|||
}
|
||||
}()
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
_, swiftDialogPath, _ := update.LocalTargetPaths(
|
||||
tufUpdateRoot,
|
||||
"swiftDialog",
|
||||
update.SwiftDialogMacOSTarget,
|
||||
)
|
||||
mdmMigrator = useraction.NewMDMMigrator(
|
||||
swiftDialogPath,
|
||||
15*time.Minute,
|
||||
&mdmMigrationHandler{
|
||||
client: client,
|
||||
tokenReader: &tokenReader,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
reportError := func(err error, info map[string]any) {
|
||||
if !client.GetServerCapabilities().Has(fleet.CapabilityErrorReporting) {
|
||||
log.Info().Msg("skipped reporting error to the server as it doesn't have the capability enabled")
|
||||
return
|
||||
}
|
||||
|
||||
fleetdErr := fleet.FleetdError{
|
||||
ErrorSource: "fleet-desktop",
|
||||
ErrorSourceVersion: version,
|
||||
ErrorTimestamp: time.Now(),
|
||||
ErrorMessage: err.Error(),
|
||||
ErrorAdditionalInfo: info,
|
||||
}
|
||||
|
||||
if err := client.ReportError(tokenReader.GetCached(), fleetdErr); err != nil {
|
||||
log.Error().Err(err).EmbedObject(fleetdErr).Msg("reporting error to Fleet server")
|
||||
}
|
||||
}
|
||||
|
||||
// poll the server to check the policy status of the host and update the
|
||||
// tray icon accordingly
|
||||
const checkInterval = 5 * time.Minute
|
||||
tic := time.NewTicker(checkInterval)
|
||||
defer tic.Stop()
|
||||
go func() {
|
||||
<-deviceEnabledChan
|
||||
|
||||
for {
|
||||
<-tic.C
|
||||
<-summaryTicker.C
|
||||
// Reset the ticker to the intended interval, in case we reset it to 1ms
|
||||
tic.Reset(checkInterval)
|
||||
summaryTicker.Reset(checkInterval)
|
||||
sum, err := client.DesktopSummary(tokenReader.GetCached())
|
||||
switch {
|
||||
case err == nil:
|
||||
|
|
@ -312,7 +322,19 @@ func main() {
|
|||
refreshMenuItems(sum.DesktopSummary, selfServiceItem, myDeviceItem)
|
||||
myDeviceItem.Enable()
|
||||
|
||||
shouldRunMigrator := sum.Notifications.NeedsMDMMigration || sum.Notifications.RenewEnrollmentProfile
|
||||
// Check our file to see if we should migrate
|
||||
var migrationType string
|
||||
if runtime.GOOS == "darwin" {
|
||||
migrationType, err = mdmMigrator.MigrationInProgress()
|
||||
if err != nil {
|
||||
go reportError(err, nil)
|
||||
log.Error().Err(err).Msg("checking if MDM migration is in progress")
|
||||
}
|
||||
}
|
||||
|
||||
migrationInProgress := migrationType != ""
|
||||
|
||||
shouldRunMigrator := sum.Notifications.NeedsMDMMigration || sum.Notifications.RenewEnrollmentProfile || migrationInProgress
|
||||
|
||||
if runtime.GOOS == "darwin" && shouldRunMigrator && mdmMigrator.CanRun() {
|
||||
enrolled, enrollURL, err := profiles.IsEnrolledInMDM()
|
||||
|
|
@ -347,18 +369,28 @@ func main() {
|
|||
})
|
||||
|
||||
// enable tray items
|
||||
migrateMDMItem.Enable()
|
||||
migrateMDMItem.Show()
|
||||
if migrationType != constant.MDMMigrationTypeADE {
|
||||
migrateMDMItem.Enable()
|
||||
migrateMDMItem.Show()
|
||||
}
|
||||
|
||||
// if the device is unmanaged or we're in force mode and the device needs
|
||||
// migration, enable aggressive mode.
|
||||
if isUnmanaged || forceModeEnabled {
|
||||
if isUnmanaged || forceModeEnabled || migrationInProgress {
|
||||
log.Info().Msg("MDM device is unmanaged or force mode enabled, automatically showing dialog")
|
||||
if err := mdmMigrator.ShowInterval(); err != nil {
|
||||
go reportError(err, nil)
|
||||
log.Error().Err(err).Msg("showing MDM migration dialog at interval")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// we're done with the migration, so mark it as complete.
|
||||
if err := mdmMigrator.MarkMigrationCompleted(); err != nil {
|
||||
go reportError(err, nil)
|
||||
log.Error().Err(err).Msg("failed to mark MDM migration as completed")
|
||||
}
|
||||
migrateMDMItem.Disable()
|
||||
migrateMDMItem.Hide()
|
||||
}
|
||||
} else {
|
||||
migrateMDMItem.Disable()
|
||||
|
|
@ -376,7 +408,7 @@ func main() {
|
|||
log.Error().Err(err).Str("url", openURL).Msg("open browser my device")
|
||||
}
|
||||
// Also refresh the device status by forcing the polling ticker to fire
|
||||
tic.Reset(1 * time.Millisecond)
|
||||
summaryTicker.Reset(1 * time.Millisecond)
|
||||
case <-transparencyItem.ClickedCh:
|
||||
openURL := client.BrowserTransparencyURL(tokenReader.GetCached())
|
||||
if err := open.Browser(openURL); err != nil {
|
||||
|
|
@ -388,8 +420,12 @@ func main() {
|
|||
log.Error().Err(err).Str("url", openURL).Msg("open browser self-service")
|
||||
}
|
||||
// Also refresh the device status by forcing the polling ticker to fire
|
||||
tic.Reset(1 * time.Millisecond)
|
||||
summaryTicker.Reset(1 * time.Millisecond)
|
||||
case <-migrateMDMItem.ClickedCh:
|
||||
if offline := offlineWatcher.ShowIfOffline(offlineWatcherCtx); offline {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := mdmMigrator.Show(); err != nil {
|
||||
go reportError(err, nil)
|
||||
log.Error().Err(err).Msg("showing MDM migration dialog on user action")
|
||||
|
|
@ -398,11 +434,19 @@ func main() {
|
|||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// FIXME: it doesn't look like this is actually triggering, at least when desktop gets
|
||||
// killed (https://github.com/fleetdm/fleet/issues/21256)
|
||||
onExit := func() {
|
||||
log.Info().Msg("exit")
|
||||
if mdmMigrator != nil {
|
||||
mdmMigrator.Exit()
|
||||
}
|
||||
log.Info().Msg("exit")
|
||||
if swiftDialogCh != nil {
|
||||
close(swiftDialogCh)
|
||||
}
|
||||
summaryTicker.Stop()
|
||||
cancelOfflineWatcherCtx()
|
||||
}
|
||||
|
||||
systray.Run(onReady, onExit)
|
||||
|
|
@ -574,3 +618,36 @@ func logDir() (string, error) {
|
|||
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func mdmMigrationSetup(ctx context.Context, tufUpdateRoot, fleetURL string, client *service.DeviceClient, tokenReader *token.Reader) (useraction.MDMMigrator, chan struct{}, useraction.MDMOfflineWatcher, error) {
|
||||
dir, err := migration.Dir()
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
mrw := migration.NewReadWriter(dir, constant.MigrationFileName)
|
||||
|
||||
// we use channel buffer size of 1 to allow one dialog at a time with non-blocking sends.
|
||||
swiftDialogCh := make(chan struct{}, 1)
|
||||
|
||||
_, swiftDialogPath, _ := update.LocalTargetPaths(
|
||||
tufUpdateRoot,
|
||||
"swiftDialog",
|
||||
update.SwiftDialogMacOSTarget,
|
||||
)
|
||||
mdmMigrator := useraction.NewMDMMigrator(
|
||||
swiftDialogPath,
|
||||
15*time.Minute,
|
||||
&mdmMigrationHandler{
|
||||
client: client,
|
||||
tokenReader: tokenReader,
|
||||
},
|
||||
mrw,
|
||||
fleetURL,
|
||||
swiftDialogCh,
|
||||
)
|
||||
|
||||
offlineWatcher := useraction.StartMDMMigrationOfflineWatcher(ctx, client, swiftDialogPath, swiftDialogCh, migration.FileWatcher(mrw))
|
||||
|
||||
return mdmMigrator, swiftDialogCh, offlineWatcher, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -851,7 +851,7 @@ func main() {
|
|||
// create the notifications middleware that wraps the orbit client
|
||||
// (must be shared by all runners that use a ConfigFetcher).
|
||||
const (
|
||||
renewEnrollmentProfileCommandFrequency = time.Hour
|
||||
renewEnrollmentProfileCommandFrequency = 3 * time.Minute
|
||||
windowsMDMEnrollmentCommandFrequency = time.Hour
|
||||
windowsMDMBitlockerCommandFrequency = time.Hour
|
||||
)
|
||||
|
|
@ -864,8 +864,7 @@ func main() {
|
|||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
orbitClient.RegisterConfigReceiver(update.ApplyRenewEnrollmentProfileConfigFetcherMiddleware(
|
||||
orbitClient, renewEnrollmentProfileCommandFrequency, fleetURL,
|
||||
))
|
||||
orbitClient, renewEnrollmentProfileCommandFrequency, fleetURL))
|
||||
const nudgeLaunchInterval = 30 * time.Minute
|
||||
orbitClient.RegisterConfigReceiver(update.ApplyNudgeConfigReceiverMiddleware(update.NudgeConfigFetcherOptions{
|
||||
UpdateRunner: updateRunner, RootDir: c.String("root-dir"), Interval: nudgeLaunchInterval,
|
||||
|
|
|
|||
|
|
@ -55,4 +55,17 @@ const (
|
|||
// ServerOverridesFileName is the name of the file in the root directory
|
||||
// that specifies the override configuration fetched from the server.
|
||||
ServerOverridesFileName = "server-overrides.json"
|
||||
// MigrationFileName is the name of the file used by fleetd to determine if the host is
|
||||
// partially through an MDM migration.
|
||||
MigrationFileName = "mdm_migration.txt"
|
||||
// MDMMigrationTypeManual indicates that the MDM migration is for a manually enrolled host.
|
||||
MDMMigrationTypeManual = "manual"
|
||||
// MDMMigrationTypeADE indicates that the MDM migration is for an ADE enrolled host.
|
||||
MDMMigrationTypeADE = "ade"
|
||||
// MDMMigrationTypePreSonoma indicates that the MDM migration is for a host on a macOS version < 14.
|
||||
MDMMigrationTypePreSonoma = "pre-sonoma"
|
||||
// MDMMigrationOfflineWatcherInterval is the interval at which the offline watcher checks for
|
||||
// the presence of the migration file.
|
||||
MDMMigrationOfflineWatcherInterval = 3 * time.Minute
|
||||
SonomaMajorVersion = 14
|
||||
)
|
||||
|
|
|
|||
157
orbit/pkg/migration/readwriter.go
Normal file
157
orbit/pkg/migration/readwriter.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package migration
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
|
||||
)
|
||||
|
||||
type ReadWriter struct {
|
||||
Path string
|
||||
FileName string
|
||||
}
|
||||
|
||||
func NewReadWriter(path, filename string) *ReadWriter {
|
||||
return &ReadWriter{
|
||||
Path: path,
|
||||
FileName: filepath.Join(path, filename),
|
||||
}
|
||||
}
|
||||
|
||||
// SetMigrationFile sets `typ` in the file used to track MDM migration type. This overwrites the
|
||||
// file if it exists.
|
||||
func (rw *ReadWriter) SetMigrationFile(typ string) error {
|
||||
_, err := rw.read()
|
||||
switch {
|
||||
case err == nil:
|
||||
// ensure the file is readable by other processes
|
||||
if err := rw.setChmod(); err != nil {
|
||||
return fmt.Errorf("loading migration file, chmod %q: %w", rw.Path, err)
|
||||
}
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
if err := os.MkdirAll(rw.Path, constant.DefaultDirMode); err != nil {
|
||||
return fmt.Errorf("creating directory for migration file: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(rw.FileName, []byte(typ), constant.DefaultWorldReadableFileMode); err != nil {
|
||||
return fmt.Errorf("writing migration file: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("load migration file %q: %w", rw.Path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveFile removes the file used for tracking the MDM migration type.
|
||||
func (rw *ReadWriter) RemoveFile() error {
|
||||
if err := os.Remove(rw.FileName); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// that's ok, noop
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("removing migration file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMigrationType returns the contents of the MDM migration file. The contents say what type of
|
||||
// migration it is.
|
||||
func (rw *ReadWriter) GetMigrationType() (string, error) {
|
||||
data, err := rw.read()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// FileExists returns whether or not the MDM migration file exists on this host.
|
||||
func (rw *ReadWriter) FileExists() (bool, error) {
|
||||
_, err := os.Stat(rw.FileName)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DirExists returns whether or not the directory where the MDM migration file is stored exists.
|
||||
func (rw *ReadWriter) DirExists() (bool, error) {
|
||||
_, err := os.Stat(rw.FileName)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (rw *ReadWriter) read() (string, error) {
|
||||
data, err := os.ReadFile(rw.FileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (rw *ReadWriter) setChmod() error {
|
||||
return os.Chmod(rw.FileName, constant.DefaultWorldReadableFileMode)
|
||||
}
|
||||
|
||||
func (rw *ReadWriter) NewFileWatcher() FileWatcher {
|
||||
return &fileWatcher{rw: rw}
|
||||
}
|
||||
|
||||
type FileWatcher interface {
|
||||
GetMigrationType() (string, error)
|
||||
FileExists() (bool, error)
|
||||
DirExists() (bool, error)
|
||||
}
|
||||
|
||||
type fileWatcher struct {
|
||||
rw *ReadWriter
|
||||
}
|
||||
|
||||
// GetMigrationType returns the contents of the MDM migration file which indicate what type of
|
||||
// migration it is.
|
||||
func (r *fileWatcher) GetMigrationType() (string, error) {
|
||||
return r.rw.GetMigrationType()
|
||||
}
|
||||
|
||||
// FileExists returns whether or not the MDM migration file exists on this host.
|
||||
func (r *fileWatcher) FileExists() (bool, error) {
|
||||
return r.rw.FileExists()
|
||||
}
|
||||
|
||||
// DirExists returns whether or not the directory where the MDM migration file is stored exists.
|
||||
func (r *fileWatcher) DirExists() (bool, error) {
|
||||
return r.rw.DirExists()
|
||||
}
|
||||
|
||||
// Dir returns the path to the directory where the MDM migration file is stored. This path should be
|
||||
// ~/Library/Caches/com.fleetdm.orbit
|
||||
func Dir() (string, error) {
|
||||
homedir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user's home directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(homedir, "Library/Caches/com.fleetdm.orbit"), nil
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ func (h *renewEnrollmentProfileConfigReceiver) Run(config *fleet.OrbitConfig) er
|
|||
// enrolled (after the user's manual steps, and osquery reporting the
|
||||
// updated mdm enrollment).
|
||||
// See https://github.com/fleetdm/fleet/pull/9409#discussion_r1084382455
|
||||
if time.Since(h.lastRun) > h.Frequency {
|
||||
if time.Since(h.lastRun) >= h.Frequency {
|
||||
// we perform this check locally on the client too to avoid showing the
|
||||
// dialog if the client is enrolled to an MDM server.
|
||||
enrollFn := h.checkEnrollmentFn
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ func (s *SwiftDialogDownloader) Run(cfg *fleet.OrbitConfig) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// TODO: we probably want to ensure that swiftDialog is always installed if we're going to be
|
||||
// using it offline.
|
||||
if !cfg.Notifications.NeedsMDMMigration && !cfg.Notifications.RenewEnrollmentProfile {
|
||||
log.Debug().Msg("got false needs migration and false renew enrollment")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package useraction
|
||||
|
||||
import "github.com/fleetdm/fleet/v4/server/fleet"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
)
|
||||
|
||||
// MDMMigrator represents the minimum set of methods a migration must implement
|
||||
// in order to be used by Fleet Desktop.
|
||||
|
|
@ -19,6 +23,12 @@ type MDMMigrator interface {
|
|||
ShowInterval() error
|
||||
// Exit tries to stop any processes started by the migrator.
|
||||
Exit()
|
||||
// MigrationInProgress checks if the MDM migration is still in progress (i.e. the host is not
|
||||
// yet fully enrolled in Fleet MDM). It returns the type of migration that is in progress, if any.
|
||||
MigrationInProgress() (string, error)
|
||||
// MarkMigrationCompleted marks the migration as completed. This is currently done by removing
|
||||
// the migration file.
|
||||
MarkMigrationCompleted() error
|
||||
}
|
||||
|
||||
// MDMMigratorProps are props required to display the dialog. It's akin to the
|
||||
|
|
@ -26,6 +36,8 @@ type MDMMigrator interface {
|
|||
type MDMMigratorProps struct {
|
||||
OrgInfo fleet.DesktopOrgInfo
|
||||
IsUnmanaged bool
|
||||
// DisableTakeover is used to disable the blur and always on top features of the dialog.
|
||||
DisableTakeover bool
|
||||
}
|
||||
|
||||
// MDMMigratorHandler handles remote actions/callbacks that the migrator calls.
|
||||
|
|
@ -33,3 +45,7 @@ type MDMMigratorHandler interface {
|
|||
NotifyRemote() error
|
||||
ShowInstructions() error
|
||||
}
|
||||
|
||||
type MDMOfflineWatcher interface {
|
||||
ShowIfOffline(ctx context.Context) bool
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ package useraction
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
|
@ -14,9 +15,13 @@ import (
|
|||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/migration"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/profiles"
|
||||
"github.com/fleetdm/fleet/v4/pkg/file"
|
||||
"github.com/fleetdm/fleet/v4/pkg/retry"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
|
@ -56,15 +61,23 @@ var mdmMigrationTemplatePreSonoma = template.Must(template.New("mdmMigrationTemp
|
|||
|
||||
Select **Start** and look for this notification in your notification center:` +
|
||||
"\n\n\n\n" +
|
||||
"After you start, this window will popup every 15-20 minutes until you finish.",
|
||||
"After you start, this window will popup every 3 minutes until you finish.",
|
||||
))
|
||||
|
||||
var mdmMigrationTemplate = template.Must(template.New("mdmMigrationTemplate").Parse(`
|
||||
var mdmManualMigrationTemplate = template.Must(template.New("").Parse(`
|
||||
## Migrate to Fleet
|
||||
|
||||
Select **Start** and My device page will appear soon:` +
|
||||
"\n\n\n\n" +
|
||||
"After you start, this dialog will popup every 3 minutes until you finish.",
|
||||
))
|
||||
|
||||
var mdmADEMigrationTemplate = template.Must(template.New("").Parse(`
|
||||
## Migrate to Fleet
|
||||
|
||||
Select **Start** and Remote Management window will appear soon:` +
|
||||
"\n\n\n\n" +
|
||||
"After you start, this window will popup every 15-20 minutes until you finish.",
|
||||
"\n\n\n\n" +
|
||||
"After you start, **Remote Management** will popup every minute until you finish.",
|
||||
))
|
||||
|
||||
var errorTemplate = template.Must(template.New("").Parse(`
|
||||
|
|
@ -73,6 +86,14 @@ var errorTemplate = template.Must(template.New("").Parse(`
|
|||
Please contact your IT admin [here]({{ .ContactURL }}).
|
||||
`))
|
||||
|
||||
var unenrollBody = "## Migrate to Fleet\nUnenrolling you from your old MDM. This could take 90 seconds...\n\n%s"
|
||||
|
||||
var mdmMigrationTemplateOffline = template.Must(template.New("").Parse(`
|
||||
## Migrate to Fleet
|
||||
|
||||
🛜🚫 No internet connection. Please connect to internet to continue.`,
|
||||
))
|
||||
|
||||
// baseDialog implements the basic building blocks to render dialogs using
|
||||
// swiftDialog.
|
||||
type baseDialog struct {
|
||||
|
|
@ -110,11 +131,9 @@ func (b *baseDialog) render(flags ...string) (chan swiftDialogExitCode, chan err
|
|||
exitCodeCh := make(chan swiftDialogExitCode, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
// all dialogs should always be blurred and on top
|
||||
// all dialogs should always be centered
|
||||
flags = append(
|
||||
flags,
|
||||
"--blurscreen",
|
||||
"--ontop",
|
||||
"--messageposition", "center",
|
||||
)
|
||||
cmd := exec.Command(b.path, flags...) //nolint:gosec
|
||||
|
|
@ -166,14 +185,18 @@ func (b *baseDialog) render(flags ...string) (chan swiftDialogExitCode, chan err
|
|||
}
|
||||
|
||||
// NewMDMMigrator creates a new swiftDialogMDMMigrator with the right internal state.
|
||||
func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler) MDMMigrator {
|
||||
func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler, mrw *migration.ReadWriter, fleetURL string, showCh chan struct{}) MDMMigrator {
|
||||
if cap(showCh) != 1 {
|
||||
log.Fatal().Msg("swift dialog channel must have a buffer size of 1")
|
||||
}
|
||||
return &swiftDialogMDMMigrator{
|
||||
handler: handler,
|
||||
baseDialog: newBaseDialog(path),
|
||||
frequency: frequency,
|
||||
unenrollmentRetryInterval: defaultUnenrollmentRetryInterval,
|
||||
// set a buffer size of 1 to allow one Show without blocking
|
||||
showCh: make(chan struct{}, 1),
|
||||
mrw: mrw,
|
||||
fleetURL: fleetURL,
|
||||
showCh: showCh,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +212,8 @@ type swiftDialogMDMMigrator struct {
|
|||
// lastShown
|
||||
lastShown time.Time
|
||||
lastShownMu sync.RWMutex
|
||||
showCh chan struct{}
|
||||
// showCh is shared with the offline watcher and used to ensure only one dialog is open at a time
|
||||
showCh chan struct{}
|
||||
|
||||
// testEnrollmentCheckFileFn is used in tests to mock the call to verify
|
||||
// the enrollment status of the host
|
||||
|
|
@ -198,6 +222,8 @@ type swiftDialogMDMMigrator struct {
|
|||
// the enrollment status of the host
|
||||
testEnrollmentCheckStatusFn func() (bool, string, error)
|
||||
unenrollmentRetryInterval time.Duration
|
||||
mrw *migration.ReadWriter
|
||||
fleetURL string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -248,12 +274,23 @@ func (m *swiftDialogMDMMigrator) render(message string, flags ...string) (chan s
|
|||
return m.baseDialog.render(flags...)
|
||||
}
|
||||
|
||||
func (m *swiftDialogMDMMigrator) renderLoadingSpinner() (chan swiftDialogExitCode, chan error) {
|
||||
return m.render("## Migrate to Fleet\nUnenrolling you from your old MDM. This could take 90 seconds...",
|
||||
func (m *swiftDialogMDMMigrator) renderLoadingSpinner(preSonoma, isManual bool) (chan swiftDialogExitCode, chan error) {
|
||||
var body string
|
||||
switch true {
|
||||
case preSonoma:
|
||||
body = fmt.Sprintf(unenrollBody, "")
|
||||
case isManual:
|
||||
body = fmt.Sprintf(unenrollBody, "")
|
||||
default:
|
||||
// ADE migration, macOS > 14
|
||||
body = fmt.Sprintf(unenrollBody, "")
|
||||
}
|
||||
|
||||
return m.render(body,
|
||||
"--button1text", "Start",
|
||||
"--button1disabled",
|
||||
"--quitkey", "x",
|
||||
"--height", "220",
|
||||
"--height", "669",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -275,7 +312,7 @@ func (m *swiftDialogMDMMigrator) renderError() (chan swiftDialogExitCode, chan e
|
|||
// waitForUnenrollment waits 90 seconds (value determined by product) for the
|
||||
// device to unenroll from the current MDM solution. If the device doesn't
|
||||
// unenroll, an error is returned.
|
||||
func (m *swiftDialogMDMMigrator) waitForUnenrollment() error {
|
||||
func (m *swiftDialogMDMMigrator) waitForUnenrollment(isADEMigration bool) error {
|
||||
maxRetries := int(mdmUnenrollmentTotalWaitTime.Seconds() / m.unenrollmentRetryInterval.Seconds())
|
||||
checkFileFn := m.testEnrollmentCheckFileFn
|
||||
if checkFileFn == nil {
|
||||
|
|
@ -292,14 +329,16 @@ func (m *swiftDialogMDMMigrator) waitForUnenrollment() error {
|
|||
return retry.Do(func() error {
|
||||
var unenrolled bool
|
||||
|
||||
fileExists, fileErr := checkFileFn()
|
||||
if fileErr != nil {
|
||||
log.Error().Err(fileErr).Msg("checking for existence of cloudConfigProfileInstalled in migration modal")
|
||||
} else if fileExists {
|
||||
log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: found")
|
||||
} else {
|
||||
log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: not found")
|
||||
unenrolled = true
|
||||
if isADEMigration {
|
||||
fileExists, fileErr := checkFileFn()
|
||||
if fileErr != nil {
|
||||
log.Error().Err(fileErr).Msg("checking for existence of cloudConfigProfileInstalled in migration modal")
|
||||
} else if fileExists {
|
||||
log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: found")
|
||||
} else {
|
||||
log.Info().Msg("checking for existence of cloudConfigProfileInstalled in migration modal: not found")
|
||||
unenrolled = true
|
||||
}
|
||||
}
|
||||
|
||||
statusEnrolled, serverURL, statusErr := checkStatusFn()
|
||||
|
|
@ -326,7 +365,33 @@ func (m *swiftDialogMDMMigrator) waitForUnenrollment() error {
|
|||
}
|
||||
|
||||
func (m *swiftDialogMDMMigrator) renderMigration() error {
|
||||
message, flags, err := m.getMessageAndFlags()
|
||||
log.Debug().Msg("checking current enrollment status")
|
||||
isCurrentlyManuallyEnrolled, err := profiles.IsManuallyEnrolledInMDM()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check what kind of migration was in progress, if any.
|
||||
previousMigrationType, err := m.mrw.GetMigrationType()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("getting migration type")
|
||||
return fmt.Errorf("getting migration type: %w", err)
|
||||
}
|
||||
|
||||
isManualMigration := isCurrentlyManuallyEnrolled || previousMigrationType == constant.MDMMigrationTypeManual
|
||||
isADEMigration := previousMigrationType == constant.MDMMigrationTypeADE
|
||||
|
||||
log.Debug().Bool("isManualMigration", isManualMigration).Bool("isADEMigration", isADEMigration).Bool("isCurrentlyManuallyEnrolled", isCurrentlyManuallyEnrolled).Str("previousMigrationType", previousMigrationType).Msg("props after assigning")
|
||||
|
||||
vers, err := m.getMacOSMajorVersion()
|
||||
if err != nil {
|
||||
// log error for debugging and continue with default template
|
||||
log.Error().Err(err).Msg("getting macOS major version failed: using default migration template")
|
||||
}
|
||||
|
||||
isPreSonoma := vers < constant.SonomaMajorVersion
|
||||
|
||||
message, flags, err := m.getMessageAndFlags(vers, isManualMigration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting mdm migrator message: %w", err)
|
||||
}
|
||||
|
|
@ -342,9 +407,24 @@ func (m *swiftDialogMDMMigrator) renderMigration() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
if previousMigrationType == constant.MDMMigrationTypeADE {
|
||||
// Do nothing; the Remote Management modal will be launched by Orbit every minute.
|
||||
return nil
|
||||
}
|
||||
|
||||
if previousMigrationType == constant.MDMMigrationTypeManual || previousMigrationType == constant.MDMMigrationTypePreSonoma {
|
||||
// Launch the "My device" page.
|
||||
log.Info().Msg("showing instructions")
|
||||
|
||||
if err := m.handler.ShowInstructions(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !m.props.IsUnmanaged {
|
||||
// show the loading spinner
|
||||
m.renderLoadingSpinner()
|
||||
m.renderLoadingSpinner(isPreSonoma, isCurrentlyManuallyEnrolled)
|
||||
|
||||
// send the API call
|
||||
if notifyErr := m.handler.NotifyRemote(); notifyErr != nil {
|
||||
|
|
@ -361,7 +441,7 @@ func (m *swiftDialogMDMMigrator) renderMigration() error {
|
|||
}
|
||||
|
||||
log.Info().Msg("webhook sent, checking for unenrollment")
|
||||
if err := m.waitForUnenrollment(); err != nil {
|
||||
if err := m.waitForUnenrollment(isADEMigration); err != nil {
|
||||
m.baseDialog.Exit()
|
||||
errDialogExitChan, errDialogErrChan := m.renderError()
|
||||
select {
|
||||
|
|
@ -374,6 +454,35 @@ func (m *swiftDialogMDMMigrator) renderMigration() error {
|
|||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case isPreSonoma:
|
||||
if err := m.mrw.SetMigrationFile(constant.MDMMigrationTypePreSonoma); err != nil {
|
||||
log.Error().Str("migration_type", constant.MDMMigrationTypeADE).Err(err).Msg("set migration file")
|
||||
}
|
||||
|
||||
log.Info().Msg("showing instructions after pre-sonoma unenrollment")
|
||||
if err := m.handler.ShowInstructions(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case isManualMigration:
|
||||
if err := m.mrw.SetMigrationFile(constant.MDMMigrationTypeManual); err != nil {
|
||||
log.Error().Str("migration_type", constant.MDMMigrationTypeManual).Err(err).Msg("set migration file")
|
||||
}
|
||||
|
||||
log.Info().Msg("showing instructions after manual unenrollment")
|
||||
if err := m.handler.ShowInstructions(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.frequency = 3 * time.Minute
|
||||
|
||||
default:
|
||||
if err := m.mrw.SetMigrationFile(constant.MDMMigrationTypeADE); err != nil {
|
||||
log.Error().Str("migration_type", constant.MDMMigrationTypeADE).Err(err).Msg("set migration file")
|
||||
}
|
||||
}
|
||||
|
||||
// close the spinner
|
||||
// TODO: maybe it's better to use
|
||||
// https://github.com/bartreardon/swiftDialog/wiki/Updating-Dialog-with-new-content
|
||||
|
|
@ -381,10 +490,6 @@ func (m *swiftDialogMDMMigrator) renderMigration() error {
|
|||
m.baseDialog.Exit()
|
||||
}
|
||||
|
||||
log.Info().Msg("showing instructions")
|
||||
if err := m.handler.ShowInstructions(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -435,16 +540,14 @@ func (m *swiftDialogMDMMigrator) SetProps(props MDMMigratorProps) {
|
|||
m.props = props
|
||||
}
|
||||
|
||||
func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string, error) {
|
||||
vers, err := m.getMacOSMajorVersion()
|
||||
if err != nil {
|
||||
// log error for debugging and continue with default template
|
||||
log.Error().Err(err).Msg("getting macOS major version failed: using default migration template")
|
||||
func (m *swiftDialogMDMMigrator) getMessageAndFlags(version int, isManualMigration bool) (*bytes.Buffer, []string, error) {
|
||||
tmpl := mdmADEMigrationTemplate
|
||||
if isManualMigration {
|
||||
tmpl = mdmManualMigrationTemplate
|
||||
}
|
||||
|
||||
tmpl := mdmMigrationTemplate
|
||||
height := "669"
|
||||
if vers != 0 && vers < 14 {
|
||||
if version != 0 && version < constant.SonomaMajorVersion {
|
||||
height = "440"
|
||||
tmpl = mdmMigrationTemplatePreSonoma
|
||||
}
|
||||
|
|
@ -454,7 +557,7 @@ func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string,
|
|||
&message,
|
||||
m.props,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("executing migrqation template: %w", err)
|
||||
return nil, nil, fmt.Errorf("executing migration template: %w", err)
|
||||
}
|
||||
|
||||
flags := []string{
|
||||
|
|
@ -465,6 +568,13 @@ func (m *swiftDialogMDMMigrator) getMessageAndFlags() (*bytes.Buffer, []string,
|
|||
"--height", height,
|
||||
}
|
||||
|
||||
if !m.props.DisableTakeover {
|
||||
flags = append(flags,
|
||||
"--blurscreen",
|
||||
"--ontop",
|
||||
)
|
||||
}
|
||||
|
||||
if m.props.OrgInfo.ContactURL != "" {
|
||||
flags = append(flags,
|
||||
// info button
|
||||
|
|
@ -502,3 +612,236 @@ func (m *swiftDialogMDMMigrator) getMacOSMajorVersion() (int, error) {
|
|||
}
|
||||
return major, nil
|
||||
}
|
||||
|
||||
func (m *swiftDialogMDMMigrator) MigrationInProgress() (string, error) {
|
||||
return m.mrw.GetMigrationType()
|
||||
}
|
||||
|
||||
func (m *swiftDialogMDMMigrator) MarkMigrationCompleted() error {
|
||||
// Reset this to the original frequency.
|
||||
m.frequency = 15 * time.Minute
|
||||
return m.mrw.RemoveFile()
|
||||
}
|
||||
|
||||
type offlineWatcher struct {
|
||||
client *service.DeviceClient
|
||||
swiftDialogPath string
|
||||
// swiftDialogCh is shared with the migrator and used to ensure only one dialog is open at a time
|
||||
swiftDialogCh chan struct{}
|
||||
fileWatcher migration.FileWatcher
|
||||
}
|
||||
|
||||
// StartMDMMigrationOfflineWatcher starts a watcher running on a 3-minute loop that checks if the
|
||||
// device goes offline in the process of migrating to Fleet's MDM and offline. If so, it shows a
|
||||
// dialog to prompt the user to connect to the internet.
|
||||
func StartMDMMigrationOfflineWatcher(ctx context.Context, client *service.DeviceClient, swiftDialogPath string, swiftDialogCh chan struct{}, fileWatcher migration.FileWatcher) MDMOfflineWatcher {
|
||||
if cap(swiftDialogCh) != 1 {
|
||||
log.Fatal().Msg("swift dialog channel must have a buffer size of 1")
|
||||
}
|
||||
|
||||
watcher := &offlineWatcher{
|
||||
client: client,
|
||||
swiftDialogPath: swiftDialogPath,
|
||||
swiftDialogCh: swiftDialogCh,
|
||||
fileWatcher: fileWatcher,
|
||||
}
|
||||
|
||||
// start loop with 3-minute interval to ping server and show dialog if offline
|
||||
go func() {
|
||||
ticker := time.NewTicker(constant.MDMMigrationOfflineWatcherInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Info().Msg("starting watcher loop")
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Debug().Msg("stopping offline dialog loop")
|
||||
return
|
||||
case <-ticker.C:
|
||||
log.Debug().Msg("offline dialog, got tick")
|
||||
go watcher.ShowIfOffline(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return watcher
|
||||
}
|
||||
|
||||
// ShowIfOffline shows the offline dialog if the host is offline.
|
||||
// It returns true if the host is offline, and false otherwise.
|
||||
func (o *offlineWatcher) ShowIfOffline(ctx context.Context) bool {
|
||||
// try the dialog channel
|
||||
select {
|
||||
case o.swiftDialogCh <- struct{}{}:
|
||||
log.Debug().Msg("occupying dialog channel")
|
||||
default:
|
||||
log.Debug().Msg("dialog channel already occupied")
|
||||
return false
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// non-blocking release of dialog channel
|
||||
select {
|
||||
case <-o.swiftDialogCh:
|
||||
log.Debug().Msg("releasing dialog channel")
|
||||
default:
|
||||
// this shouldn't happen so log for debugging
|
||||
log.Debug().Msg("dialog channel already released")
|
||||
}
|
||||
}()
|
||||
|
||||
if !o.isUnmanaged() || !o.isOffline() {
|
||||
return false
|
||||
}
|
||||
|
||||
log.Info().Msg("showing offline dialog")
|
||||
if err := o.showSwiftDialogMDMMigrationOffline(ctx); err != nil {
|
||||
log.Error().Err(err).Msg("error showing offline dialog")
|
||||
} else {
|
||||
log.Info().Msg("done showing offline dialog")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (o *offlineWatcher) isUnmanaged() bool {
|
||||
mt, err := o.fileWatcher.GetMigrationType()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("getting migration type")
|
||||
}
|
||||
|
||||
if mt == "" {
|
||||
log.Debug().Msg("offline dialog, no migration type found, do nothing")
|
||||
return false
|
||||
}
|
||||
|
||||
log.Debug().Msgf("offline dialog, device is unmanaged, migration type %s", mt)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (o *offlineWatcher) isOffline() bool {
|
||||
err := o.client.Ping()
|
||||
if err == nil {
|
||||
log.Debug().Msg("offline dialog, ping ok, device is online")
|
||||
return false
|
||||
}
|
||||
if !isOfflineError(err) {
|
||||
log.Error().Err(err).Msg("offline dialog, error pinging server does not contain dial tcp or no such host, assuming device is online")
|
||||
return false
|
||||
}
|
||||
log.Debug().Err(err).Msg("offline dialog, error pinging server, assuming device is offline")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isOfflineError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
offlineMsgs := []string{"no such host", "dial tcp", "no route to host"}
|
||||
for _, msg := range offlineMsgs {
|
||||
if strings.Contains(err.Error(), msg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// // NOTE: We're starting with basic string matching and planning to improve error matching
|
||||
// // in future iterations. Here's some ideas for stuff to add in addition to strings.Contains:
|
||||
// if urlErr, ok := err.(*url.Error); ok {
|
||||
// log.Info().Msg("is url error")
|
||||
// if urlErr.Timeout() {
|
||||
// log.Info().Msg("is timeout")
|
||||
// return true
|
||||
// }
|
||||
// // Check for no such host error
|
||||
// if opErr, ok := urlErr.Err.(*net.OpError); ok {
|
||||
// log.Info().Msg("is net op error")
|
||||
// if dnsErr, ok := opErr.Err.(*net.DNSError); ok {
|
||||
// log.Info().Msg("is dns error")
|
||||
// if dnsErr.Err == "no such host" {
|
||||
// log.Info().Msg("is dns no such host")
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ShowDialogMDMMigrationOffline displays the dialog every time is called
|
||||
func (o *offlineWatcher) showSwiftDialogMDMMigrationOffline(ctx context.Context) error {
|
||||
props := MDMMigratorProps{
|
||||
DisableTakeover: true,
|
||||
}
|
||||
m := swiftDialogMDMMigrationOffline{
|
||||
baseDialog: newBaseDialog(o.swiftDialogPath),
|
||||
props: props,
|
||||
}
|
||||
|
||||
flags, err := m.getFlags()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting flags for offline dialog: %w", err)
|
||||
}
|
||||
|
||||
exitCodeCh, errCh := m.render(flags...)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Debug().Msg("dialog context canceled")
|
||||
m.baseDialog.Exit()
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
return fmt.Errorf("showing offline dialog: %w", err)
|
||||
case <-exitCodeCh:
|
||||
// there's only one button, so we don't need to check the exit code
|
||||
log.Info().Msg("closing offline dialog")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type swiftDialogMDMMigrationOffline struct {
|
||||
*baseDialog
|
||||
props MDMMigratorProps
|
||||
}
|
||||
|
||||
func (m *swiftDialogMDMMigrationOffline) render(flags ...string) (chan swiftDialogExitCode, chan error) {
|
||||
return m.baseDialog.render(flags...)
|
||||
}
|
||||
|
||||
func (m *swiftDialogMDMMigrationOffline) getFlags() ([]string, error) {
|
||||
tmpl := mdmMigrationTemplateOffline
|
||||
var message bytes.Buffer
|
||||
if err := tmpl.Execute(
|
||||
&message,
|
||||
nil,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("executing migration template: %w", err)
|
||||
}
|
||||
|
||||
// disable the built-in title and icon so we have full control over content
|
||||
title := "none"
|
||||
icon := "none"
|
||||
|
||||
flags := []string{
|
||||
"--height", "124",
|
||||
"--alignment", "center",
|
||||
"--title", title,
|
||||
"--icon", icon,
|
||||
// modal content
|
||||
"--message", message.String(),
|
||||
"--messagefont", "size=16",
|
||||
// main button
|
||||
"--button1text", "Close",
|
||||
}
|
||||
|
||||
if !m.props.DisableTakeover {
|
||||
flags = append(flags,
|
||||
"--blurscreen",
|
||||
"--ontop",
|
||||
)
|
||||
}
|
||||
|
||||
return flags, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ func TestWaitForUnenrollment(t *testing.T) {
|
|||
return true, "example.com", nil
|
||||
}
|
||||
|
||||
outErr := m.waitForUnenrollment()
|
||||
outErr := m.waitForUnenrollment(true)
|
||||
if c.wantErr {
|
||||
require.Error(t, outErr)
|
||||
} else {
|
||||
|
|
@ -71,7 +71,27 @@ func TestWaitForUnenrollment(t *testing.T) {
|
|||
return false, "", nil
|
||||
}
|
||||
|
||||
outErr := m.waitForUnenrollment()
|
||||
outErr := m.waitForUnenrollment(true)
|
||||
require.NoError(t, outErr)
|
||||
})
|
||||
|
||||
t.Run("only check file during ADE enrollment", func(t *testing.T) {
|
||||
var fileWasChecked bool
|
||||
m.testEnrollmentCheckFileFn = func() (bool, error) {
|
||||
fileWasChecked = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
m.testEnrollmentCheckStatusFn = func() (bool, string, error) {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
err := m.waitForUnenrollment(false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, fileWasChecked)
|
||||
|
||||
err = m.waitForUnenrollment(true)
|
||||
require.NoError(t, err)
|
||||
require.True(t, fileWasChecked)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,32 @@
|
|||
|
||||
package useraction
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler) MDMMigrator {
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/migration"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
)
|
||||
|
||||
func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler, mrw *migration.ReadWriter, fleetURL string, showCh chan struct{}) MDMMigrator {
|
||||
return &NoopMDMMigrator{}
|
||||
}
|
||||
|
||||
func StartMDMMigrationOfflineWatcher(ctx context.Context, client *service.DeviceClient, swiftDialogPath string, swiftDialogCh chan struct{}, fileWatcher migration.FileWatcher) MDMOfflineWatcher {
|
||||
return &NoopOfflineWatcher{}
|
||||
}
|
||||
|
||||
type NoopOfflineWatcher struct{}
|
||||
|
||||
func (o *NoopOfflineWatcher) ShowIfOffline(ctx context.Context) bool { return false }
|
||||
|
||||
type NoopMDMMigrator struct{}
|
||||
|
||||
func (m *NoopMDMMigrator) CanRun() bool { return false }
|
||||
func (m *NoopMDMMigrator) SetProps(MDMMigratorProps) {}
|
||||
func (m *NoopMDMMigrator) Show() error { return nil }
|
||||
func (m *NoopMDMMigrator) ShowInterval() error { return nil }
|
||||
func (m *NoopMDMMigrator) Exit() {}
|
||||
func (m *NoopMDMMigrator) CanRun() bool { return false }
|
||||
func (m *NoopMDMMigrator) SetProps(MDMMigratorProps) {}
|
||||
func (m *NoopMDMMigrator) Show() error { return nil }
|
||||
func (m *NoopMDMMigrator) ShowInterval() error { return nil }
|
||||
func (m *NoopMDMMigrator) Exit() {}
|
||||
func (m *NoopMDMMigrator) MigrationInProgress() (string, error) { return "", nil }
|
||||
func (m *NoopMDMMigrator) MarkMigrationCompleted() error { return nil }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
)
|
||||
|
||||
type HostStatus string
|
||||
|
|
@ -1222,3 +1224,46 @@ func IsEligibleForDEPMigration(host *Host, mdmInfo *HostMDM, isConnectedToFleetM
|
|||
// the checkout message from the host.
|
||||
(!isConnectedToFleetMDM || mdmInfo.Name != WellKnownMDMFleet)
|
||||
}
|
||||
|
||||
var macOSADEMigrationOnlyLastVersion = semver.MustParse("14")
|
||||
|
||||
// IsEligibleForManualMigration returns true if the host is manually enrolled into a 3rd party MDM
|
||||
// and is able to migrate to Fleet.
|
||||
func IsEligibleForManualMigration(host *Host, mdmInfo *HostMDM, isConnectedToFleetMDM bool) (bool, error) {
|
||||
goodVersion, err := IsMacOSMajorVersionOK(host)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("checking macOS version for manual migration eligibility: %w", err)
|
||||
}
|
||||
|
||||
return goodVersion &&
|
||||
host.IsOsqueryEnrolled() &&
|
||||
!host.IsDEPAssignedToFleet() &&
|
||||
mdmInfo != nil &&
|
||||
!mdmInfo.InstalledFromDep &&
|
||||
!mdmInfo.HasJSONProfileAssigned() &&
|
||||
mdmInfo.Enrolled &&
|
||||
(!isConnectedToFleetMDM || mdmInfo.Name != WellKnownMDMFleet), nil
|
||||
}
|
||||
|
||||
func IsMacOSMajorVersionOK(host *Host) (bool, error) {
|
||||
if host == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(host.OSVersion, " ")
|
||||
|
||||
if len(parts) < 2 || parts[0] != "macOS" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
version, err := semver.NewVersion(parts[1])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parsing macOS version \"%s\": %w", parts[1], err)
|
||||
}
|
||||
|
||||
if version.GreaterThan(macOSADEMigrationOnlyLastVersion) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,6 +222,8 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
|
|||
depProfileResponse DEPAssignProfileResponseStatus
|
||||
enrolledInThirdPartyMDM bool
|
||||
expected bool
|
||||
expectedManual bool
|
||||
hostOS string
|
||||
}{
|
||||
{
|
||||
name: "Eligible for DEP migration",
|
||||
|
|
@ -230,6 +232,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
|
|||
depProfileResponse: DEPAssignProfileResponseSuccess,
|
||||
enrolledInThirdPartyMDM: true,
|
||||
expected: true,
|
||||
expectedManual: false,
|
||||
},
|
||||
{
|
||||
name: "Not eligible - osqueryHostID nil",
|
||||
|
|
@ -238,6 +241,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
|
|||
depProfileResponse: DEPAssignProfileResponseSuccess,
|
||||
enrolledInThirdPartyMDM: true,
|
||||
expected: false,
|
||||
expectedManual: false,
|
||||
},
|
||||
{
|
||||
name: "Not eligible - not DEP assigned to Fleet",
|
||||
|
|
@ -246,6 +250,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
|
|||
depProfileResponse: DEPAssignProfileResponseSuccess,
|
||||
enrolledInThirdPartyMDM: true,
|
||||
expected: false,
|
||||
expectedManual: false,
|
||||
},
|
||||
{
|
||||
name: "Not eligible - not enrolled in third-party MDM",
|
||||
|
|
@ -254,6 +259,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
|
|||
depProfileResponse: DEPAssignProfileResponseSuccess,
|
||||
enrolledInThirdPartyMDM: false,
|
||||
expected: false,
|
||||
expectedManual: false,
|
||||
},
|
||||
{
|
||||
name: "Not eligible - not DEP assigned and DEP profile failed",
|
||||
|
|
@ -262,6 +268,8 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
|
|||
depProfileResponse: DEPAssignProfileResponseNotAccessible,
|
||||
enrolledInThirdPartyMDM: true,
|
||||
expected: false,
|
||||
expectedManual: true,
|
||||
hostOS: "macOS 14.5",
|
||||
},
|
||||
{
|
||||
name: "Not eligible - DEP assigned and DEP profile failed",
|
||||
|
|
@ -270,6 +278,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
|
|||
depProfileResponse: DEPAssignProfileResponseFailed,
|
||||
enrolledInThirdPartyMDM: true,
|
||||
expected: false,
|
||||
expectedManual: false,
|
||||
},
|
||||
{
|
||||
name: "Not eligible - DEP assigned but not response yet",
|
||||
|
|
@ -278,6 +287,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
|
|||
depProfileResponse: "",
|
||||
enrolledInThirdPartyMDM: true,
|
||||
expected: false,
|
||||
expectedManual: false,
|
||||
},
|
||||
{
|
||||
name: "Not eligible - DEP assigned but not accessible",
|
||||
|
|
@ -286,6 +296,27 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
|
|||
depProfileResponse: DEPAssignProfileResponseNotAccessible,
|
||||
enrolledInThirdPartyMDM: true,
|
||||
expected: false,
|
||||
expectedManual: false,
|
||||
},
|
||||
{
|
||||
name: "Manual migration eligible - enrolled in 3rd party, but not DEP",
|
||||
osqueryHostID: ptr.String("some-id"),
|
||||
depAssignedToFleet: ptr.Bool(false),
|
||||
depProfileResponse: "",
|
||||
enrolledInThirdPartyMDM: true,
|
||||
expected: false,
|
||||
expectedManual: true,
|
||||
hostOS: "macOS 14.5",
|
||||
},
|
||||
{
|
||||
name: "Manual migration ineligible - enrolled in 3rd party, not DEP, but OS version too low",
|
||||
osqueryHostID: ptr.String("some-id"),
|
||||
depAssignedToFleet: ptr.Bool(false),
|
||||
depProfileResponse: "",
|
||||
enrolledInThirdPartyMDM: true,
|
||||
expected: false,
|
||||
expectedManual: false,
|
||||
hostOS: "macOS 13.9",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -294,6 +325,7 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
|
|||
host := &Host{
|
||||
OsqueryHostID: tc.osqueryHostID,
|
||||
DEPAssignedToFleet: tc.depAssignedToFleet,
|
||||
OSVersion: tc.hostOS,
|
||||
}
|
||||
|
||||
mdmInfo := &HostMDM{
|
||||
|
|
@ -303,6 +335,9 @@ func TestIsEligibleForDEPMigration(t *testing.T) {
|
|||
}
|
||||
|
||||
require.Equal(t, tc.expected, IsEligibleForDEPMigration(host, mdmInfo, false))
|
||||
manual, err := IsEligibleForManualMigration(host, mdmInfo, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedManual, manual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1019,7 +1019,6 @@ func (s *integrationMDMTestSuite) createAppleMobileHostThenEnrollMDM(platform st
|
|||
require.NoError(t, err)
|
||||
|
||||
return fleetHost, mdmDevice
|
||||
|
||||
}
|
||||
|
||||
func createWindowsHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testing.T) (*fleet.Host, *mdmtest.TestWindowsMDMClient) {
|
||||
|
|
@ -3432,11 +3431,6 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhook() {
|
|||
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusBadRequest)
|
||||
require.False(t, webhookCalled)
|
||||
|
||||
// host is not DEP so migration is not allowed
|
||||
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), h.ID, !isServer, enrolled, mdmURL, !installedFromDEP, mdmName, ""))
|
||||
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusBadRequest)
|
||||
require.False(t, webhookCalled)
|
||||
|
||||
// host is not enrolled to MDM so migration is not allowed
|
||||
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), h.ID, !isServer, !enrolled, mdmURL, installedFromDEP, mdmName, ""))
|
||||
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusBadRequest)
|
||||
|
|
@ -3524,6 +3518,16 @@ func (s *integrationMDMTestSuite) TestMigrateMDMDeviceWebhook() {
|
|||
require.True(t, webhookCalled)
|
||||
webhookCalled = false
|
||||
|
||||
// host is manually enrolled, which is allowed
|
||||
h.RefetchCriticalQueriesUntil = ptr.Time(time.Now().Add(-1 * time.Minute))
|
||||
err = s.ds.UpdateHost(context.Background(), h)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, s.ds.SetOrUpdateMDMData(context.Background(), h.ID, !isServer, enrolled, mdmURL, !installedFromDEP, mdmName, ""))
|
||||
s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "good-token"), nil, http.StatusNoContent)
|
||||
require.True(t, webhookCalled)
|
||||
webhookCalled = false
|
||||
|
||||
// the refetch critical queries timestamp has been updated to the future
|
||||
h, err = s.ds.Host(context.Background(), h.ID)
|
||||
require.NoError(t, err)
|
||||
|
|
@ -5500,6 +5504,37 @@ func (s *integrationMDMTestSuite) TestMDMMigration() {
|
|||
require.True(t, orbitConfigResp.Notifications.NeedsMDMMigration)
|
||||
require.False(t, orbitConfigResp.Notifications.RenewEnrollmentProfile)
|
||||
|
||||
// simulate a device that is manually enrolled to 3rd party
|
||||
err = s.ds.SetOrUpdateMDMData(
|
||||
ctx,
|
||||
host.ID,
|
||||
false,
|
||||
true,
|
||||
"https://simplemdm.com",
|
||||
false,
|
||||
fleet.WellKnownMDMSimpleMDM,
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
getDesktopResp = fleetDesktopResponse{}
|
||||
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/desktop", nil, http.StatusOK)
|
||||
require.NoError(t, json.NewDecoder(res.Body).Decode(&getDesktopResp))
|
||||
require.NoError(t, res.Body.Close())
|
||||
require.NoError(t, getDesktopResp.Err)
|
||||
require.Zero(t, *getDesktopResp.FailingPolicies)
|
||||
require.True(t, getDesktopResp.Notifications.NeedsMDMMigration)
|
||||
require.False(t, getDesktopResp.Notifications.RenewEnrollmentProfile)
|
||||
require.Equal(t, acResp.OrgInfo.OrgLogoURL, getDesktopResp.Config.OrgInfo.OrgLogoURL)
|
||||
require.Equal(t, acResp.OrgInfo.OrgLogoURLLightBackground, getDesktopResp.Config.OrgInfo.OrgLogoURLLightBackground)
|
||||
require.Equal(t, acResp.OrgInfo.ContactURL, getDesktopResp.Config.OrgInfo.ContactURL)
|
||||
require.Equal(t, acResp.OrgInfo.OrgName, getDesktopResp.Config.OrgInfo.OrgName)
|
||||
require.Equal(t, acResp.MDM.MacOSMigration.Mode, getDesktopResp.Config.MDM.MacOSMigration.Mode)
|
||||
|
||||
orbitConfigResp = orbitGetConfigResponse{}
|
||||
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *host.OrbitNodeKey)), http.StatusOK, &orbitConfigResp)
|
||||
require.True(t, orbitConfigResp.Notifications.NeedsMDMMigration)
|
||||
require.False(t, orbitConfigResp.Notifications.RenewEnrollmentProfile)
|
||||
|
||||
// clean up nano tables
|
||||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(context.Background(), `
|
||||
|
|
@ -9739,7 +9774,6 @@ func (s *integrationMDMTestSuite) TestEnrollAfterDEPSyncIOSIPadOS() {
|
|||
var listCmdResp listMDMAppleCommandsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)
|
||||
require.Empty(t, listCmdResp.Results)
|
||||
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
||||
|
|
@ -9933,7 +9967,6 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
|
|||
var listCmdResp listMDMAppleCommandsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)
|
||||
require.Len(t, listCmdResp.Results, commandsSent)
|
||||
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestVPPApps() {
|
||||
|
|
@ -10396,15 +10429,18 @@ func (s *integrationMDMTestSuite) TestVPPApps() {
|
|||
deviceToken string
|
||||
}{
|
||||
"iOS app install": {installHost: iOSHost, titleID: iOSTitleID, mdmClient: iOSMdmClient, app: iOSApp, hostCount: 1},
|
||||
"iPadOS app install": {installHost: iPadOSHost, titleID: iPadOSTitleID, mdmClient: iPadOSMdmClient, app: iPadOSApp,
|
||||
extraAvailable: 1, hostCount: 1},
|
||||
"macOS app install": {installHost: selfServiceHost, titleID: macOSTitleID, mdmClient: selfServiceDevice, app: macOSApp,
|
||||
hostCount: 2, deviceToken: selfServiceToken},
|
||||
"iPadOS app install": {
|
||||
installHost: iPadOSHost, titleID: iPadOSTitleID, mdmClient: iPadOSMdmClient, app: iPadOSApp,
|
||||
extraAvailable: 1, hostCount: 1,
|
||||
},
|
||||
"macOS app install": {
|
||||
installHost: selfServiceHost, titleID: macOSTitleID, mdmClient: selfServiceDevice, app: macOSApp,
|
||||
hostCount: 2, deviceToken: selfServiceToken,
|
||||
},
|
||||
}
|
||||
|
||||
for name, install := range installs {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
|
||||
installHost := install.installHost
|
||||
titleID := install.titleID
|
||||
mdmClient := install.mdmClient
|
||||
|
|
|
|||
|
|
@ -203,8 +203,13 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro
|
|||
notifs.RenewEnrollmentProfile = true
|
||||
}
|
||||
|
||||
manualMigrationEligible, err := fleet.IsEligibleForManualMigration(host, mdmInfo, isConnectedToFleetMDM)
|
||||
if err != nil {
|
||||
return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "checking manual migration eligibility")
|
||||
}
|
||||
|
||||
if appConfig.MDM.MacOSMigration.Enable &&
|
||||
fleet.IsEligibleForDEPMigration(host, mdmInfo, isConnectedToFleetMDM) {
|
||||
(fleet.IsEligibleForDEPMigration(host, mdmInfo, isConnectedToFleetMDM) || manualMigrationEligible) {
|
||||
notifs.NeedsMDMMigration = true
|
||||
}
|
||||
|
||||
|
|
|
|||
19
tools/mdm/migration/micromdm/README.md
Normal file
19
tools/mdm/migration/micromdm/README.md
Normal 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
tools/mdm/migration/micromdm/main.go
Normal file
149
tools/mdm/migration/micromdm/main.go
Normal 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 := µ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
|
||||
}
|
||||
BIN
website/assets/images/permanent/mdm-ade-migration-1024x500.png
vendored
Normal file
BIN
website/assets/images/permanent/mdm-ade-migration-1024x500.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
BIN
website/assets/images/permanent/mdm-manual-migration-1024x500.png
vendored
Normal file
BIN
website/assets/images/permanent/mdm-manual-migration-1024x500.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 312 KiB |
BIN
website/assets/images/permanent/mdm-migration-pre-sonoma-unenroll-1024x500.png
vendored
Normal file
BIN
website/assets/images/permanent/mdm-migration-pre-sonoma-unenroll-1024x500.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
Loading…
Reference in a new issue