<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Closes #46153 This PR is big, but I found it worth it to include in the same PR to keep the mental change context in one place. This PR moves away from our previous version of a big SQL computing the desired state and label membership with big union branches. It does so by switching the model up completely, first: - We batch read hosts (current hardcoded is 5k), and we always iterate 5k hosts and then decide if they have changes, so that means a tick (30s) could read 5k hosts that DOES NOT require changes, but that is computed in code after, rather than relying on a big SQL to do it (twice). - We then for those hosts, bulk fetch label memberships, their related team profiles and current rows. This performs much better as we can lookup everything we need by primary key or super fast indexed columns, simple fetch all these calls. - Then once gathered the information we move to the code to determine if the operation is install, remove, NO-OP (Desired state calculation), then we check the label membership to further determine it's final action. - We then move to what we did before, which is queue the correct command etc. It comes with some slight caveats, which is we now load a lot more data into memory (but before we could spike worse), so when loadtesting we watched CPU/Memory utilization, which never seemed to spike as the datasets are kept as small as possible. _Cleanup will come in a follow-up PR where we remove all the old code._ # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [X] Added/updated automated tests - [ ] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Performance** * Optimized Apple profile and DDM (Declarations) reconciliation engine with batched processing for significantly improved performance in environments with large numbers of Apple-enrolled hosts. * Implemented cursor-based pagination for more efficient reconciliation across large fleets. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45573?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude <noreply@anthropic.com>
888 lines
34 KiB
Go
888 lines
34 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/pkg/fleetdbase"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/appmanifest"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Name of the Apple MDM job as registered in the worker. Note that although it
|
|
// is a single job, it can process a number of different-but-related tasks,
|
|
// identified by the Task field in the job's payload.
|
|
const appleMDMJobName = "apple_mdm"
|
|
|
|
type AppleMDMTask string
|
|
|
|
// List of supported tasks.
|
|
const (
|
|
AppleMDMPostDEPEnrollmentTask AppleMDMTask = "post_dep_enrollment"
|
|
AppleMDMPostManualEnrollmentTask AppleMDMTask = "post_manual_enrollment"
|
|
// PostDEPReleaseDevice is not enqueued anymore for macOS but remains for
|
|
// backward compatibility (processing existing jobs after a fleet upgrade)
|
|
// and for ios/ipados. Macs are now released via the swift dialog UI of the
|
|
// setup experience flow.
|
|
AppleMDMPostDEPReleaseDeviceTask AppleMDMTask = "post_dep_release_device"
|
|
)
|
|
|
|
// AppleMDM is the job processor for the apple_mdm job.
|
|
// CertProfilesLimit is the per-tick CA-profile throttle used by the
|
|
// shared apple_mdm.ReconcileProfilesForEnrollingHost path. Set by the cron
|
|
// constructor in cmd/fleet/cron.go from the config (defaults to 0 in
|
|
// tests, which disables CA throttling — fine since tests rarely exercise
|
|
// CA profiles and the per-host path bypasses throttling for recently
|
|
// enrolled hosts anyway).
|
|
type AppleMDM struct {
|
|
Datastore fleet.Datastore
|
|
Log *slog.Logger
|
|
Commander *apple_mdm.MDMAppleCommander
|
|
BootstrapPackageStore fleet.MDMBootstrapPackageStore
|
|
VPPInstaller fleet.AppleMDMVPPInstaller
|
|
NewActivityFn fleet.NewActivityFunc
|
|
}
|
|
|
|
// Name returns the name of the job.
|
|
func (a *AppleMDM) Name() string {
|
|
return appleMDMJobName
|
|
}
|
|
|
|
// appleMDMArgs is the payload for the Apple MDM job.
|
|
type appleMDMArgs struct {
|
|
Task AppleMDMTask `json:"task"`
|
|
HostUUID string `json:"host_uuid"`
|
|
TeamID *uint `json:"team_id,omitempty"` //nolint:apiparamcheck
|
|
// EnrollReference is the UUID of the MDM IdP account used to enroll the
|
|
// device. It is used to set the username and full name of the user
|
|
// associated with the device.
|
|
//
|
|
// FIXME: Rename this to IdPAccountUUID or something similar.
|
|
EnrollReference string `json:"enroll_reference,omitempty"`
|
|
EnrollmentCommands []string `json:"enrollment_commands,omitempty"`
|
|
Platform string `json:"platform,omitempty"`
|
|
UseWorkerDeviceRelease bool `json:"use_worker_device_release,omitempty"`
|
|
ReleaseDeviceAttempt int `json:"release_device_attempt,omitempty"` // number of attempts to release the device
|
|
ReleaseDeviceStartedAt *time.Time `json:"release_device_started_at,omitempty"` // time when the release device task first started
|
|
FromMDMMigration bool `json:"from_mdm_migration,omitempty"` // indicates if the task is part of an MDM migration
|
|
}
|
|
|
|
// Run executes the apple_mdm job.
|
|
func (a *AppleMDM) Run(ctx context.Context, argsJSON json.RawMessage) error {
|
|
appCfg, err := a.Datastore.AppConfig(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "retrieving app config")
|
|
}
|
|
if !appCfg.MDM.EnabledAndConfigured || a.Commander == nil {
|
|
return nil
|
|
}
|
|
|
|
var args appleMDMArgs
|
|
if err := json.Unmarshal(argsJSON, &args); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "unmarshal args")
|
|
}
|
|
|
|
switch args.Task {
|
|
case AppleMDMPostDEPEnrollmentTask:
|
|
err := a.runPostDEPEnrollment(ctx, args)
|
|
return ctxerr.Wrap(ctx, err, "running post Apple DEP enrollment task")
|
|
|
|
case AppleMDMPostManualEnrollmentTask:
|
|
err := a.runPostManualEnrollment(ctx, args)
|
|
return ctxerr.Wrap(ctx, err, "running post Apple manual enrollment task")
|
|
|
|
case AppleMDMPostDEPReleaseDeviceTask:
|
|
err := a.runPostDEPReleaseDevice(ctx, args)
|
|
return ctxerr.Wrap(ctx, err, "running post Apple DEP release device task")
|
|
|
|
default:
|
|
return ctxerr.Errorf(ctx, "unknown task: %v", args.Task)
|
|
}
|
|
}
|
|
|
|
func isMacOS(platform string) bool {
|
|
// For backwards compatibility, we assume empty platform in job arguments is macOS.
|
|
return platform == "" ||
|
|
platform == "darwin"
|
|
}
|
|
|
|
func (a *AppleMDM) runPostManualEnrollment(ctx context.Context, args appleMDMArgs) error {
|
|
_, err := a.installProfilesForEnrollingHost(ctx, args.HostUUID)
|
|
if err != nil {
|
|
a.Log.ErrorContext(ctx, "error installing profiles for enrolling host", "host_uuid", args.HostUUID, "err", err)
|
|
// We do not return here, as we want to continue with the rest of the logic, and then the reconciler will just pick up the remaining work.
|
|
// We do this since this is a speed optimization and not critical to complete enrollment itself.
|
|
}
|
|
|
|
if isMacOS(args.Platform) {
|
|
if _, err := a.installFleetd(ctx, args.HostUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "installing post-enrollment packages")
|
|
}
|
|
} else {
|
|
// We shouldn't have any setup experience steps if we're not on a premium license,
|
|
// but best to check anyway plus it saves some db queries.
|
|
if license.IsPremium(ctx) {
|
|
_, err := a.installSetupExperienceVPPAppsOnIosIpadOS(ctx, args.HostUUID, ptr.ValOrZero(args.TeamID))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "installing setup experience VPP apps on iOS/iPadOS")
|
|
}
|
|
}
|
|
// Refetch is handled by the iphone_ipad_refetcher cron, which now
|
|
// picks up freshly-enrolled hosts on its next tick (see
|
|
// ListIOSAndIPadOSToRefetch). That avoids tying the host's inventory
|
|
// catch-up to the synchronous enrollment path and keeps a single
|
|
// source of truth for refetch command emission.
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) error {
|
|
var (
|
|
awaitCmdUUIDs []string
|
|
appCfg *fleet.AppConfig
|
|
team *fleet.Team
|
|
err error
|
|
)
|
|
|
|
if isMacOS(args.Platform) {
|
|
var manualAgentInstall bool
|
|
if args.TeamID == nil {
|
|
if appCfg, err = a.getAppConfig(ctx, appCfg); err != nil {
|
|
return err
|
|
}
|
|
manualAgentInstall = appCfg.MDM.MacOSSetup.ManualAgentInstall.Value
|
|
} else {
|
|
if team, err = a.getTeamConfig(ctx, team, *args.TeamID); err != nil {
|
|
return err
|
|
}
|
|
manualAgentInstall = team.Config.MDM.MacOSSetup.ManualAgentInstall.Value
|
|
}
|
|
|
|
if !manualAgentInstall {
|
|
fleetdCmdUUID, err := a.installFleetd(ctx, args.HostUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "installing post-enrollment packages")
|
|
}
|
|
awaitCmdUUIDs = append(awaitCmdUUIDs, fleetdCmdUUID)
|
|
}
|
|
|
|
allowBootstrapDuringMigration := false
|
|
allowBootstrapDuringMigrationEV := os.Getenv("FLEET_ALLOW_BOOTSTRAP_PACKAGE_DURING_MIGRATION")
|
|
if allowBootstrapDuringMigrationEV == "1" || strings.EqualFold(allowBootstrapDuringMigrationEV, "true") {
|
|
allowBootstrapDuringMigration = true
|
|
}
|
|
|
|
if args.FromMDMMigration && !allowBootstrapDuringMigration {
|
|
a.Log.InfoContext(ctx, "skipping bootstrap package installation during MDM migration", "host_uuid", args.HostUUID)
|
|
err = a.Datastore.RecordSkippedHostBootstrapPackage(ctx, args.HostUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "recording skipped bootstrap package")
|
|
}
|
|
} else {
|
|
bootstrapCmdUUID, err := a.installBootstrapPackage(ctx, args.HostUUID, args.TeamID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "installing post-enrollment packages")
|
|
}
|
|
if bootstrapCmdUUID != "" {
|
|
awaitCmdUUIDs = append(awaitCmdUUIDs, bootstrapCmdUUID)
|
|
}
|
|
}
|
|
} else {
|
|
commandUUIDs, err := a.installSetupExperienceVPPAppsOnIosIpadOS(ctx, args.HostUUID, ptr.ValOrZero(args.TeamID))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "installing setup experience VPP apps on iOS/iPadOS")
|
|
}
|
|
awaitCmdUUIDs = append(awaitCmdUUIDs, commandUUIDs...)
|
|
}
|
|
|
|
cmdUUIDs, err := a.installProfilesForEnrollingHost(ctx, args.HostUUID)
|
|
if err != nil {
|
|
a.Log.ErrorContext(ctx, "error installing profiles for enrolling host", "host_uuid", args.HostUUID, "err", err)
|
|
// We do not return here, as we want to continue with the rest of the logic, and then the reconciler will just pick up the remaining work.
|
|
// We do this since this is a speed optimization and not critical to complete enrollment itself, as we have other backing logic.
|
|
cmdUUIDs = []string{}
|
|
}
|
|
|
|
awaitCmdUUIDs = append(awaitCmdUUIDs, cmdUUIDs...)
|
|
|
|
var ssoEnabled, managedAdminAccountEnabled, lockPrimaryAccountInfo bool
|
|
var primaryAccountType fleet.PrimaryAccountType
|
|
var ssoAccount *fleet.MDMIdPAccount
|
|
var adminAccount *apple_mdm.AdminAccountConfig
|
|
|
|
if ref := args.EnrollReference; ref != "" {
|
|
a.Log.InfoContext(ctx, "got an enroll_reference", "host_uuid", args.HostUUID, "ref", ref)
|
|
if appCfg, err = a.getAppConfig(ctx, appCfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
ssoAccount, err = a.Datastore.GetMDMIdPAccountByUUID(ctx, ref)
|
|
if err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "getting idp account details for enroll reference %s", ref)
|
|
}
|
|
|
|
ssoEnabled = appCfg.MDM.MacOSSetup.EnableEndUserAuthentication
|
|
lockPrimaryAccountInfo = appCfg.MDM.MacOSSetup.LockEndUserInfo.Value
|
|
if args.TeamID != nil {
|
|
if team, err = a.getTeamConfig(ctx, team, *args.TeamID); err != nil {
|
|
return err
|
|
}
|
|
ssoEnabled = team.Config.MDM.MacOSSetup.EnableEndUserAuthentication
|
|
lockPrimaryAccountInfo = team.Config.MDM.MacOSSetup.LockEndUserInfo.Value
|
|
}
|
|
}
|
|
|
|
if isMacOS(args.Platform) && license.IsPremium(ctx) {
|
|
if args.TeamID == nil {
|
|
if appCfg, err = a.getAppConfig(ctx, appCfg); err != nil {
|
|
return err
|
|
}
|
|
managedAdminAccountEnabled = appCfg.MDM.MacOSSetup.EnableManagedLocalAccount.Value
|
|
primaryAccountType = fleet.PrimaryAccountType(appCfg.MDM.MacOSSetup.EndUserLocalAccountType.Value)
|
|
} else {
|
|
if team, err = a.getTeamConfig(ctx, team, *args.TeamID); err != nil {
|
|
return err
|
|
}
|
|
managedAdminAccountEnabled = team.Config.MDM.MacOSSetup.EnableManagedLocalAccount.Value
|
|
primaryAccountType = fleet.PrimaryAccountType(team.Config.MDM.MacOSSetup.EndUserLocalAccountType.Value)
|
|
}
|
|
}
|
|
|
|
const fleetAdminFullName = "Fleet Admin"
|
|
|
|
// Only send AccountConfiguration for macOS devices.
|
|
if isMacOS(args.Platform) && (ssoEnabled || managedAdminAccountEnabled) {
|
|
var password string
|
|
cmdUUID := uuid.New().String()
|
|
if managedAdminAccountEnabled {
|
|
password = apple_mdm.GenerateManagedAccountPassword()
|
|
passwordHash, err := apple_mdm.GenerateSaltedSHA512PBKDF2Hash(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
adminAccount = &apple_mdm.AdminAccountConfig{
|
|
ShortName: fleet.ManagedLocalAccountUsername,
|
|
FullName: fleetAdminFullName,
|
|
PasswordHash: passwordHash,
|
|
Hidden: true,
|
|
PrimaryAccountType: primaryAccountType,
|
|
}
|
|
// Save the password before sending the command so the plaintext is
|
|
// escrowed even if the command enqueue succeeds but a later step fails.
|
|
if err := a.Datastore.SaveHostManagedLocalAccount(ctx, args.HostUUID, password, cmdUUID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Only include the SSO account in the payload if SSO is actually enabled.
|
|
// ssoAccount may be non-nil (fetched from enroll reference) even when SSO is disabled.
|
|
var ssoAccountForPayload *fleet.MDMIdPAccount
|
|
if ssoEnabled {
|
|
ssoAccountForPayload = ssoAccount
|
|
}
|
|
if err := a.sendManagedAccounts(ctx, &args, ssoAccountForPayload, adminAccount, lockPrimaryAccountInfo, cmdUUID); err != nil {
|
|
return err
|
|
}
|
|
awaitCmdUUIDs = append(awaitCmdUUIDs, cmdUUID)
|
|
}
|
|
|
|
// proceed to release the device if it is not a macos, as those are released
|
|
// via the setup experience flow, or if we were told to use the worker based
|
|
// release.
|
|
if !isMacOS(args.Platform) || args.UseWorkerDeviceRelease {
|
|
var manualRelease bool
|
|
if args.TeamID == nil {
|
|
if appCfg, err = a.getAppConfig(ctx, appCfg); err != nil {
|
|
return err
|
|
}
|
|
manualRelease = appCfg.MDM.MacOSSetup.EnableReleaseDeviceManually.Value
|
|
} else {
|
|
if team, err = a.getTeamConfig(ctx, team, *args.TeamID); err != nil {
|
|
return err
|
|
}
|
|
manualRelease = team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value
|
|
}
|
|
|
|
if !manualRelease {
|
|
// send all command uuids for the commands sent here during post-DEP
|
|
// enrollment and enqueue a job to look for the status of those commands to
|
|
// be final and same for MDM profiles of that host; it means the DEP
|
|
// enrollment process is done and the device can be released.
|
|
if err := QueueAppleMDMJob(ctx, a.Datastore, a.Log, AppleMDMPostDEPReleaseDeviceTask,
|
|
args.HostUUID, args.Platform, args.TeamID, args.EnrollReference, false, args.FromMDMMigration, awaitCmdUUIDs...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "queue Apple Post-DEP release device job")
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getTeamConfig gets team config from DB if not provided.
|
|
func (a *AppleMDM) getTeamConfig(ctx context.Context, team *fleet.Team, teamID uint) (*fleet.Team, error) {
|
|
if team == nil { // TODO see if we can swap this (plus callers) to use TeamLite
|
|
var err error
|
|
team, err = a.Datastore.TeamWithExtras(ctx, teamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "fetch team to send AccountConfiguration")
|
|
}
|
|
}
|
|
return team, nil
|
|
}
|
|
|
|
// getAppConfig gets app config from DB if not provided.
|
|
func (a *AppleMDM) getAppConfig(ctx context.Context, appConfig *fleet.AppConfig) (*fleet.AppConfig, error) {
|
|
if appConfig == nil {
|
|
var err error
|
|
appConfig, err = a.Datastore.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting app config")
|
|
}
|
|
}
|
|
return appConfig, nil
|
|
}
|
|
|
|
func (a *AppleMDM) getIdPDisplayName(ctx context.Context, acct *fleet.MDMIdPAccount, args appleMDMArgs) (string, error) {
|
|
if acct.Fullname != "" && !strings.Contains(acct.Fullname, "@") {
|
|
return acct.Fullname, nil
|
|
}
|
|
|
|
// If full name is empty or appears to be an email, see if it exists via SCIM integration
|
|
scimUser, err := a.Datastore.ScimUserByUserNameOrEmail(ctx, acct.Username, acct.Email)
|
|
switch {
|
|
case err != nil && !fleet.IsNotFound(err):
|
|
return "", ctxerr.Wrap(ctx, err, "getting scim user details for enroll reference %s and host_uuid %s", acct.UUID, args.HostUUID)
|
|
case scimUser == nil:
|
|
return acct.Fullname, nil
|
|
}
|
|
if scimUser.DisplayName() == "" {
|
|
return acct.Fullname, nil
|
|
}
|
|
return scimUser.DisplayName(), nil
|
|
}
|
|
|
|
// This job is used only for iDevices or for macos devices that don't use any
|
|
// setup experience items (software installs, script exec) - see
|
|
// appleMDMArgs.UseWorkerDeviceRelease. Otherwise releasing devices is now done
|
|
// via the orbit endpoint /setup_experience/status that is polled by a swift
|
|
// dialog UI window during the setup process, and automatically releases the
|
|
// device once all pending setup tasks are done.
|
|
func (a *AppleMDM) runPostDEPReleaseDevice(ctx context.Context, args appleMDMArgs) error {
|
|
// Edge cases:
|
|
// - if the device goes offline for a long time, should we go ahead and
|
|
// release after a while?
|
|
// - if some commands/profiles failed (a final state), should we go ahead
|
|
// and release?
|
|
// - if the device keeps moving team, or profiles keep being added/removed
|
|
// from its team, it's possible that its profiles will never settle and
|
|
// always have pending statuses. Same as going offline, should we release
|
|
// after a while?
|
|
//
|
|
// We opted "yes" to all those, and we want to release after a few minutes,
|
|
// not hours, so we'll allow only a couple retries.
|
|
|
|
const (
|
|
maxWaitTime = 15 * time.Minute
|
|
minAttempts = 10
|
|
maxAttempts = 30
|
|
nextAttemptMinDelay = 30 * time.Second
|
|
)
|
|
|
|
args.ReleaseDeviceAttempt++
|
|
if args.ReleaseDeviceStartedAt == nil {
|
|
now := time.Now().UTC()
|
|
args.ReleaseDeviceStartedAt = &now
|
|
}
|
|
|
|
a.Log.DebugContext(ctx,
|
|
fmt.Sprintf("awaiting commands %v and profiles to settle for host %s", args.EnrollmentCommands, args.HostUUID),
|
|
"task", "runPostDEPReleaseDevice",
|
|
"attempt", args.ReleaseDeviceAttempt,
|
|
"started_at", args.ReleaseDeviceStartedAt.Format(time.RFC3339),
|
|
)
|
|
|
|
// if we've reached the minimum number of attempts and the maximum time to
|
|
// wait, we release the device even if some commands or profiles are still
|
|
// pending. We also release in case it reached the maximum number of
|
|
// attempts, to prevent an issue with clock skew where the wait delay does
|
|
// not appear to be reached.
|
|
if (args.ReleaseDeviceAttempt >= minAttempts && time.Since(*args.ReleaseDeviceStartedAt) >= maxWaitTime) ||
|
|
(args.ReleaseDeviceAttempt >= maxAttempts) {
|
|
a.Log.InfoContext(ctx, "releasing device after too many attempts or too long wait", "host_uuid", args.HostUUID, "attempts", args.ReleaseDeviceAttempt)
|
|
if err := a.Commander.DeviceConfigured(ctx, args.HostUUID, uuid.NewString()); err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "failed to enqueue DeviceConfigured command after %d attempts", args.ReleaseDeviceAttempt)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
reenqueueTask := func() error {
|
|
// re-enqueue the same job, but now
|
|
// ReleaseDeviceAttempt/ReleaseDeviceStartedAt have been incremented/set,
|
|
// and run it not before a delay so it doesn't run again until the next
|
|
// worker cycle.
|
|
_, err := QueueJobWithDelay(ctx, a.Datastore, appleMDMJobName, args, nextAttemptMinDelay)
|
|
return err
|
|
}
|
|
|
|
// used to cross reference against the setup experience statuses below
|
|
notNowCmdUUIDs := make(map[string]any)
|
|
|
|
for _, cmdUUID := range args.EnrollmentCommands {
|
|
if cmdUUID == "" {
|
|
continue
|
|
}
|
|
|
|
res, err := a.Datastore.GetMDMAppleCommandResults(ctx, cmdUUID, args.HostUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "failed to get MDM command results")
|
|
}
|
|
|
|
var completed bool
|
|
for _, r := range res {
|
|
if r.Status == fleet.MDMAppleStatusNotNow {
|
|
notNowCmdUUIDs[cmdUUID] = ""
|
|
}
|
|
|
|
// succeeded or failed, it is done (final state). We also consider "NotNow"
|
|
// as completed, as it means the device is not going to process that command
|
|
// now, and we don't want to block the DEP device release because of that.
|
|
if r.Status == fleet.MDMAppleStatusAcknowledged || r.Status == fleet.MDMAppleStatusError || r.Status == fleet.MDMAppleStatusNotNow || r.Status == fleet.MDMAppleStatusCommandFormatError {
|
|
completed = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !completed {
|
|
// DEP enrollment commands are not done being delivered to that device,
|
|
// cannot release it now.
|
|
if err := reenqueueTask(); err != nil {
|
|
return fmt.Errorf("failed to re-enqueue task: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
a.Log.DebugContext(ctx,
|
|
fmt.Sprintf("command %s has completed", cmdUUID),
|
|
"task", "runPostDEPReleaseDevice",
|
|
)
|
|
}
|
|
|
|
// all DEP-enrollment commands are done, check the host's profiles
|
|
profs, err := a.Datastore.GetHostMDMAppleProfiles(ctx, args.HostUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "failed to get host MDM profiles")
|
|
}
|
|
for _, prof := range profs {
|
|
// NOTE: DDM profiles (declarations) are ignored because while a device is
|
|
// awaiting to be released, it cannot process a DDM session (at least
|
|
// that's what we noticed during testing).
|
|
if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleDeclarationUUIDPrefix) {
|
|
continue
|
|
}
|
|
|
|
// NOTE: user-scoped profiles are ignored because they are not sent by Fleet
|
|
// until after the device is released - there is no user-channel available
|
|
// on the host until after the release, and after the user actually created
|
|
// the user account.
|
|
if prof.Scope == fleet.PayloadScopeUser {
|
|
continue
|
|
}
|
|
|
|
// if it has any pending profiles, then its profiles are not done being
|
|
// delivered (installed or removed).
|
|
if prof.Status == nil || *prof.Status == fleet.MDMDeliveryPending {
|
|
if err := reenqueueTask(); err != nil {
|
|
return fmt.Errorf("failed to re-enqueue task: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
a.Log.DebugContext(ctx,
|
|
fmt.Sprintf("profile %s has been deployed", prof.Identifier),
|
|
"task", "runPostDEPReleaseDevice",
|
|
)
|
|
}
|
|
|
|
profilesMissingInstallation, err := a.Datastore.ListMDMAppleProfilesToInstall(ctx, args.HostUUID) // Get profiles that are missing to be installed on this host
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "failed to list profiles missing installation")
|
|
}
|
|
profilesMissingInstallation = fleet.FilterOutUserScopedProfiles(profilesMissingInstallation)
|
|
if !isMacOS(args.Platform) {
|
|
profilesMissingInstallation = fleet.FilterMacOSOnlyProfilesFromIOSIPadOS(profilesMissingInstallation)
|
|
}
|
|
|
|
if len(profilesMissingInstallation) > 0 {
|
|
a.Log.InfoContext(ctx, "re-enqueuing due to profiles missing installation", "host_uuid", args.HostUUID)
|
|
// requeue the task if some profiles are still missing.
|
|
if err := reenqueueTask(); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "failed to re-enqueue task")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if !isMacOS(args.Platform) {
|
|
setupExperienceStatuses, err := a.Datastore.ListSetupExperienceResultsByHostUUID(ctx, args.HostUUID, ptr.ValOrZero(args.TeamID))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "retrieving setup experience status results for host pending DEP release")
|
|
}
|
|
for _, status := range setupExperienceStatuses {
|
|
// skip items that had the command response of "NotNow" as those setup exp statuses will be pending/running
|
|
// and we have decided to not block the device release for NotNow status so we dont want to reenqueue these.
|
|
if status.NanoCommandUUID != nil {
|
|
if _, ok := notNowCmdUUIDs[*status.NanoCommandUUID]; ok {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if status.Status == fleet.SetupExperienceStatusPending || status.Status == fleet.SetupExperienceStatusRunning {
|
|
a.Log.InfoContext(ctx, "re-enqueuing due to setup experience items still pending or running", "host_uuid", args.HostUUID, "status_id", status.ID)
|
|
if err := reenqueueTask(); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "failed to re-enqueue task due to pending setup experience items")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// release the device
|
|
a.Log.InfoContext(ctx, "releasing device, all DEP enrollment commands and profiles have completed", "host_uuid", args.HostUUID)
|
|
if err := a.Commander.DeviceConfigured(ctx, args.HostUUID, uuid.NewString()); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "failed to enqueue DeviceConfigured command")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *AppleMDM) installFleetd(ctx context.Context, hostUUID string) (string, error) {
|
|
manifestURL := fleetdbase.GetPKGManifestURL()
|
|
cmdUUID := uuid.New().String()
|
|
if err := a.Commander.InstallEnterpriseApplication(ctx, []string{hostUUID}, cmdUUID, manifestURL); err != nil {
|
|
return "", err
|
|
}
|
|
a.Log.InfoContext(ctx, "sent command to install fleetd", "host_uuid", hostUUID)
|
|
return cmdUUID, nil
|
|
}
|
|
|
|
func (a *AppleMDM) installSetupExperienceVPPAppsOnIosIpadOS(ctx context.Context, hostUUID string, teamID uint) ([]string, error) {
|
|
statuses, err := a.Datastore.ListSetupExperienceResultsByHostUUID(ctx, hostUUID, teamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "retrieving setup experience status results for next step")
|
|
}
|
|
|
|
var appsPending []*fleet.SetupExperienceStatusResult
|
|
commandUUIDs := []string{}
|
|
for _, status := range statuses {
|
|
if err := status.IsValid(); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "invalid row")
|
|
}
|
|
|
|
switch {
|
|
case status.VPPAppTeamID != nil:
|
|
if status.Status == fleet.SetupExperienceStatusPending {
|
|
appsPending = append(appsPending, status)
|
|
}
|
|
case status.SetupExperienceScriptID != nil, status.SoftwareInstallerID != nil:
|
|
status.Status = fleet.SetupExperienceStatusFailure
|
|
err = a.Datastore.UpdateSetupExperienceStatusResult(ctx, status)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "updating setup experience status result to failure")
|
|
}
|
|
// If we enqueued a non-VPP item for an iOS/iPadOS device, it likely a code bug
|
|
a.Log.ErrorContext(ctx, "unexpected setup experience item for iOS/iPadOS device, only VPP apps are supported", "host_uuid", hostUUID, "status_id", status.ID)
|
|
}
|
|
}
|
|
|
|
if len(appsPending) > 0 {
|
|
// enqueue vpp apps
|
|
// TODO Is there a better way to get a host by UUID? This is a somewhat "wide" search which feels unnecessary
|
|
host, err := a.Datastore.HostByIdentifier(ctx, hostUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "retrieving host by UUID")
|
|
}
|
|
for _, app := range appsPending {
|
|
vppAppID, err := app.VPPAppID()
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "constructing vpp app details for installation")
|
|
}
|
|
|
|
if app.SoftwareTitleID == nil {
|
|
return nil, ctxerr.Errorf(ctx, "setup experience software title id missing from vpp app install request: %d", app.ID)
|
|
}
|
|
|
|
vppApp := &fleet.VPPApp{
|
|
TitleID: *app.SoftwareTitleID,
|
|
VPPAppTeam: fleet.VPPAppTeam{
|
|
VPPAppID: *vppAppID,
|
|
},
|
|
}
|
|
|
|
opts := fleet.HostSoftwareInstallOptions{
|
|
SelfService: false,
|
|
ForSetupExperience: true,
|
|
}
|
|
|
|
cmdUUID, err := a.installSoftwareFromVPP(ctx, host, vppApp, true, opts)
|
|
|
|
failedBeforeCommandSend := err != nil
|
|
if err != nil {
|
|
// if we get an error (e.g. no available licenses) while attempting to enqueue the
|
|
// install, then we should immediately go to an error state so setup experience
|
|
// isn't blocked.
|
|
a.Log.ErrorContext(ctx, "got an error when attempting to enqueue VPP app install", "err", err, "adam_id", app.VPPAppAdamID)
|
|
app.Status = fleet.SetupExperienceStatusFailure
|
|
app.Error = ptr.String(err.Error())
|
|
} else {
|
|
app.NanoCommandUUID = &cmdUUID
|
|
app.Status = fleet.SetupExperienceStatusRunning
|
|
commandUUIDs = append(commandUUIDs, cmdUUID)
|
|
}
|
|
if err := a.Datastore.UpdateSetupExperienceStatusResult(ctx, app); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "updating setup experience with vpp install command uuid")
|
|
}
|
|
// Emit activity for the VPP app install failure, if one occurred
|
|
if failedBeforeCommandSend && a.NewActivityFn != nil {
|
|
failActivity := fleet.ActivityInstalledAppStoreApp{
|
|
HostID: host.ID,
|
|
HostDisplayName: host.DisplayName(),
|
|
SoftwareTitle: app.Name,
|
|
AppStoreID: ptr.ValOrZero(app.VPPAppAdamID),
|
|
Status: string(fleet.SoftwareInstallFailed),
|
|
HostPlatform: host.Platform,
|
|
FromSetupExperience: true,
|
|
}
|
|
if actErr := a.NewActivityFn(ctx, nil, failActivity); actErr != nil {
|
|
a.Log.WarnContext(ctx, "failed to create activity for VPP app install failure during setup experience", "err", actErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return commandUUIDs, nil
|
|
}
|
|
|
|
func (a *AppleMDM) installSoftwareFromVPP(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp, appleDevice bool, opts fleet.HostSoftwareInstallOptions) (string, error) {
|
|
// Should not happen in the normal course of events but can happen in tests
|
|
// and likely indicates things weren't initialized properly.
|
|
if a.VPPInstaller == nil {
|
|
return "", errors.New("VPP installer not configured")
|
|
}
|
|
token, err := a.VPPInstaller.GetVPPTokenIfCanInstallVPPApps(ctx, appleDevice, host)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return a.VPPInstaller.InstallVPPAppPostValidation(ctx, host, vppApp, token, opts)
|
|
}
|
|
|
|
func (a *AppleMDM) installBootstrapPackage(ctx context.Context, hostUUID string, teamID *uint) (string, error) {
|
|
// GetMDMAppleBootstrapPackageMeta expects team id 0 for no team
|
|
var tmID uint
|
|
if teamID != nil {
|
|
tmID = *teamID
|
|
}
|
|
meta, err := a.Datastore.GetMDMAppleBootstrapPackageMeta(ctx, tmID)
|
|
if err != nil {
|
|
var nfe fleet.NotFoundError
|
|
if errors.As(err, &nfe) {
|
|
a.Log.InfoContext(ctx, "unable to find a bootstrap package for DEP enrolled device, skipping installation", "host_uuid", hostUUID)
|
|
return "", nil
|
|
}
|
|
|
|
return "", err
|
|
}
|
|
|
|
// Get CloudFront CDN signed URL if configured
|
|
url := a.getSignedURL(ctx, meta)
|
|
|
|
if url == "" {
|
|
appCfg, err := a.Datastore.AppConfig(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
url, err = meta.URL(appCfg.MDMUrl())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
manifest := appmanifest.NewFromSha(meta.Sha256, url)
|
|
cmdUUID := uuid.New().String()
|
|
err = a.Commander.InstallEnterpriseApplicationWithEmbeddedManifest(ctx, []string{hostUUID}, cmdUUID, manifest)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
err = a.Datastore.RecordHostBootstrapPackage(ctx, cmdUUID, hostUUID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
a.Log.InfoContext(ctx, "sent command to install bootstrap package", "host_uuid", hostUUID)
|
|
return cmdUUID, nil
|
|
}
|
|
|
|
func (a *AppleMDM) getSignedURL(ctx context.Context, meta *fleet.MDMAppleBootstrapPackage) string {
|
|
var url string
|
|
if a.BootstrapPackageStore != nil {
|
|
pkgID := hex.EncodeToString(meta.Sha256)
|
|
signedURL, err := a.BootstrapPackageStore.Sign(ctx, pkgID, fleet.BootstrapPackageSignedURLExpiry)
|
|
switch {
|
|
case errors.Is(err, fleet.ErrNotConfigured):
|
|
// no CDN configured, fall back to the MDM URL
|
|
case err != nil:
|
|
// log the error but continue with the MDM URL
|
|
a.Log.ErrorContext(ctx, "failed to sign bootstrap package URL", "err", err)
|
|
default:
|
|
exists, err := a.BootstrapPackageStore.Exists(ctx, pkgID)
|
|
switch {
|
|
case err != nil:
|
|
// log the error but continue with the MDM URL
|
|
a.Log.ErrorContext(ctx, "failed to check if bootstrap package exists", "err", err)
|
|
case !exists:
|
|
// log the error but continue with the MDM URL
|
|
a.Log.ErrorContext(ctx, "bootstrap package does not exist in package store", "pkg_id", pkgID)
|
|
default:
|
|
url = signedURL
|
|
}
|
|
}
|
|
}
|
|
return url
|
|
}
|
|
|
|
// installProfilesForEnrollingHost installs all configuration profiles for the host immediately after enrollment
|
|
// to speed up the setup experience process. This runs before the reconciler cycle.
|
|
//
|
|
// It delegates to apple_mdm.ReconcileProfilesForEnrollingHost, which reuses the
|
|
// same in-memory desired-state / label / diff pipeline that the batched
|
|
// cron uses — so the worker and cron can never drift on what should be
|
|
// installed for a given host. The worker still emits the
|
|
// DeclarativeManagement command afterwards so DDM syncs start as soon
|
|
// as enrollment completes.
|
|
func (a *AppleMDM) installProfilesForEnrollingHost(ctx context.Context, hostUUID string) ([]string, error) {
|
|
a.Log.InfoContext(ctx, "installing profiles post-enrollment", "host_uuid", hostUUID)
|
|
|
|
cmdUUIDs, err := apple_mdm.ReconcileProfilesForEnrollingHost(ctx, a.Datastore, a.Commander, a.Log, hostUUID, 0)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "reconcile apple profiles for enrolling host")
|
|
}
|
|
|
|
if len(cmdUUIDs) == 0 {
|
|
// We just skip sending the decl. management as well here even though there might be some, but it's not blocking so doesn't matter as much.
|
|
a.Log.InfoContext(ctx, "no profiles to install for enrolling host", "host_uuid", hostUUID)
|
|
return cmdUUIDs, nil
|
|
}
|
|
|
|
a.Log.InfoContext(ctx, "successfully queued profiles from apple mdm worker",
|
|
"host_uuid", hostUUID, "profiles_sent", len(cmdUUIDs))
|
|
|
|
// Send a DeclarativeManagement command to start a DDM sync. We don't
|
|
// block on DDM declarations being reconciled — they have their own
|
|
// cron — but kicking off the sync here means freshly-enrolled hosts
|
|
// don't wait a full cron tick for it.
|
|
declarativeManagementCmdUUID := uuid.NewString()
|
|
if err := a.Commander.DeclarativeManagement(ctx, []string{hostUUID}, declarativeManagementCmdUUID); err != nil {
|
|
a.Log.ErrorContext(ctx, "failed to send DeclarativeManagement command after installing profiles for enrolling host", "host_uuid", hostUUID, "error", err)
|
|
return cmdUUIDs, nil
|
|
}
|
|
cmdUUIDs = append(cmdUUIDs, declarativeManagementCmdUUID)
|
|
|
|
return cmdUUIDs, nil
|
|
}
|
|
|
|
// QueueAppleMDMJob queues a apple_mdm job for one of the supported tasks, to
|
|
// be processed asynchronously via the worker.
|
|
func QueueAppleMDMJob(
|
|
ctx context.Context,
|
|
ds fleet.Datastore,
|
|
logger *slog.Logger,
|
|
task AppleMDMTask,
|
|
hostUUID string,
|
|
platform string,
|
|
teamID *uint,
|
|
enrollReference string,
|
|
useWorkerDeviceRelease bool,
|
|
fromMDMMigration bool,
|
|
enrollmentCommandUUIDs ...string,
|
|
) error {
|
|
attrs := []interface{}{
|
|
"enabled", "true",
|
|
appleMDMJobName, task,
|
|
"host_uuid", hostUUID,
|
|
"platform", platform,
|
|
"with_enroll_reference", enrollReference != "",
|
|
"from_mdm_migration", fromMDMMigration,
|
|
}
|
|
if teamID != nil {
|
|
attrs = append(attrs, "team_id", *teamID)
|
|
}
|
|
if len(enrollmentCommandUUIDs) > 0 {
|
|
attrs = append(attrs, "enrollment_commands", fmt.Sprintf("%v", enrollmentCommandUUIDs))
|
|
}
|
|
logger.InfoContext(ctx, "queuing Apple MDM job", attrs...)
|
|
|
|
args := &appleMDMArgs{
|
|
Task: task,
|
|
HostUUID: hostUUID,
|
|
TeamID: teamID,
|
|
EnrollReference: enrollReference,
|
|
EnrollmentCommands: enrollmentCommandUUIDs,
|
|
Platform: platform,
|
|
UseWorkerDeviceRelease: useWorkerDeviceRelease,
|
|
FromMDMMigration: fromMDMMigration,
|
|
}
|
|
|
|
// the release device task is always added with a delay
|
|
var delay time.Duration
|
|
if task == AppleMDMPostDEPReleaseDeviceTask {
|
|
delay = 30 * time.Second
|
|
}
|
|
job, err := QueueJobWithDelay(ctx, ds, appleMDMJobName, args, delay)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "queueing job")
|
|
}
|
|
logger.DebugContext(ctx, "queued Apple MDM job", "job_id", job.ID)
|
|
return nil
|
|
}
|
|
|
|
// sendManagedAccounts enqueues an AccountConfiguration command for an sso and/or
|
|
// a breakglass admin account.
|
|
func (a *AppleMDM) sendManagedAccounts(
|
|
ctx context.Context,
|
|
args *appleMDMArgs,
|
|
ssoAccount *fleet.MDMIdPAccount,
|
|
adminAccount *apple_mdm.AdminAccountConfig,
|
|
lockPrimaryAccountInfo bool,
|
|
cmdUUID string,
|
|
) error {
|
|
var ssoConfig *apple_mdm.SSOAccountConfig
|
|
if ssoAccount != nil {
|
|
fullName, err := a.getIdPDisplayName(ctx, ssoAccount, *args)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting idp account display name")
|
|
}
|
|
a.Log.InfoContext(ctx, "setting username and fullname", "host_uuid", args.HostUUID)
|
|
ssoConfig = &apple_mdm.SSOAccountConfig{
|
|
FullName: fullName,
|
|
UserName: ssoAccount.Username,
|
|
LockPrimaryAccountInfo: lockPrimaryAccountInfo,
|
|
}
|
|
}
|
|
|
|
if err := a.Commander.AccountConfiguration(ctx, []string{args.HostUUID}, cmdUUID, ssoConfig, adminAccount); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending AccountConfiguration command")
|
|
}
|
|
|
|
return nil
|
|
}
|