diff --git a/changes/feature_19010-ipad-ios-wipe b/changes/feature_19010-ipad-ios-wipe new file mode 100644 index 0000000000..872132eea9 --- /dev/null +++ b/changes/feature_19010-ipad-ios-wipe @@ -0,0 +1 @@ +* Added support to wipe iOS/iPadOS devices. diff --git a/cmd/fleetctl/mdm.go b/cmd/fleetctl/mdm.go index b21231dd9f..fabd2539b7 100644 --- a/cmd/fleetctl/mdm.go +++ b/cmd/fleetctl/mdm.go @@ -316,7 +316,6 @@ func hostMdmActionSetup(c *cli.Context, hostIdent string, actionType string) (cl if err != nil { var nfe service.NotFoundErr if errors.As(err, &nfe) { - fmt.Println(hostIdent) return nil, nil, errors.New("The host doesn't exist. Please provide a valid host identifier.") } diff --git a/ee/server/service/hosts.go b/ee/server/service/hosts.go index a4c0a5bd06..e73ae027a4 100644 --- a/ee/server/service/hosts.go +++ b/ee/server/service/hosts.go @@ -58,6 +58,8 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) (unlockPIN string // locking validations are based on the platform of the host switch host.FleetPlatform() { + case "ios", "ipados": + return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock iOS or iPadOS hosts. Use wipe instead.")) case "darwin": if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { if errors.Is(err, fleet.ErrMDMNotConfigured) { @@ -176,7 +178,7 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) // locking validations are based on the platform of the host switch host.FleetPlatform() { - case "darwin": + case "darwin", "ios", "ipados": // all good, no need to check if MDM enrolled, will validate later that it // is currently locked. @@ -267,7 +269,7 @@ func (svc *Service) WipeHost(ctx context.Context, hostID uint) error { // uses scripts, not MDM. var requireMDM bool switch host.FleetPlatform() { - case "darwin": + case "darwin", "ios", "ipados": if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { if errors.Is(err, fleet.ErrMDMNotConfigured) { err = fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) @@ -471,7 +473,7 @@ func (svc *Service) enqueueWipeHostRequest(ctx context.Context, host *fleet.Host } switch wipeStatus.HostFleetPlatform { - case "darwin": + case "darwin", "ios", "ipados": wipeCommandUUID := uuid.NewString() if err := svc.mdmAppleCommander.EraseDevice(ctx, host, wipeCommandUUID); err != nil { return ctxerr.Wrap(ctx, err, "enqueuing wipe request for darwin") diff --git a/frontend/interfaces/platform.ts b/frontend/interfaces/platform.ts index d01e043a09..ec2e11efd3 100644 --- a/frontend/interfaces/platform.ts +++ b/frontend/interfaces/platform.ts @@ -68,6 +68,8 @@ export const HOST_LINUX_PLATFORMS = [ "tuxedo", ] as const; +export const HOST_APPLE_PLATFORMS = ["darwin", "ios", "ipados"] as const; + /** * Checks if the provided platform is a Linux-like OS. We can recieve many * different types of host platforms so we need a check that will cover all @@ -78,3 +80,9 @@ export const isLinuxLike = (platform: string) => { platform as typeof HOST_LINUX_PLATFORMS[number] ); }; + +export const isAppleDevice = (platform: string) => { + return HOST_APPLE_PLATFORMS.includes( + platform as typeof HOST_APPLE_PLATFORMS[number] + ); +}; diff --git a/frontend/interfaces/script.ts b/frontend/interfaces/script.ts index 0d3acce6e3..67d9572052 100644 --- a/frontend/interfaces/script.ts +++ b/frontend/interfaces/script.ts @@ -9,7 +9,7 @@ export interface IScript { } export const isScriptSupportedPlatform = (hostPlatform: string) => - ["darwin", "windows", ...HOST_LINUX_PLATFORMS].includes(hostPlatform); // excludes chrome, see also https://github.com/fleetdm/fleet/blob/5a21e2cfb029053ddad0508869eb9f1f23997bf2/server/fleet/hosts.go#L775 + ["darwin", "windows", ...HOST_LINUX_PLATFORMS].includes(hostPlatform); // excludes chrome, ios, ipados see also https://github.com/fleetdm/fleet/blob/5a21e2cfb029053ddad0508869eb9f1f23997bf2/server/fleet/hosts.go#L775 export type IScriptExecutionStatus = "ran" | "pending" | "error"; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx index 82a7f85dac..dbde20119a 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx @@ -785,7 +785,7 @@ describe("Host Actions Dropdown", () => { expect(screen.queryByText("Unlock")).not.toBeInTheDocument(); }); - it("does not renders when a mac host but does not have Fleet mac mdm enabled and configured", async () => { + it("does not renders when a macOS host but does not have Fleet mac mdm enabled and configured", async () => { const render = createCustomRenderer({ context: { app: { @@ -921,7 +921,7 @@ describe("Host Actions Dropdown", () => { expect(screen.queryByText("Wipe")).not.toBeInTheDocument(); }); - it("does not renders when a mac host but does not have Fleet mac mdm enabled and configured", async () => { + it("does not renders when a macOS host but does not have Fleet macOS mdm enabled and configured", async () => { const render = createCustomRenderer({ context: { app: { @@ -1139,55 +1139,85 @@ describe("Host Actions Dropdown", () => { }); }); - describe("Does not render dropdown for certain platforms", () => { - it("does not render dropdown for iOS", async () => { + describe("Render options only available for iOS and iPadOS", () => { + it("renders only the transfer, wipe, and delete options for iOS", async () => { const render = createCustomRenderer({ context: { app: { + isPremiumTier: true, isGlobalAdmin: true, + isMacMdmEnabledAndConfigured: true, currentUser: createMockUser(), }, }, }); - render( + const { user } = render( ); - expect(screen.queryByText("Actions")).not.toBeInTheDocument(); + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Transfer")).toBeInTheDocument(); + expect(screen.queryByText("Wipe")).toBeInTheDocument(); + expect(screen.queryByText("Delete")).toBeInTheDocument(); + + expect(screen.queryByText("Query")).not.toBeInTheDocument(); + expect(screen.queryByText("Run script")).not.toBeInTheDocument(); + expect( + screen.queryByText("Show disk encryption key") + ).not.toBeInTheDocument(); + expect(screen.queryByText("Turn off MDM")).not.toBeInTheDocument(); + expect(screen.queryByText("Lock")).not.toBeInTheDocument(); }); - it("does not render dropdown for iPadOS", async () => { + it("renders only the transfer, wipe, and delete options for iPadOS", async () => { const render = createCustomRenderer({ context: { app: { + isPremiumTier: true, isGlobalAdmin: true, + isMacMdmEnabledAndConfigured: true, currentUser: createMockUser(), }, }, }); - render( + const { user } = render( ); - expect(screen.queryByText("Actions")).not.toBeInTheDocument(); + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Transfer")).toBeInTheDocument(); + expect(screen.queryByText("Wipe")).toBeInTheDocument(); + expect(screen.queryByText("Delete")).toBeInTheDocument(); + + expect(screen.queryByText("Query")).not.toBeInTheDocument(); + expect(screen.queryByText("Run script")).not.toBeInTheDocument(); + expect( + screen.queryByText("Show disk encryption key") + ).not.toBeInTheDocument(); + expect(screen.queryByText("Turn off MDM")).not.toBeInTheDocument(); + expect(screen.queryByText("Lock")).not.toBeInTheDocument(); }); }); }); diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx index 21e5f807c7..9d466d545d 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx @@ -79,10 +79,6 @@ const HostActionsDropdown = ({ // No options to render. Exit early if (options.length === 0) return null; - if (hostPlatform === "ios" || hostPlatform === "ipados") { - return null; - } - return (
{ const canEditMdm = (config: IHostActionConfigOptions) => { const { + hostPlatform, isGlobalAdmin, isGlobalMaintainer, isTeamAdmin, @@ -95,7 +96,7 @@ const canEditMdm = (config: IHostActionConfigOptions) => { isMacMdmEnabledAndConfigured, } = config; return ( - config.hostPlatform === "darwin" && + hostPlatform === "darwin" && isMacMdmEnabledAndConfigured && isEnrolledInMdm && isFleetMdm && @@ -103,6 +104,13 @@ const canEditMdm = (config: IHostActionConfigOptions) => { ); }; +const canQueryHost = ({ hostPlatform }: IHostActionConfigOptions) => { + // Currently we cannot query iOS or iPadOS + const isIosOrIpadosHost = hostPlatform === "ios" || hostPlatform === "ipados"; + + return !isIosOrIpadosHost; +}; + const canLockHost = ({ isPremiumTier, hostPlatform, @@ -146,17 +154,18 @@ const canWipeHost = ({ hostMdmDeviceStatus, }: IHostActionConfigOptions) => { const hostMdmEnabled = - (hostPlatform === "darwin" && isMacMdmEnabledAndConfigured) || + (isAppleDevice(hostPlatform) && isMacMdmEnabledAndConfigured) || (hostPlatform === "windows" && isWindowsMdmEnabledAndConfigured); - // macOS and Windows hosts have the same conditions and can be wiped if they + // Windows and Apple devices (i.e. macOS, iOS, iPadOS) have the same conditions and can be wiped if they // are enrolled in MDM and the MDM is enabled. - const canWipeMacOrWindows = hostMdmEnabled && isFleetMdm && isEnrolledInMdm; + const canWipeWindowsOrAppleOS = + hostMdmEnabled && isFleetMdm && isEnrolledInMdm; return ( isPremiumTier && hostMdmDeviceStatus === "unlocked" && - (isLinuxLike(hostPlatform) || canWipeMacOrWindows) && + (isLinuxLike(hostPlatform) || canWipeWindowsOrAppleOS) && (isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer) ); }; @@ -205,8 +214,12 @@ const canDeleteHost = (config: IHostActionConfigOptions) => { }; const canShowDiskEncryption = (config: IHostActionConfigOptions) => { - const { isPremiumTier, doesStoreEncryptionKey } = config; - return isPremiumTier && doesStoreEncryptionKey; + const { isPremiumTier, doesStoreEncryptionKey, hostPlatform } = config; + + // Currently we cannot show disk encryption key for iOS or iPadOS + const isIosOrIpadosHost = hostPlatform === "ios" || hostPlatform === "ipados"; + + return isPremiumTier && doesStoreEncryptionKey && !isIosOrIpadosHost; }; const canRunScript = ({ @@ -237,6 +250,10 @@ const removeUnavailableOptions = ( options = options.filter((option) => option.value !== "transfer"); } + if (!canQueryHost(config)) { + options = options.filter((option) => option.value !== "query"); + } + if (!canShowDiskEncryption(config)) { options = options.filter((option) => option.value !== "diskEncryption"); } @@ -266,9 +283,8 @@ const removeUnavailableOptions = ( } // TODO: refactor to filter in one pass using predefined filters specified for each of the - // DEFAULT_OPTIONS. Note that as currently, structured the default is to include all options. For - // example, "Query" is implicitly included by default because there is no equivalent `canQuery` - // filter being applied here. This is a bit confusing since + // DEFAULT_OPTIONS. Note that as currently, structured the default is to include all options. + // This is a bit confusing since we remove options instead of add options return options; }; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 26ac200d74..741249f5cc 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -77,7 +77,7 @@ import TransferHostModal from "../../components/TransferHostModal"; import DeleteHostModal from "../../components/DeleteHostModal"; import DiskEncryptionKeyModal from "./modals/DiskEncryptionKeyModal"; -import HostActionDropdown from "./HostActionsDropdown/HostActionsDropdown"; +import HostActionsDropdown from "./HostActionsDropdown/HostActionsDropdown"; import OSSettingsModal from "../OSSettingsModal"; import BootstrapPackageModal from "./modals/BootstrapPackageModal"; import RunScriptModal from "./modals/RunScriptModal"; @@ -672,7 +672,7 @@ const HostDetailsPage = ({ } return ( - - platform === "darwin" + isAppleDevice(platform) ? "Host is locked. The end user can’t use the host until the six-digit PIN has been entered." : "Host is locked. The end user can’t use the host until the host has been unlocked.", }, @@ -43,7 +44,7 @@ export const DEVICE_STATUS_TAGS: DeviceStatusTagConfig = { title: "WIPED", tagType: "error", generateTooltip: (platform) => - platform === "darwin" + isAppleDevice(platform) ? "Host is wiped. To prevent the host from automatically reenrolling to Fleet, first release the host from Apple Business Manager and then delete the host in Fleet." : "Host is wiped.", }, diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 5497591e4f..bd86ce7532 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -303,8 +303,10 @@ const ( ) // anchored, so that it matches to the end of the line -var scriptHashbangValidation = regexp.MustCompile(`^#!\s*(:?/usr)?/bin/z?sh(?:\s*|\s+.*)$`) -var ErrUnsupportedInterpreter = errors.New(`Interpreter not supported. Shell scripts must run in "#!/bin/sh" or "#!/bin/zsh."`) +var ( + scriptHashbangValidation = regexp.MustCompile(`^#!\s*(:?/usr)?/bin/z?sh(?:\s*|\s+.*)$`) + ErrUnsupportedInterpreter = errors.New(`Interpreter not supported. Shell scripts must run in "#!/bin/sh" or "#!/bin/zsh."`) +) // ValidateShebang validates if we support a script, and whether we // can execute it directly, or need to pass it to a shell interpreter. @@ -402,7 +404,7 @@ type HostLockWipeStatus struct { } func (s *HostLockWipeStatus) IsPendingLock() bool { - if s.HostFleetPlatform == "darwin" { + if s.HostFleetPlatform == "darwin" || s.HostFleetPlatform == "ios" || s.HostFleetPlatform == "ipados" { // pending lock if an MDM command is queued but no result received yet return s.LockMDMCommand != nil && s.LockMDMCommandResult == nil } @@ -411,7 +413,7 @@ func (s *HostLockWipeStatus) IsPendingLock() bool { } func (s HostLockWipeStatus) IsPendingUnlock() bool { - if s.HostFleetPlatform == "darwin" { + if s.HostFleetPlatform == "darwin" || s.HostFleetPlatform == "ios" || s.HostFleetPlatform == "ipados" { // Apple MDM does not have a concept of pending unlock. return false } @@ -432,7 +434,7 @@ func (s HostLockWipeStatus) IsLocked() bool { // this state is regardless of pending unlock/wipe (it reports whether the // host is locked *now*). - if s.HostFleetPlatform == "darwin" { + if s.HostFleetPlatform == "darwin" || s.HostFleetPlatform == "ios" || s.HostFleetPlatform == "ipados" { // locked if an MDM command was sent and succeeded return s.LockMDMCommand != nil && s.LockMDMCommandResult != nil && s.LockMDMCommandResult.Status == MDMAppleStatusAcknowledged @@ -458,7 +460,7 @@ func (s HostLockWipeStatus) IsWiped() bool { // wiped if an MDM command was sent and succeeded return s.WipeMDMCommand != nil && s.WipeMDMCommandResult != nil && strings.HasPrefix(s.WipeMDMCommandResult.Status, "2") - case "darwin": + case "darwin", "ios", "ipados": // wiped if an MDM command was sent and succeeded return s.WipeMDMCommand != nil && s.WipeMDMCommandResult != nil && s.WipeMDMCommandResult.Status == MDMAppleStatusAcknowledged diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 9799f6e7e0..23f8d5d673 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -1423,6 +1423,12 @@ func (svc *Service) EnqueueMDMAppleCommandRemoveEnrollmentProfile(ctx context.Co return ctxerr.Wrap(ctx, err, "getting host info for mdm apple remove profile command") } + if h.Platform == "ios" || h.Platform == "ipados" { + return &fleet.BadRequestError{ + Message: "Can't turn off MDM for iOS or iPadOS hosts. Use wipe instead.", + } + } + info, err := svc.ds.GetHostMDMCheckinInfo(ctx, h.UUID) if err != nil { return ctxerr.Wrap(ctx, err, "getting mdm checkin info for mdm apple remove profile command") diff --git a/tools/mdm/apple/applebmapi/main.go b/tools/mdm/apple/applebmapi/main.go index ae47ee1902..d1ecd69ed0 100644 --- a/tools/mdm/apple/applebmapi/main.go +++ b/tools/mdm/apple/applebmapi/main.go @@ -19,31 +19,25 @@ import ( "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/datastore/mysql" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" - nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" kitlog "github.com/go-kit/kit/log" ) func main() { mysqlAddr := flag.String("mysql", "localhost:3306", "mysql address") - appleBMToken := flag.String("apple-bm-token", "", "path to (decrypted) Apple BM token") + serverPrivateKey := flag.String("server-private-key", "", "fleet server's private key (to decrypt MDM assets)") profileUUID := flag.String("profile-uuid", "", "the Apple profile UUID to retrieve") serialNum := flag.String("serial-number", "", "serial number of a device to get the device details") flag.Parse() - if *appleBMToken == "" { - log.Fatal("must provide Apple BM token") + if *serverPrivateKey == "" { + log.Fatal("must provide -server-private-key") } if *profileUUID != "" && *serialNum != "" { log.Fatal("only one of -profile-uuid or -serial-number must be provided") } - tok, err := os.ReadFile(*appleBMToken) - if err != nil { - log.Fatal(err) - } - cfg := config.MysqlConfig{ Protocol: "tcp", Address: *mysqlAddr, @@ -55,17 +49,19 @@ func main() { ConnMaxLifetime: 0, } logger := kitlog.NewLogfmtLogger(os.Stderr) - opts := []mysql.DBOption{mysql.Logger(logger)} + opts := []mysql.DBOption{ + mysql.Logger(logger), + mysql.WithFleetConfig(&config.FleetConfig{ + Server: config.ServerConfig{ + PrivateKey: *serverPrivateKey, + }, + }), + } mds, err := mysql.New(cfg, clock.C, opts...) if err != nil { log.Fatal(err) } - var jsonTok nanodep_client.OAuth1Tokens - if err := json.Unmarshal(tok, &jsonTok); err != nil { - log.Fatal(err) - } - depStorage, err := mds.NewMDMAppleDEPStorage() if err != nil { log.Fatal(err)