add migration support to FD and orbit (#11741)

https://github.com/fleetdm/fleet/issues/11534
This commit is contained in:
Roberto Dip 2023-05-18 14:21:54 -03:00 committed by GitHub
parent 9a7f3cf674
commit 8829b84a63
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 763 additions and 43 deletions

View file

@ -374,8 +374,8 @@ endif
pkgutil --expand $(TMP_DIR)/swiftDialog-$(version).pkg $(TMP_DIR)/swiftDialog_pkg_expanded
mkdir -p $(TMP_DIR)/swiftDialog_pkg_payload_expanded
tar xvf $(TMP_DIR)/swiftDialog_pkg_expanded/Payload --directory $(TMP_DIR)/swiftDialog_pkg_payload_expanded
$(TMP_DIR)/swiftDialog_pkg_payload_expanded/usr/local/bin/dialog --version
tar czf $(out-path)/swiftDialog.app.tar.gz -C $(TMP_DIR)/swiftDialog_pkg_payload_expanded/usr/local bin
$(TMP_DIR)/swiftDialog_pkg_payload_expanded/Library/Application\ Support/Dialog/Dialog.app/Contents/MacOS/Dialog --version
tar czf $(out-path)/swiftDialog.app.tar.gz -C $(TMP_DIR)/swiftDialog_pkg_payload_expanded/Library/Application\ Support/Dialog/ Dialog.app
rm -rf $(TMP_DIR)
# Build and generate desktop.app.tar.gz bundle.

View file

@ -92,18 +92,30 @@ func (svc *Service) GetFleetDesktopSummary(ctx context.Context) (fleet.DesktopSu
}
sum.FailingPolicies = &r
appCfg, err := svc.ds.AppConfig(ctx)
appCfg, err := svc.AppConfigObfuscated(ctx)
if err != nil {
return sum, ctxerr.Wrap(ctx, err, "retrieving app config")
}
if appCfg.MDM.EnabledAndConfigured &&
appCfg.MDM.MacOSMigration.Enable &&
host.IsOsqueryEnrolled() &&
host.MDMInfo.IsDEPCapable() &&
host.MDMInfo.IsEnrolledInThirdPartyMDM() {
sum.Notifications.NeedsMDMMigration = true
if appCfg.MDM.EnabledAndConfigured && appCfg.MDM.MacOSMigration.Enable {
if host.MDMInfo.IsPendingDEPFleetEnrollment() {
sum.Notifications.RenewEnrollmentProfile = true
}
if host.IsOsqueryEnrolled() &&
host.MDMInfo.IsDEPCapable() &&
host.MDMInfo.IsEnrolledInThirdPartyMDM() {
sum.Notifications.NeedsMDMMigration = true
}
}
// organization information
sum.Config.OrgInfo.OrgName = appCfg.OrgInfo.OrgName
sum.Config.OrgInfo.OrgLogoURL = appCfg.OrgInfo.OrgLogoURL
sum.Config.OrgInfo.ContactURL = appCfg.OrgInfo.ContactURL
// mdm information
sum.Config.MDM.MacOSMigration.Mode = appCfg.MDM.MacOSMigration.Mode
return sum, nil
}

View file

@ -0,0 +1 @@
* MDM: added support to enhance the DEP migration flow in macOS.

View file

@ -11,8 +11,11 @@ import (
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
"github.com/fleetdm/fleet/v4/orbit/pkg/token"
"github.com/fleetdm/fleet/v4/orbit/pkg/update"
"github.com/fleetdm/fleet/v4/orbit/pkg/useraction"
"github.com/fleetdm/fleet/v4/pkg/certificate"
"github.com/fleetdm/fleet/v4/pkg/open"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/getlantern/systray"
"github.com/oklog/run"
@ -82,10 +85,16 @@ func main() {
if fleetClientCrt != nil {
log.Info().Msg("Using TLS client certificate and key to authenticate to the server.")
}
tufUpdateRoot := os.Getenv("FLEET_DESKTOP_TUF_UPDATE_ROOT")
if tufUpdateRoot != "" {
log.Info().Msgf("got a TUF update root: %s", tufUpdateRoot)
}
// Setting up working runners such as signalHandler runner
go setupRunners()
var mdmMigrator useraction.MDMMigrator
onReady := func() {
log.Info().Msg("ready")
@ -220,6 +229,22 @@ 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,
},
)
}
// poll the server to check the policy status of the host and update the
// tray icon accordingly
go func() {
@ -271,9 +296,32 @@ func main() {
}
myDeviceItem.Enable()
if sum.Notifications.NeedsMDMMigration {
if runtime.GOOS == "darwin" &&
(sum.Notifications.NeedsMDMMigration || sum.Notifications.RenewEnrollmentProfile) &&
mdmMigrator.CanRun() {
// if the device is unmanaged or we're
// in force mode and the device needs
// migration, enable aggressive mode.
aggressive := sum.Notifications.RenewEnrollmentProfile ||
(sum.Notifications.NeedsMDMMigration && sum.Config.MDM.MacOSMigration.Mode == fleet.MacOSMigrationModeForced)
// update org info in case it changed
mdmMigrator.SetProps(useraction.MDMMigratorProps{
OrgInfo: sum.Config.OrgInfo,
Aggressive: aggressive,
})
// enable tray items
migrateMDMItem.Enable()
migrateMDMItem.Show()
if aggressive {
log.Info().Msg("MDM migration is in aggressive mode, automatically showing dialog")
if err := mdmMigrator.ShowInterval(); err != nil {
log.Error().Err(err).Msg("showing MDM migration dialog at interval")
}
}
} else {
migrateMDMItem.Disable()
migrateMDMItem.Hide()
@ -296,25 +344,45 @@ func main() {
log.Error().Err(err).Str("url", openURL).Msg("open browser transparency")
}
case <-migrateMDMItem.ClickedCh:
// TODO: we should be signaling Orbit to show
// swiftDialog instead. To be done in a
// follow up.
openURL := client.BrowserDeviceURL(tokenReader.GetCached())
if err := open.Browser(openURL); err != nil {
log.Error().Err(err).Msg("open browser to migrate MDM")
if err := mdmMigrator.Show(); err != nil {
log.Error().Err(err).Msg("showing MDM migration dialog on user action")
}
}
}
}()
}
onExit := func() {
if mdmMigrator != nil {
mdmMigrator.Exit()
}
log.Info().Msg("exit")
}
systray.Run(onReady, onExit)
}
type mdmMigrationHandler struct {
client *service.DeviceClient
tokenReader *token.Reader
}
func (m *mdmMigrationHandler) NotifyRemote() error {
log.Debug().Msg("sending request to trigger mdm migration webhook")
if err := m.client.MigrateMDM(m.tokenReader.GetCached()); err != nil {
log.Error().Err(err).Msg("triggering migration webhook")
return fmt.Errorf("on migration start: %w", err)
}
log.Debug().Msg("successfully sent request to trigger mdm migration webhook")
return nil
}
func (m *mdmMigrationHandler) ShowInstructions() {
openURL := m.client.BrowserDeviceURL(m.tokenReader.GetCached())
if err := open.Browser(openURL); err != nil {
log.Error().Err(err).Str("url", openURL).Msg("open browser")
}
}
// setupLogs configures our logging system to write logs to rolling files and
// stderr, if for some reason we can't write a log file the logs are still
// printed to stderr.

View file

@ -624,6 +624,7 @@ func main() {
})
configFetcher = update.ApplyDiskEncryptionRunnerMiddleware(configFetcher)
configFetcher = update.ApplySwiftDialogDownloaderMiddleware(configFetcher, updateRunner)
}
const orbitFlagsUpdateInterval = 30 * time.Second
@ -858,6 +859,7 @@ func main() {
rawClientCrt,
rawClientKey,
c.String("fleet-desktop-alternative-browser-host"),
opt.RootDirectory,
)
g.Add(desktopRunner.actor())
}
@ -895,6 +897,7 @@ func registerExtensionRunner(g *run.Group, extSockPath string, opts ...table.Opt
type desktopRunner struct {
// desktopPath is the path to the desktop executable.
desktopPath string
updateRoot string
// fleetURL is the URL of the Fleet server.
fleetURL string
// trw is the Fleet Desktop token reader and writer (implements token rotation).
@ -924,9 +927,11 @@ func newDesktopRunner(
trw *token.ReadWriter,
fleetClientCrt []byte, fleetClientKey []byte,
fleetAlternativeBrowserHost string,
updateRoot string,
) *desktopRunner {
return &desktopRunner{
desktopPath: desktopPath,
updateRoot: updateRoot,
fleetURL: fleetURL,
trw: trw,
fleetRootCA: fleetRootCA,
@ -985,6 +990,7 @@ func (d *desktopRunner) execute() error {
execuser.WithEnv("FLEET_DESKTOP_FLEET_TLS_CLIENT_KEY", string(d.fleetClientKey)),
execuser.WithEnv("FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST", d.fleetAlternativeBrowserHost),
execuser.WithEnv("FLEET_DESKTOP_TUF_UPDATE_ROOT", d.updateRoot),
}
if d.fleetRootCA != "" {
opts = append(opts, execuser.WithEnv("FLEET_DESKTOP_FLEET_ROOT_CA", d.fleetRootCA))

View file

@ -89,10 +89,10 @@ var (
ExtractedExecSubPath: []string{"Nudge.app", "Contents", "MacOS", "Nudge"},
}
SwiftDialogTarget = TargetInfo{
SwiftDialogMacOSTarget = TargetInfo{
Platform: "macos",
Channel: "stable",
TargetFile: "swiftDialog.app.tar.gz",
ExtractedExecSubPath: []string{"bin", "dialog"},
ExtractedExecSubPath: []string{"Dialog.app", "Contents", "MacOS", "Dialog"},
}
)

View file

@ -0,0 +1,60 @@
package update
import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/rs/zerolog/log"
)
type SwiftDialogDownloader struct {
Fetcher OrbitConfigFetcher
UpdateRunner *Runner
}
type SwiftDialogDownloaderOptions struct {
// UpdateRunner is the wrapped Runner where swiftDialog will be set as a target. It is responsible for
// actually ensuring that swiftDialog is installed and updated via the designated TUF server.
UpdateRunner *Runner
}
func ApplySwiftDialogDownloaderMiddleware(
f OrbitConfigFetcher,
runner *Runner,
) OrbitConfigFetcher {
return &SwiftDialogDownloader{Fetcher: f, UpdateRunner: runner}
}
func (s *SwiftDialogDownloader) GetConfig() (*fleet.OrbitConfig, error) {
log.Debug().Msg("running swiftDialog installer middleware")
cfg, err := s.Fetcher.GetConfig()
if err != nil {
return nil, err
}
if cfg == nil {
log.Debug().Msg("SwiftDialogDownloader received nil config")
return nil, nil
}
if !cfg.Notifications.NeedsMDMMigration && !cfg.Notifications.RenewEnrollmentProfile {
return cfg, nil
}
updaterHasTarget := s.UpdateRunner.HasRunnerOptTarget("swiftDialog")
runnerHasLocalHash := s.UpdateRunner.HasLocalHash("swiftDialog")
if !updaterHasTarget || !runnerHasLocalHash {
log.Info().Msg("refreshing the update runner config with swiftDialog targets and hashes")
log.Debug().Msgf("updater has target: %t, runner has local hash: %t", updaterHasTarget, runnerHasLocalHash)
s.UpdateRunner.AddRunnerOptTarget("swiftDialog")
s.UpdateRunner.updater.SetTargetInfo("swiftDialog", SwiftDialogMacOSTarget)
// we don't want to keep swiftDialog as a target if we failed to update the
// cached hashes in the runner.
if err := s.UpdateRunner.StoreLocalHash("swiftDialog"); err != nil {
log.Debug().Msgf("removing swiftDialog from target options, error updating local hashes: %e", err)
s.UpdateRunner.RemoveRunnerOptTarget("swiftDialog")
s.UpdateRunner.updater.RemoveTargetInfo("swiftDialog")
return cfg, err
}
}
return cfg, nil
}

View file

@ -229,6 +229,22 @@ func (u *Updater) DirLocalPath(target string) (string, error) {
return localTarget.DirPath, nil
}
// LocalTargetPaths returns (path, execPath, dirPath) to be used by this
// package for a given TargetInfo target.
func LocalTargetPaths(rootDir string, targetName string, t TargetInfo) (path, execPath, dirPath string) {
path = filepath.Join(
rootDir, binDir, targetName, t.Platform, t.Channel, t.TargetFile,
)
execPath = path
if strings.HasSuffix(path, ".tar.gz") {
execPath = filepath.Join(append([]string{filepath.Dir(path)}, t.ExtractedExecSubPath...)...)
dirPath = filepath.Join(filepath.Dir(path), t.ExtractedExecSubPath[0])
}
return path, execPath, dirPath
}
// LocalTarget holds local paths of a target.
//
// E.g., for a osqueryd target:
@ -263,16 +279,12 @@ func (u *Updater) localTarget(target string) (*LocalTarget, error) {
if !ok {
return nil, fmt.Errorf("unknown target: %s", target)
}
path, execPath, dirPath := LocalTargetPaths(u.opt.RootDirectory, target, t)
lt := &LocalTarget{
Info: t,
Path: filepath.Join(
u.opt.RootDirectory, binDir, target, t.Platform, t.Channel, t.TargetFile,
),
}
lt.ExecPath = lt.Path
if strings.HasSuffix(lt.Path, ".tar.gz") {
lt.ExecPath = filepath.Join(append([]string{filepath.Dir(lt.Path)}, t.ExtractedExecSubPath...)...)
lt.DirPath = filepath.Join(filepath.Dir(lt.Path), lt.Info.ExtractedExecSubPath[0])
Info: t,
Path: path,
ExecPath: execPath,
DirPath: dirPath,
}
return lt, nil
}

View file

@ -63,3 +63,44 @@ func TestMakeRepoPath(t *testing.T) {
})
}
}
func TestLocalTargetPaths(t *testing.T) {
testCases := []struct {
info TargetInfo
wantPath string
wantExecPath string
wantDirPath string
}{
{
DesktopWindowsTarget,
"root/bin/target/windows/stable/fleet-desktop.exe",
"root/bin/target/windows/stable/fleet-desktop.exe",
"",
},
{
NudgeMacOSTarget,
"root/bin/target/macos/stable/nudge.app.tar.gz",
"root/bin/target/macos/stable/Nudge.app/Contents/MacOS/Nudge",
"root/bin/target/macos/stable/Nudge.app",
},
{
DesktopLinuxTarget,
"root/bin/target/linux/stable/desktop.tar.gz",
"root/bin/target/linux/stable/fleet-desktop/fleet-desktop",
"root/bin/target/linux/stable/fleet-desktop",
},
{
SwiftDialogMacOSTarget,
"root/bin/target/macos/stable/swiftDialog.app.tar.gz",
"root/bin/target/macos/stable/Dialog.app/Contents/MacOS/Dialog",
"root/bin/target/macos/stable/Dialog.app",
},
}
for _, tt := range testCases {
path, execPath, dirPath := LocalTargetPaths("root", "target", tt.info)
require.Equal(t, tt.wantPath, path)
require.Equal(t, tt.wantExecPath, execPath)
require.Equal(t, tt.wantDirPath, dirPath)
}
}

View file

@ -0,0 +1,35 @@
package useraction
import "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.
type MDMMigrator interface {
// CanRun indicates if the migrator is able to run, for example, for macOS it
// checks if the swiftDialog executable is present.
CanRun() bool
// SetProps sets/updates the props.
SetProps(MDMMigratorProps)
// Show displays the dialog if there's no other dialog running.
Show() error
// ShowInterval is used to display dialogs at an interval. It displays
// the dialog if there's no other dialog running and a given interval
// (defined by the migrator itself) has passed since the last time the
// dialog was shown.
ShowInterval() error
// Exit tries to stop any processes started by the migrator.
Exit()
}
// MDMMigratorProps are props required to display the dialog. It's akin to the
// concept of props in UI frameworks like React.
type MDMMigratorProps struct {
OrgInfo fleet.DesktopOrgInfo
Aggressive bool
}
// MDMMigratorHandler handles remote actions/callbacks that the migrator calls.
type MDMMigratorHandler interface {
NotifyRemote() error
ShowInstructions()
}

View file

@ -0,0 +1,294 @@
//go:build darwin
package useraction
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"sync"
"text/template"
"time"
"github.com/rs/zerolog/log"
)
type swiftDialogExitCode int
const (
primaryBtnExitCode = 0
errorExitCode = 1
secondaryBtnExitCode = 2
infoBtnExitCode = 3
timeoutExitCode = 4
userQuitExitCode = 10
unknownExitCode = 99
)
var mdmMigrationTemplate = template.Must(template.New("mdmMigrationTemplate").Parse(`
## Migrate to Fleet
To begin, click "Start." Your default browser will open your My Device page.
{{ if .Aggressive }}You {{ else }} Once you start, you {{ end -}} will see this dialog every 15 minutes until you click "Turn on MDM" and complete the instructions.
\\![Image showing the Fleet UI](https://fleetdm.com/images/permanent/mdm-migration-screenshot-768x180@2x.jpg)
Unsure? Contact {{ .OrgInfo.OrgName }} IT [here]({{ .OrgInfo.ContactURL }}).
`))
var errorTemplate = template.Must(template.New("").Parse(`
### Something's gone wrong.
Please contact your IT admin [here]({{ .ContactURL }}).
`))
// baseDialog implements the basic building blocks to render dialogs using
// swiftDialog.
type baseDialog struct {
path string
interruptCh chan struct{}
}
func newBaseDialog(path string) *baseDialog {
return &baseDialog{path: path, interruptCh: make(chan struct{})}
}
func (b *baseDialog) CanRun() bool {
if _, err := os.Stat(b.path); err != nil {
return false
}
return true
}
// Exit sends the interrupt signal to try and stop the current swiftDialog
// instance.
func (b *baseDialog) Exit() {
b.interruptCh <- struct{}{}
log.Info().Msg("dialog exit message sent")
}
// render is a general-purpose render method that receives the flags used to
// display swiftDialog, and starts an asyncronous routine to display the dialog
// without blocking.
//
// The first returned channel sends the exit code returned by swiftDialog, and
// the second channel is used to send errors.
func (b *baseDialog) render(flags ...string) (chan swiftDialogExitCode, chan error) {
exitCodeCh := make(chan swiftDialogExitCode, 1)
errCh := make(chan error, 1)
go func() {
cmd := exec.Command(b.path, flags...) //nolint:gosec
done := make(chan error)
stopInterruptCh := make(chan struct{})
defer close(stopInterruptCh)
if err := cmd.Start(); err != nil {
errCh <- err
return
}
go func() { done <- cmd.Wait() }()
go func() {
select {
case <-b.interruptCh:
if err := cmd.Process.Signal(os.Interrupt); err != nil {
log.Error().Err(err).Msg("sending interrupt signal to swiftDialog process")
if err := cmd.Process.Kill(); err != nil {
log.Error().Err(err).Msg("killing swiftDialog process")
errCh <- errors.New("failed to stop/kill swiftDialog process")
}
}
case <-stopInterruptCh:
return
}
}()
if err := <-done; err != nil {
// non-zero exit codes
if exitError, ok := err.(*exec.ExitError); ok {
ec := exitError.ExitCode()
switch ec {
case errorExitCode:
exitCodeCh <- errorExitCode
case secondaryBtnExitCode, infoBtnExitCode, timeoutExitCode:
exitCodeCh <- swiftDialogExitCode(ec)
default:
errCh <- fmt.Errorf("unknown exit code showing dialog: %w", exitError)
}
} else {
errCh <- fmt.Errorf("running swiftDialog: %w", err)
}
} else {
exitCodeCh <- 0
}
}()
return exitCodeCh, errCh
}
func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler) MDMMigrator {
return &swiftDialogMDMMigrator{
handler: handler,
baseDialog: newBaseDialog(path),
frequency: frequency,
}
}
// swiftDialogMDMMigrator implements MDMMigrator for macOS using swiftDialog as
// the underlying mechanism for user action.
type swiftDialogMDMMigrator struct {
*baseDialog
props MDMMigratorProps
frequency time.Duration
handler MDMMigratorHandler
// ensures only one dialog is open at a time, protects access to
// lastShown
showMu sync.Mutex
lastShown time.Time
// ensures only one dialog is open at a given interval
intervalMu sync.Mutex
}
func (m *swiftDialogMDMMigrator) render(message string, flags ...string) (chan swiftDialogExitCode, chan error) {
icon := m.props.OrgInfo.OrgLogoURL
if icon == "" {
icon = "https://fleetdm.com/images/permanent/fleet-mark-color-40x40@4x.png"
}
flags = append([]string{
// disable the built-in title so we have full control over the
// content
"--title", "none",
// top icon
"--icon", icon,
"--iconsize", "80",
"--centreicon",
// modal content
"--message", message,
"--messagefont", "size=16",
"--alignment", "center",
"--ontop",
}, flags...)
return m.baseDialog.render(flags...)
}
func (m *swiftDialogMDMMigrator) renderLoadingSpinner() (chan swiftDialogExitCode, chan error) {
return m.render("## Migrate to Fleet\n\nCommunicating with MDM server...",
"--button1text", "Start",
"--button1disabled",
"--quitkey", "x",
)
}
func (m *swiftDialogMDMMigrator) renderError() (chan swiftDialogExitCode, chan error) {
var errorMessage bytes.Buffer
if err := errorTemplate.Execute(
&errorMessage,
m.props.OrgInfo,
); err != nil {
codeChan := make(chan swiftDialogExitCode, 1)
errChan := make(chan error, 1)
errChan <- fmt.Errorf("execute error template: %w", err)
return codeChan, errChan
}
return m.render(errorMessage.String(), "--button1text", "Close")
}
func (m *swiftDialogMDMMigrator) renderMigration() error {
var message bytes.Buffer
if err := mdmMigrationTemplate.Execute(
&message,
m.props,
); err != nil {
return fmt.Errorf("execute template: %w", err)
}
exitCodeCh, errCh := m.render(message.String(),
// info button
"--infobuttontext", "?",
"--infobuttonaction", "https://fleetdm.com/handbook/company/why-this-way#why-open-source",
// main button
"--button1text", "Start",
// secondary button
"--button2text", "Later",
"--blurscreen", "--ontop", "--height", "600",
)
select {
case err := <-errCh:
return fmt.Errorf("showing start migration dialog: %w", err)
case exitCode := <-exitCodeCh:
// we don't perform any action for all the other buttons
if exitCode != primaryBtnExitCode {
return nil
}
if !m.props.Aggressive {
// show the loading spinner
m.renderLoadingSpinner()
// send the API call
if notifyErr := m.handler.NotifyRemote(); notifyErr != nil {
m.baseDialog.Exit()
errDialogExitChan, errDialogErrChan := m.renderError()
select {
case <-errDialogExitChan:
return nil
case err := <-errDialogErrChan:
return fmt.Errorf("rendering errror dialog: %w", err)
}
}
log.Info().Msg("webhook sent, closing spinner")
// close the spinner
// TODO: maybe it's better to use
// https://github.com/bartreardon/swiftDialog/wiki/Updating-Dialog-with-new-content
// instead? it uses a file as IPC
m.baseDialog.Exit()
}
log.Info().Msg("showing instructions")
m.handler.ShowInstructions()
}
return nil
}
func (m *swiftDialogMDMMigrator) Show() error {
if m.showMu.TryLock() {
defer m.showMu.Unlock()
if err := m.renderMigration(); err != nil {
return fmt.Errorf("show: %w", err)
}
}
return nil
}
func (m *swiftDialogMDMMigrator) ShowInterval() error {
if m.intervalMu.TryLock() {
defer m.intervalMu.Unlock()
if time.Since(m.lastShown) > m.frequency {
if err := m.Show(); err != nil {
return fmt.Errorf("show interval: %w", err)
}
m.lastShown = time.Now()
}
}
return nil
}
func (m *swiftDialogMDMMigrator) SetProps(props MDMMigratorProps) {
m.props = props
}

View file

@ -0,0 +1,17 @@
//go:build !darwin
package useraction
import "time"
func NewMDMMigrator(path string, frequency time.Duration, handler MDMMigratorHandler) 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() {}

View file

@ -7,13 +7,38 @@ import "time"
type DesktopSummary struct {
FailingPolicies *uint `json:"failing_policies_count,omitempty"`
Notifications DesktopNotifications `json:"notifications,omitempty"`
Config DesktopConfig `json:"config"`
}
// DesktopNotifications are notifications that the fleet server sends to
// Fleet Desktop so that it can run commands or more generally react to this
// information.
type DesktopNotifications struct {
NeedsMDMMigration bool `json:"needs_mdm_migration,omitempty"`
NeedsMDMMigration bool `json:"needs_mdm_migration,omitempty"`
RenewEnrollmentProfile bool `json:"renew_enrollment_profile,omitempty"`
}
// DesktopConfig is a subset of AppConfig with information relevant to Fleet
// Desktop to operate.
type DesktopConfig struct {
OrgInfo DesktopOrgInfo `json:"org_info,omitempty"`
MDM DesktopMDMConfig `json:"mdm"`
}
// DesktopMDMConfig is a subset of fleet.MDM with configuration that's relevant
// to Fleet Desktop to operate.
type DesktopMDMConfig struct {
MacOSMigration struct {
Mode MacOSMigrationMode `json:"mode"`
} `json:"macos_migration"`
}
// DesktopMDMConfig is a subset of fleet.OrgInfo with configuration that's relevant
// to Fleet Desktop to operate.
type DesktopOrgInfo struct {
OrgName string `json:"org_name"`
OrgLogoURL string `json:"org_logo_url"`
ContactURL string `json:"contact_url"`
}
type MigrateMDMDeviceWebhookPayload struct {

View file

@ -8,6 +8,7 @@ import "encoding/json"
type OrbitConfigNotifications struct {
RenewEnrollmentProfile bool `json:"renew_enrollment_profile,omitempty"`
RotateDiskEncryptionKey bool `json:"rotate_disk_encryption_key,omitempty"`
NeedsMDMMigration bool `json:"needs_mdm_migration,omitempty"`
}
type OrbitConfig struct {

View file

@ -138,3 +138,8 @@ func (dc *DeviceClient) DesktopSummary(token string) (*fleetDesktopResponse, err
return nil, err
}
func (dc *DeviceClient) MigrateMDM(token string) error {
verb, path := "POST", "/api/latest/fleet/device/"+token+"/migrate_mdm"
return dc.request(verb, path, "", nil)
}

View file

@ -6,10 +6,10 @@ import (
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/require"
)
@ -22,7 +22,7 @@ func TestGetFleetDesktopSummary(t *testing.T) {
require.Empty(t, sum)
})
t.Run("different app config values", func(t *testing.T) {
t.Run("different app config values for managed host", func(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
@ -42,7 +42,8 @@ func TestGetFleetDesktopSummary(t *testing.T) {
},
},
out: fleet.DesktopNotifications{
NeedsMDMMigration: true,
NeedsMDMMigration: true,
RenewEnrollmentProfile: false,
},
},
{
@ -53,7 +54,8 @@ func TestGetFleetDesktopSummary(t *testing.T) {
},
},
out: fleet.DesktopNotifications{
NeedsMDMMigration: false,
NeedsMDMMigration: false,
RenewEnrollmentProfile: false,
},
},
{
@ -64,7 +66,8 @@ func TestGetFleetDesktopSummary(t *testing.T) {
},
},
out: fleet.DesktopNotifications{
NeedsMDMMigration: false,
NeedsMDMMigration: false,
RenewEnrollmentProfile: false,
},
},
{
@ -75,7 +78,8 @@ func TestGetFleetDesktopSummary(t *testing.T) {
},
},
out: fleet.DesktopNotifications{
NeedsMDMMigration: false,
NeedsMDMMigration: false,
RenewEnrollmentProfile: false,
},
},
}
@ -87,7 +91,7 @@ func TestGetFleetDesktopSummary(t *testing.T) {
return &appCfg, nil
}
ctx = host.NewContext(ctx, &fleet.Host{
ctx := test.HostContext(ctx, &fleet.Host{
OsqueryHostID: ptr.String("test"),
MDMInfo: &fleet.HostMDM{
IsServer: false,
@ -103,6 +107,91 @@ func TestGetFleetDesktopSummary(t *testing.T) {
})
t.Run("different app config values for unmanaged host", func(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
ds.FailingPoliciesCountFunc = func(ctx context.Context, host *fleet.Host) (uint, error) {
return uint(1), nil
}
cases := []struct {
mdm fleet.MDM
out fleet.DesktopNotifications
}{
{
mdm: fleet.MDM{
EnabledAndConfigured: true,
MacOSMigration: fleet.MacOSMigration{
Enable: true,
},
},
out: fleet.DesktopNotifications{
NeedsMDMMigration: false,
RenewEnrollmentProfile: true,
},
},
{
mdm: fleet.MDM{
EnabledAndConfigured: false,
MacOSMigration: fleet.MacOSMigration{
Enable: true,
},
},
out: fleet.DesktopNotifications{
NeedsMDMMigration: false,
RenewEnrollmentProfile: false,
},
},
{
mdm: fleet.MDM{
EnabledAndConfigured: true,
MacOSMigration: fleet.MacOSMigration{
Enable: false,
},
},
out: fleet.DesktopNotifications{
NeedsMDMMigration: false,
RenewEnrollmentProfile: false,
},
},
{
mdm: fleet.MDM{
EnabledAndConfigured: false,
MacOSMigration: fleet.MacOSMigration{
Enable: false,
},
},
out: fleet.DesktopNotifications{
NeedsMDMMigration: false,
RenewEnrollmentProfile: false,
},
},
}
for _, c := range cases {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
appCfg := fleet.AppConfig{}
appCfg.MDM = c.mdm
return &appCfg, nil
}
ctx = test.HostContext(ctx, &fleet.Host{
OsqueryHostID: ptr.String("test"),
MDMInfo: &fleet.HostMDM{
IsServer: false,
InstalledFromDep: true,
Enrolled: false,
Name: fleet.WellKnownMDMFleet,
}})
sum, err := svc.GetFleetDesktopSummary(ctx)
require.NoError(t, err)
require.Equal(t, c.out, sum.Notifications, fmt.Sprintf("enabled_and_configured: %t | macos_migration.enable: %t", c.mdm.EnabledAndConfigured, c.mdm.MacOSMigration.Enable))
require.EqualValues(t, 1, *sum.FailingPolicies)
}
})
t.Run("different host attributes", func(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)}
@ -136,7 +225,8 @@ func TestGetFleetDesktopSummary(t *testing.T) {
host: &fleet.Host{OsqueryHostID: nil},
err: nil,
out: fleet.DesktopNotifications{
NeedsMDMMigration: false,
NeedsMDMMigration: false,
RenewEnrollmentProfile: false,
},
},
{
@ -151,7 +241,8 @@ func TestGetFleetDesktopSummary(t *testing.T) {
}},
err: nil,
out: fleet.DesktopNotifications{
NeedsMDMMigration: false,
NeedsMDMMigration: false,
RenewEnrollmentProfile: false,
},
},
{
@ -162,11 +253,12 @@ func TestGetFleetDesktopSummary(t *testing.T) {
IsServer: false,
InstalledFromDep: true,
Enrolled: false,
Name: fleet.WellKnownMDMIntune,
Name: fleet.WellKnownMDMFleet,
}},
err: nil,
out: fleet.DesktopNotifications{
NeedsMDMMigration: false,
NeedsMDMMigration: false,
RenewEnrollmentProfile: true,
},
},
{
@ -181,7 +273,8 @@ func TestGetFleetDesktopSummary(t *testing.T) {
}},
err: nil,
out: fleet.DesktopNotifications{
NeedsMDMMigration: false,
NeedsMDMMigration: false,
RenewEnrollmentProfile: false,
},
},
{
@ -196,14 +289,15 @@ func TestGetFleetDesktopSummary(t *testing.T) {
}},
err: nil,
out: fleet.DesktopNotifications{
NeedsMDMMigration: true,
NeedsMDMMigration: true,
RenewEnrollmentProfile: false,
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ctx = host.NewContext(ctx, c.host)
ctx = test.HostContext(ctx, c.host)
sum, err := svc.GetFleetDesktopSummary(ctx)
if c.err != nil {

View file

@ -4392,6 +4392,12 @@ func (s *integrationMDMTestSuite) TestDesktopMDMMigration() {
token := "token_test_migration"
host := createHostAndDeviceToken(t, s.ds, token)
// enable migration
var acResp appConfigResponse
s.DoJSON("PATCH", "/api/v1/fleet/config", json.RawMessage(`{
"mdm": { "macos_migration": { "enable": true, "mode": "voluntary", "webhook_url": "https://example.com" } }
}`), http.StatusOK, &acResp)
getDesktopResp := fleetDesktopResponse{}
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/desktop", nil, http.StatusOK)
require.NoError(t, json.NewDecoder(res.Body).Decode(&getDesktopResp))
@ -4399,6 +4405,11 @@ func (s *integrationMDMTestSuite) TestDesktopMDMMigration() {
require.NoError(t, getDesktopResp.Err)
require.Zero(t, *getDesktopResp.FailingPolicies)
require.False(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.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)
// simulate that the device is enrolled in a third-party MDM and DEP capable
err := s.ds.SetOrUpdateMDMData(
@ -4419,6 +4430,36 @@ func (s *integrationMDMTestSuite) TestDesktopMDMMigration() {
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.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)
// simulate that the device needs to be enrolled in fleet, DEP capable
err = s.ds.SetOrUpdateMDMData(
ctx,
host.ID,
false,
false,
s.server.URL,
true,
fleet.WellKnownMDMFleet,
)
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.False(t, getDesktopResp.Notifications.NeedsMDMMigration)
require.True(t, getDesktopResp.Notifications.RenewEnrollmentProfile)
require.Equal(t, acResp.OrgInfo.OrgLogoURL, getDesktopResp.Config.OrgInfo.OrgLogoURL)
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)
}
func (s *integrationMDMTestSuite) runWorker() {

View file

@ -252,6 +252,14 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro
}
}
if config.MDM.EnabledAndConfigured &&
config.MDM.MacOSMigration.Enable &&
host.IsOsqueryEnrolled() &&
host.MDMInfo.IsDEPCapable() &&
host.MDMInfo.IsEnrolledInThirdPartyMDM() {
notifs.NeedsMDMMigration = true
}
return fleet.OrbitConfig{
Flags: opts.CommandLineStartUpFlags,
Extensions: opts.Extensions,

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB