<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #39281 # Checklist for submitter - [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 - [x] QA'd all new/changed functionality manually https://github.com/user-attachments/assets/8ef7c66d-f8a6-46d5-ba17-66c1df8171b6 https://github.com/user-attachments/assets/6f67b49b-daf1-4acd-ad0f-55a7d1225a44 ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added 29 iOS/iPadOS device vitals to host details and API responses. * Added a “View all” modal for browsing complete device vitals, including subscriptions and accessibility details. * Added support for cellular technology, organization information, attestation data, and device settings. * **Privacy** * Personal/BYOD enrollments exclude expanded device-vitals fields. * Sensitive vitals are removed from device URL-authenticated responses. * **Bug Fixes** * Stale vitals are cleared when enrollment ownership changes or hosts are deleted. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
934 lines
31 KiB
Go
934 lines
31 KiB
Go
package apple_mdm
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/appmanifest"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
|
mdmcrypto "github.com/fleetdm/fleet/v4/server/mdm/crypto"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
|
nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
|
|
"github.com/micromdm/plist"
|
|
)
|
|
|
|
// commandPayload is the common structure all MDM commands use
|
|
type commandPayload struct {
|
|
CommandUUID string
|
|
Command any
|
|
}
|
|
|
|
// MDMAppleCommander contains methods to enqueue commands managed by Fleet and
|
|
// send push notifications to hosts.
|
|
//
|
|
// It's intentionally decoupled from fleet.Service so it can be used internally
|
|
// in crons and other services, leaving authentication/permission handling to
|
|
// the caller.
|
|
type MDMAppleCommander struct {
|
|
storage fleet.MDMAppleStore
|
|
pusher nanomdm_push.Pusher
|
|
}
|
|
|
|
// NewMDMAppleCommander creates a new commander instance.
|
|
func NewMDMAppleCommander(mdmStorage fleet.MDMAppleStore, mdmPushService nanomdm_push.Pusher) *MDMAppleCommander {
|
|
return &MDMAppleCommander{
|
|
storage: mdmStorage,
|
|
pusher: mdmPushService,
|
|
}
|
|
}
|
|
|
|
// InstallProfile sends the homonymous MDM command to the given hosts, it also
|
|
// takes care of the base64 encoding of the provided profile bytes.
|
|
func (svc *MDMAppleCommander) InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string, name string) error {
|
|
raw, err := svc.SignAndEncodeInstallProfile(ctx, profile, uuid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cmd, err := mdm.DecodeCommand([]byte(raw))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "decoding InstallProfile command")
|
|
}
|
|
err = svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone, name)
|
|
return ctxerr.Wrap(ctx, err, "commander install profile")
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) SignAndEncodeInstallProfile(ctx context.Context, profile []byte, commandUUID string) (string, error) {
|
|
signedProfile, err := mdmcrypto.Sign(ctx, profile, svc.storage)
|
|
if err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "signing profile")
|
|
}
|
|
|
|
base64Profile := base64.StdEncoding.EncodeToString(signedProfile)
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>RequestType</key>
|
|
<string>InstallProfile</string>
|
|
<key>Payload</key>
|
|
<data>%s</data>
|
|
</dict>
|
|
</dict>
|
|
</plist>`, commandUUID, base64Profile)
|
|
return raw, nil
|
|
}
|
|
|
|
// RemoveProfile sends the homonymous MDM command to the given hosts.
|
|
func (svc *MDMAppleCommander) RemoveProfile(ctx context.Context, hostUUIDs []string, profileIdentifier string, uuid string, name string) error {
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>RequestType</key>
|
|
<string>RemoveProfile</string>
|
|
<key>Identifier</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</dict>
|
|
</plist>`, uuid, profileIdentifier)
|
|
cmd, err := mdm.DecodeCommand([]byte(raw))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "decoding RemoveProfile command")
|
|
}
|
|
err = svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone, name)
|
|
return ctxerr.Wrap(ctx, err, "commander remove profile")
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, host *fleet.Host, uuid string) (unlockPIN string, err error) {
|
|
// Check for existing pending lock command first
|
|
existingCmd, existingPIN, err := svc.storage.GetPendingLockCommand(ctx, host.UUID)
|
|
if err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "checking for pending lock command")
|
|
}
|
|
|
|
// If a pending lock command exists, just send a push notification and return the existing PIN
|
|
if existingCmd != nil {
|
|
if err := svc.SendNotifications(ctx, []string{host.UUID}); err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "sending notifications for existing DeviceLock")
|
|
}
|
|
return existingPIN, nil
|
|
}
|
|
|
|
// No pending lock, create a new one
|
|
unlockPIN, err = GenerateRandomPin(6)
|
|
if err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "generating random PIN for DeviceLock")
|
|
}
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>RequestType</key>
|
|
<string>DeviceLock</string>
|
|
<key>PIN</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</dict>
|
|
</plist>`, uuid, unlockPIN,
|
|
)
|
|
|
|
cmd, err := mdm.DecodeCommand([]byte(raw))
|
|
if err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "decoding command")
|
|
}
|
|
|
|
if err := svc.storage.EnqueueDeviceLockCommand(ctx, host, cmd, unlockPIN); err != nil {
|
|
// Check if another request just created a lock
|
|
type conflictInterface interface {
|
|
IsConflict() bool
|
|
}
|
|
if c, ok := err.(conflictInterface); ok && c.IsConflict() {
|
|
// Another goroutine won the race, fetch the command that was created
|
|
existingCmd, existingPIN, err := svc.storage.GetPendingLockCommand(ctx, host.UUID)
|
|
if err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "getting existing lock after race condition")
|
|
}
|
|
if existingCmd != nil {
|
|
// Send push notification for the existing command and return its PIN
|
|
if pushErr := svc.SendNotifications(ctx, []string{host.UUID}); pushErr != nil {
|
|
// Log the push error but still return the PIN since the command exists
|
|
// The push can be retried on subsequent requests
|
|
ctxerr.Handle(ctx, ctxerr.Wrap(ctx, pushErr, "failed to send push notification after lock race"))
|
|
return existingPIN, nil
|
|
}
|
|
return existingPIN, nil
|
|
}
|
|
// This shouldn't happen, but if we can't find the command, return the original error
|
|
return "", ctxerr.Wrap(ctx, err, "lock command conflict but no existing command found")
|
|
}
|
|
return "", ctxerr.Wrap(ctx, err, "enqueuing for DeviceLock")
|
|
}
|
|
|
|
if err := svc.SendNotifications(ctx, []string{host.UUID}); err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "sending notifications for DeviceLock")
|
|
}
|
|
|
|
return unlockPIN, nil
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) EnableLostMode(ctx context.Context, host *fleet.Host, commandUUID string, orgName string) error {
|
|
msg := fmt.Sprintf("This device is locked. It belongs to %s.", orgName)
|
|
cmdPayload := commandPayload{
|
|
CommandUUID: commandUUID,
|
|
Command: map[string]any{
|
|
"RequestType": "EnableLostMode",
|
|
"Message": msg,
|
|
},
|
|
}
|
|
rawBytes, err := plist.MarshalIndent(cmdPayload, " ")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "marshalling EnableLostMode payload")
|
|
}
|
|
raw := string(rawBytes)
|
|
|
|
cmd, err := mdm.DecodeCommand([]byte(raw))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "decoding EnableLostMode command")
|
|
}
|
|
|
|
if err := svc.storage.EnqueueDeviceLockCommand(ctx, host, cmd, ""); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueuing for EnableLostMode")
|
|
}
|
|
|
|
if err := svc.SendNotifications(ctx, []string{host.UUID}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending notifications for EnableLostMode")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) DisableLostMode(ctx context.Context, host *fleet.Host, commandUUID string) error {
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>RequestType</key>
|
|
<string>DisableLostMode</string>
|
|
</dict>
|
|
</dict>
|
|
</plist>`, commandUUID)
|
|
|
|
cmd, err := mdm.DecodeCommand([]byte(raw))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "decoding command for DisableLostMode")
|
|
}
|
|
|
|
if err := svc.storage.EnqueueDeviceUnlockCommand(ctx, host, cmd); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueuing device unlock command for DisableLostMode")
|
|
}
|
|
|
|
if err := svc.SendNotifications(ctx, []string{host.UUID}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending notifications for DisableLostMode")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, host *fleet.Host, uuid string) error {
|
|
pin, err := GenerateRandomPin(6)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "generating random PIN for EraseDevice")
|
|
}
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>RequestType</key>
|
|
<string>EraseDevice</string>
|
|
<key>PIN</key>
|
|
<string>%s</string>
|
|
<key>ObliterationBehavior</key>
|
|
<string>Default</string>
|
|
</dict>
|
|
</dict>
|
|
</plist>`, uuid, pin)
|
|
|
|
cmd, err := mdm.DecodeCommand([]byte(raw))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "decoding DeviceWipe command")
|
|
}
|
|
|
|
if err := svc.storage.EnqueueDeviceWipeCommand(ctx, host, cmd); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueuing for DeviceWipe")
|
|
}
|
|
|
|
if err := svc.SendNotifications(ctx, []string{host.UUID}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending notifications for DeviceWipe")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error {
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>ManifestURL</key>
|
|
<string>%s</string>
|
|
<key>RequestType</key>
|
|
<string>InstallEnterpriseApplication</string>
|
|
</dict>
|
|
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</plist>`, manifestURL, uuid)
|
|
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
|
|
}
|
|
|
|
type installEnterpriseApplicationPayload struct {
|
|
Manifest *appmanifest.Manifest
|
|
RequestType string
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) InstallEnterpriseApplicationWithEmbeddedManifest(
|
|
ctx context.Context,
|
|
hostUUIDs []string,
|
|
uuid string,
|
|
manifest *appmanifest.Manifest,
|
|
) error {
|
|
cmd := commandPayload{
|
|
CommandUUID: uuid,
|
|
Command: installEnterpriseApplicationPayload{
|
|
RequestType: "InstallEnterpriseApplication",
|
|
Manifest: manifest,
|
|
},
|
|
}
|
|
|
|
raw, err := plist.Marshal(cmd)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal command payload plist: %w", err)
|
|
}
|
|
|
|
return svc.EnqueueCommand(ctx, hostUUIDs, string(raw))
|
|
}
|
|
|
|
// SSOAccountConfig holds the SSO (end-user authentication) parameters for an
|
|
// AccountConfiguration MDM command.
|
|
type SSOAccountConfig struct {
|
|
FullName string
|
|
UserName string
|
|
LockPrimaryAccountInfo bool
|
|
}
|
|
|
|
// AdminAccountConfig holds the parameters for an AutoSetupAdminAccounts entry
|
|
// in an AccountConfiguration MDM command.
|
|
type AdminAccountConfig struct {
|
|
ShortName string // e.g. "_fleetadmin"
|
|
FullName string // e.g. "Fleet Admin"
|
|
PasswordHash []byte // SALTED-SHA512-PBKDF2 plist from GenerateSaltedSHA512PBKDF2Hash
|
|
Hidden bool // true → hidden from login window
|
|
PrimaryAccountType fleet.PrimaryAccountType // admin, standard, or none
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) AccountConfiguration(ctx context.Context, hostUUIDs []string,
|
|
cmdUUID string,
|
|
ssoAccount *SSOAccountConfig,
|
|
adminAccount *AdminAccountConfig,
|
|
) error {
|
|
var payload string
|
|
|
|
// Send primary account info if we have an SSO account, and no adminAccount config or the primary account type is not "none"
|
|
if ssoAccount != nil && (adminAccount == nil || adminAccount.PrimaryAccountType != fleet.PrimaryAccountTypeNone) {
|
|
payload += fmt.Sprintf(`
|
|
<key>PrimaryAccountFullName</key>
|
|
<string>%s</string>
|
|
<key>PrimaryAccountUserName</key>
|
|
<string>%s</string>
|
|
<key>LockPrimaryAccountInfo</key>
|
|
<%t />
|
|
`, ssoAccount.FullName, ssoAccount.UserName, ssoAccount.LockPrimaryAccountInfo)
|
|
}
|
|
|
|
if adminAccount != nil {
|
|
switch adminAccount.PrimaryAccountType {
|
|
case fleet.PrimaryAccountTypeStandard:
|
|
payload += `
|
|
<key>SetPrimarySetupAccountAsRegularUser</key>
|
|
<true />
|
|
`
|
|
case fleet.PrimaryAccountTypeNone:
|
|
payload += `
|
|
<key>SkipPrimarySetupAccountCreation</key>
|
|
<true />
|
|
`
|
|
default:
|
|
// no-op for admin account type as that is default
|
|
}
|
|
|
|
passwordHashEncoded := base64.StdEncoding.EncodeToString(adminAccount.PasswordHash)
|
|
payload += fmt.Sprintf(`
|
|
<key>AutoSetupAdminAccounts</key>
|
|
<array>
|
|
<dict>
|
|
<key>hidden</key>
|
|
<%t />
|
|
<key>passwordHash</key>
|
|
<data>%s</data>
|
|
<key>shortName</key>
|
|
<string>%s</string>
|
|
<key>fullName</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</array>
|
|
`, adminAccount.Hidden, passwordHashEncoded, adminAccount.ShortName, adminAccount.FullName)
|
|
}
|
|
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Command</key>
|
|
<dict>%s
|
|
<key>RequestType</key>
|
|
<string>AccountConfiguration</string>
|
|
</dict>
|
|
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</plist>`, payload, cmdUUID)
|
|
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
|
|
}
|
|
|
|
// DeclarativeManagement sends the homonym [command][1] to the device to enable DDM or start a new DDM session.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/declarativemanagementcommand
|
|
func (svc *MDMAppleCommander) DeclarativeManagement(ctx context.Context, hostUUIDs []string, uuid string) error {
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>RequestType</key>
|
|
<string>DeclarativeManagement</string>
|
|
</dict>
|
|
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</plist>`, uuid)
|
|
|
|
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) DeviceConfigured(ctx context.Context, hostUUID, cmdUUID string) error {
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>RequestType</key>
|
|
<string>DeviceConfigured</string>
|
|
</dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</plist>`, cmdUUID)
|
|
|
|
return svc.EnqueueCommand(ctx, []string{hostUUID}, raw)
|
|
}
|
|
|
|
var byodDeviceInformationQueryKeys = []string{
|
|
"DeviceName",
|
|
"DeviceCapacity",
|
|
"AvailableDeviceCapacity",
|
|
"OSVersion",
|
|
"SupplementalOSVersionExtra",
|
|
"WiFiMAC",
|
|
"ProductName",
|
|
"IsMDMLostModeEnabled",
|
|
"TimeZone",
|
|
}
|
|
|
|
// deviceInformationQueryKeys are the Apple query keys requested in a
|
|
// DeviceInformation command's <Queries> array for non-personal
|
|
// (company-owned) hosts, in request order.
|
|
var deviceInformationQueryKeys = []string{
|
|
"DeviceName",
|
|
"DeviceCapacity",
|
|
"AvailableDeviceCapacity",
|
|
"OSVersion",
|
|
"SupplementalOSVersionExtra",
|
|
"WiFiMAC",
|
|
"ProductName",
|
|
"IsMDMLostModeEnabled",
|
|
"TimeZone",
|
|
"AccessibilitySettings",
|
|
"AppAnalyticsEnabled",
|
|
"AwaitingConfiguration",
|
|
"BatteryLevel",
|
|
"BluetoothMAC",
|
|
"CellularTechnology",
|
|
"DataRoamingEnabled",
|
|
"DevicePropertiesAttestation",
|
|
"DiagnosticSubmissionEnabled",
|
|
"EASDeviceIdentifier",
|
|
"IsCloudBackupEnabled",
|
|
"IsDeviceLocatorServiceEnabled",
|
|
"IsDoNotDisturbInEffect",
|
|
"IsNetworkTethered",
|
|
"iTunesStoreAccountHash",
|
|
"iTunesStoreAccountIsActive",
|
|
"LastCloudBackupDate",
|
|
"MDMOptions",
|
|
"ModelNumber",
|
|
"ModemFirmwareVersion",
|
|
"OrganizationInfo",
|
|
"PersonalHotspotEnabled",
|
|
"PushToken",
|
|
"ServiceSubscriptions",
|
|
"SupplementalBuildVersion",
|
|
"UDID",
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) DeviceInformation(ctx context.Context, hostUUIDs []string, cmdUUID string, isPersonalEnrollment bool) error {
|
|
keys := deviceInformationQueryKeys
|
|
if isPersonalEnrollment {
|
|
keys = byodDeviceInformationQueryKeys
|
|
}
|
|
|
|
var queries strings.Builder
|
|
for _, key := range keys {
|
|
queries.WriteString(" <string>")
|
|
queries.WriteString(key)
|
|
queries.WriteString("</string>\n")
|
|
}
|
|
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>Queries</key>
|
|
<array>
|
|
%s </array>
|
|
<key>RequestType</key>
|
|
<string>DeviceInformation</string>
|
|
</dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</plist>`, queries.String(), cmdUUID)
|
|
|
|
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
|
|
}
|
|
|
|
// deviceNameSettingCommand builds the raw Settings/DeviceName command used to
|
|
// rename a device.
|
|
func deviceNameSettingCommand(deviceName, cmdUUID string) (string, error) {
|
|
escaped, err := mobileconfig.XMLEscapeString(deviceName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>RequestType</key>
|
|
<string>Settings</string>
|
|
<key>Settings</key>
|
|
<array>
|
|
<dict>
|
|
<key>Item</key>
|
|
<string>DeviceName</string>
|
|
<key>DeviceName</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</array>
|
|
</dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</plist>`, escaped, cmdUUID), nil
|
|
}
|
|
|
|
// DeviceNameSetting sends the Settings command with a DeviceName item to rename the device.
|
|
// Requires supervision on iOS/iPadOS.
|
|
func (svc *MDMAppleCommander) DeviceNameSetting(ctx context.Context, hostUUID, cmdUUID, deviceName string) error {
|
|
raw, err := deviceNameSettingCommand(deviceName, cmdUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "escaping device name for XML")
|
|
}
|
|
return svc.EnqueueCommand(ctx, []string{hostUUID}, raw)
|
|
}
|
|
|
|
// DeviceNameSettingWithoutNotifications is like DeviceNameSetting but only
|
|
// enqueues the command; it does not send an APNs push. The caller must invoke
|
|
// SendNotifications afterwards. This lets a bulk sender enqueue one command per
|
|
// host and then wake every device with a single batched push instead of one
|
|
// APNs request per host.
|
|
func (svc *MDMAppleCommander) DeviceNameSettingWithoutNotifications(ctx context.Context, hostUUID, cmdUUID, deviceName string) error {
|
|
raw, err := deviceNameSettingCommand(deviceName, cmdUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "escaping device name for XML")
|
|
}
|
|
cmd, err := mdm.DecodeCommand([]byte(raw))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "decoding command")
|
|
}
|
|
if _, err := svc.storage.EnqueueCommand(ctx, []string{hostUUID},
|
|
&mdm.CommandWithSubtype{Command: *cmd, Subtype: mdm.CommandSubtypeNone}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueuing for DeviceName")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) InstalledApplicationList(ctx context.Context, hostUUIDs []string, cmdUUID string, managedOnly bool) error {
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>ManagedAppsOnly</key>
|
|
<%t/>
|
|
<key>RequestType</key>
|
|
<string>InstalledApplicationList</string>
|
|
<key>Items</key>
|
|
<array>
|
|
<string>Name</string>
|
|
<string>ShortVersion</string>
|
|
<string>Identifier</string>
|
|
<string>Installing</string>
|
|
</array>
|
|
</dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</plist>`, managedOnly, cmdUUID)
|
|
|
|
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
|
|
}
|
|
|
|
// CertificateList sends the homonym [command][1] to the device to get a list of installed
|
|
// certificates on the device.
|
|
//
|
|
// Note that user-enrolled devices ignore the [ManagedOnly][2] value set below and will always
|
|
// include only managed certificates. This is a limitation imposed by Apple.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/certificatelistcommand
|
|
// [2]: https://developer.apple.com/documentation/devicemanagement/certificatelistcommand/command-data.dictionary
|
|
func (svc *MDMAppleCommander) CertificateList(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>ManagedOnly</key>
|
|
<false/>
|
|
<key>RequestType</key>
|
|
<string>CertificateList</string>
|
|
</dict>
|
|
</dict>
|
|
</plist>`, cmdUUID)
|
|
|
|
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) DeviceLocation(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>RequestType</key>
|
|
<string>DeviceLocation</string>
|
|
</dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</plist>
|
|
`, cmdUUID)
|
|
|
|
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) ClearPasscode(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>RequestType</key>
|
|
<string>ClearPasscode</string>
|
|
<key>UnlockToken</key>
|
|
<data>%s</data>
|
|
</dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</plist>`, "$"+fleet.HostSecretPrefix+fleet.HostSecretMDMUnlockToken, cmdUUID)
|
|
|
|
// We skip EnqueueCommand here, to avoid decoding the command as <data> is binary, which fails to decode with placeholder.
|
|
cmd := &mdm.Command{
|
|
CommandUUID: cmdUUID,
|
|
Raw: []byte(raw),
|
|
}
|
|
cmd.Command.RequestType = fleet.AppleMDMCommandTypeClearPasscode
|
|
|
|
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone, "")
|
|
}
|
|
|
|
// EnqueueCommand takes care of enqueuing the commands and sending push
|
|
// notifications to the devices.
|
|
//
|
|
// Always sending the push notification when a command is enqueued was decided
|
|
// internally, leaving making pushes optional as an optimization to be tackled
|
|
// later.
|
|
func (svc *MDMAppleCommander) EnqueueCommand(ctx context.Context, hostUUIDs []string, rawCommand string) error {
|
|
cmd, err := mdm.DecodeCommand([]byte(rawCommand))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "decoding command")
|
|
}
|
|
|
|
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone, "")
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs []string, cmd *mdm.Command,
|
|
subtype mdm.CommandSubtype, name string,
|
|
) error {
|
|
if _, err := svc.storage.EnqueueCommand(ctx, hostUUIDs,
|
|
&mdm.CommandWithSubtype{Command: *cmd, Subtype: subtype, Name: name}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueuing command")
|
|
}
|
|
|
|
if err := svc.SendNotifications(ctx, hostUUIDs); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending notifications")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnqueueCommandInstallProfileWithSecrets is a special case of EnqueueCommand that does not expand secret variables.
|
|
// Secret variables are expanded when the command is sent to the device, and secrets are never stored in the database unencrypted.
|
|
func (svc *MDMAppleCommander) EnqueueCommandInstallProfileWithSecrets(ctx context.Context, hostUUIDs []string,
|
|
rawCommand mobileconfig.Mobileconfig, commandUUID string, name string,
|
|
) error {
|
|
cmd := &mdm.Command{
|
|
CommandUUID: commandUUID,
|
|
Raw: []byte(rawCommand),
|
|
}
|
|
cmd.Command.RequestType = "InstallProfile"
|
|
|
|
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeProfileWithSecrets, name)
|
|
}
|
|
|
|
func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs []string) error {
|
|
apnsResponses, err := svc.pusher.Push(ctx, hostUUIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "commander push")
|
|
}
|
|
|
|
// Even if we didn't get an error, some of the APNs
|
|
// responses might have failed, signal that to the caller.
|
|
failed := map[string]error{}
|
|
for uuid, response := range apnsResponses {
|
|
if response.Err != nil {
|
|
failed[uuid] = response.Err
|
|
}
|
|
}
|
|
|
|
if len(failed) > 0 {
|
|
return &APNSDeliveryError{errorsByUUID: failed}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// BulkDeleteHostUserCommandsWithoutResults calls the storage method with the same name.
|
|
func (svc *MDMAppleCommander) BulkDeleteHostUserCommandsWithoutResults(ctx context.Context, commandToIDs map[string][]string) error {
|
|
return svc.storage.BulkDeleteHostUserCommandsWithoutResults(ctx, commandToIDs)
|
|
}
|
|
|
|
// SetRecoveryLock sends the SetRecoveryLock MDM command to set the recovery lock password.
|
|
// The password is not included in the command - instead, a placeholder is used that will be
|
|
// expanded at delivery time by looking up the password from host_recovery_key_passwords.
|
|
// The password must be stored (via SetHostsRecoveryLockPasswords) BEFORE calling this method.
|
|
// See https://developer.apple.com/documentation/devicemanagement/set_recovery_lock
|
|
func (svc *MDMAppleCommander) SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
|
// Use the host secret placeholder - the actual password will be injected at delivery time
|
|
// by ExpandHostSecrets, which looks up the password from host_recovery_key_passwords.
|
|
cmdPayload := commandPayload{
|
|
CommandUUID: cmdUUID,
|
|
Command: map[string]any{
|
|
"RequestType": "SetRecoveryLock",
|
|
"NewPassword": "$" + fleet.HostSecretPrefix + fleet.HostSecretRecoveryLockPassword,
|
|
},
|
|
}
|
|
rawBytes, err := plist.MarshalIndent(cmdPayload, " ")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "marshalling SetRecoveryLock payload")
|
|
}
|
|
|
|
if err := svc.EnqueueCommand(ctx, hostUUIDs, string(rawBytes)); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueuing SetRecoveryLock command")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClearRecoveryLock sends the SetRecoveryLock MDM command to clear the recovery lock password.
|
|
// The CurrentPassword is a placeholder that will be expanded at delivery time by looking up
|
|
// the existing password from host_recovery_key_passwords. NewPassword is empty to clear the lock.
|
|
// See https://developer.apple.com/documentation/devicemanagement/set_recovery_lock
|
|
func (svc *MDMAppleCommander) ClearRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
|
cmdPayload := commandPayload{
|
|
CommandUUID: cmdUUID,
|
|
Command: map[string]any{
|
|
"RequestType": "SetRecoveryLock",
|
|
"CurrentPassword": "$" + fleet.HostSecretPrefix + fleet.HostSecretRecoveryLockPassword,
|
|
"NewPassword": "",
|
|
},
|
|
}
|
|
rawBytes, err := plist.MarshalIndent(cmdPayload, " ")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "marshalling ClearRecoveryLock payload")
|
|
}
|
|
|
|
if err := svc.EnqueueCommand(ctx, hostUUIDs, string(rawBytes)); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueuing ClearRecoveryLock command")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetAutoAdminPassword sends the SetAutoAdminPassword command to rotate the password
|
|
// of a managed local administrator account previously provisioned by an
|
|
// AutoSetupAdminAccounts entry in an AccountConfiguration command.
|
|
//
|
|
// guid is the account UUID captured from osquery on this host (NOT the host UUID).
|
|
// passwordHashPlist is the SALTED-SHA512-PBKDF2 plist returned by
|
|
// GenerateSaltedSHA512PBKDF2Hash; we base64-encode it into the <data> field of the
|
|
// outer command plist, matching how AccountConfiguration carries its passwordHash.
|
|
//
|
|
// See https://developer.apple.com/documentation/devicemanagement/setautoadminpasswordcommand
|
|
func (svc *MDMAppleCommander) SetAutoAdminPassword(ctx context.Context, hostUUID, guid string, passwordHashPlist []byte, cmdUUID string) error {
|
|
passwordHashEncoded := base64.StdEncoding.EncodeToString(passwordHashPlist)
|
|
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Command</key>
|
|
<dict>
|
|
<key>RequestType</key>
|
|
<string>SetAutoAdminPassword</string>
|
|
<key>GUID</key>
|
|
<string>%s</string>
|
|
<key>passwordHash</key>
|
|
<data>%s</data>
|
|
</dict>
|
|
<key>CommandUUID</key>
|
|
<string>%s</string>
|
|
</dict>
|
|
</plist>`, guid, passwordHashEncoded, cmdUUID)
|
|
|
|
if err := svc.EnqueueCommand(ctx, []string{hostUUID}, raw); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueuing SetAutoAdminPassword command")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RotateRecoveryLock sends the SetRecoveryLock MDM command to rotate the recovery lock password.
|
|
// Both CurrentPassword and NewPassword are placeholders that will be expanded at delivery time.
|
|
// CurrentPassword is the existing password from encrypted_password column.
|
|
// NewPassword is the new password from pending_encrypted_password column.
|
|
// See https://developer.apple.com/documentation/devicemanagement/set_recovery_lock
|
|
func (svc *MDMAppleCommander) RotateRecoveryLock(ctx context.Context, hostUUID string, cmdUUID string) error {
|
|
cmdPayload := commandPayload{
|
|
CommandUUID: cmdUUID,
|
|
Command: map[string]any{
|
|
"RequestType": "SetRecoveryLock",
|
|
"CurrentPassword": "$" + fleet.HostSecretPrefix + fleet.HostSecretRecoveryLockPassword,
|
|
"NewPassword": "$" + fleet.HostSecretPrefix + fleet.HostSecretRecoveryLockPendingPassword,
|
|
},
|
|
}
|
|
rawBytes, err := plist.MarshalIndent(cmdPayload, " ")
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "marshalling RotateRecoveryLock payload")
|
|
}
|
|
|
|
if err := svc.EnqueueCommand(ctx, []string{hostUUID}, string(rawBytes)); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueuing RotateRecoveryLock command")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// APNSDeliveryError records an error and the associated host UUIDs in which it
|
|
// occurred.
|
|
type APNSDeliveryError struct {
|
|
errorsByUUID map[string]error
|
|
}
|
|
|
|
func (e *APNSDeliveryError) Error() string {
|
|
var uuids []string
|
|
for uuid := range e.errorsByUUID {
|
|
uuids = append(uuids, uuid)
|
|
}
|
|
|
|
// sort UUIDs alphabetically for deterministic output
|
|
sort.Strings(uuids)
|
|
|
|
var errStrings []string
|
|
for _, uuid := range uuids {
|
|
errStrings = append(errStrings, fmt.Sprintf("UUID: %s, Error: %v", uuid, e.errorsByUUID[uuid]))
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
"APNS delivery failed with the following errors:\n%s",
|
|
strings.Join(errStrings, "\n"),
|
|
)
|
|
}
|
|
|
|
func (e *APNSDeliveryError) FailedUUIDs() []string {
|
|
var uuids []string
|
|
for uuid := range e.errorsByUUID {
|
|
uuids = append(uuids, uuid)
|
|
}
|
|
|
|
// sort UUIDs alphabetically for deterministic output
|
|
sort.Strings(uuids)
|
|
return uuids
|
|
}
|
|
|
|
func (e *APNSDeliveryError) StatusCode() int { return http.StatusBadGateway }
|