Add fleetctl get mdm-commands command and supporting API endpoint (#11163)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
* Added the `fleetctl get mdm-commands` command to get a list of MDM commands that were executed. This also adds the `GET /api/latest/fleet/mdm/apple/commands` API endpoint.
|
||||
@@ -1,825 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/service"
|
||||
"github.com/groob/plist"
|
||||
"github.com/micromdm/micromdm/mdm/appmanifest"
|
||||
"github.com/micromdm/micromdm/mdm/mdm"
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
apnsKeyPath = "fleet-mdm-apple-apns.key"
|
||||
scepCACertPath = "fleet-mdm-apple-scep.crt"
|
||||
scepCAKeyPath = "fleet-mdm-apple-scep.key"
|
||||
bmPublicKeyCertPath = "fleet-apple-mdm-bm-public-key.crt"
|
||||
bmPrivateKeyPath = "fleet-apple-mdm-bm-private.key"
|
||||
)
|
||||
|
||||
func appleMDMCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "apple-mdm",
|
||||
Usage: "Apple MDM functionality",
|
||||
// TODO: Remove when Apple MDM is production ready.
|
||||
Hidden: true,
|
||||
Flags: []cli.Flag{
|
||||
configFlag(),
|
||||
contextFlag(),
|
||||
debugFlag(),
|
||||
},
|
||||
Subcommands: []*cli.Command{
|
||||
appleMDMEnrollmentProfilesCommand(),
|
||||
appleMDMEnqueueCommandCommand(),
|
||||
appleMDMDEPCommand(),
|
||||
appleMDMDevicesCommand(),
|
||||
appleMDMCommandResultsCommand(),
|
||||
appleMDMInstallersCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMEnrollmentProfilesCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "enrollment-profiles",
|
||||
Usage: "Commands to manage enrollment profiles",
|
||||
Subcommands: []*cli.Command{
|
||||
appleMDMEnrollmentProfilesCreateAutomaticCommand(),
|
||||
appleMDMEnrollmentProfilesCreateManualCommand(),
|
||||
appleMDMEnrollmentProfilesListCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMEnrollmentProfilesCreateAutomaticCommand() *cli.Command {
|
||||
var depProfilePath string
|
||||
return &cli.Command{
|
||||
Name: "create-automatic",
|
||||
Usage: "Create an automatic enrollment profile",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "dep-profile",
|
||||
Usage: "JSON file with fields defined in https://developer.apple.com/documentation/devicemanagement/profile",
|
||||
Destination: &depProfilePath,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
profile, err := os.ReadFile(depProfilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read dep profile: %w", err)
|
||||
}
|
||||
client, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
depProfile := json.RawMessage(profile)
|
||||
enrollmentProfile, err := client.CreateEnrollmentProfile(fleet.MDMAppleEnrollmentTypeAutomatic, &depProfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create enrollment profile: %w", err)
|
||||
}
|
||||
fmt.Printf("Automatic enrollment profile created, ID: %d\n", enrollmentProfile.ID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMEnrollmentProfilesCreateManualCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "create-manual",
|
||||
Usage: "Create a manual enrollment profile",
|
||||
Flags: []cli.Flag{},
|
||||
Action: func(c *cli.Context) error {
|
||||
client, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
enrollmentProfile, err := client.CreateEnrollmentProfile(fleet.MDMAppleEnrollmentTypeManual, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create enrollment profile: %w", err)
|
||||
}
|
||||
fmt.Printf("Manual enrollment profile created, URL: %s.\n", enrollmentProfile.EnrollmentURL)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMEnrollmentProfilesListCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List all enrollments",
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
enrollments, err := fleet.ListEnrollments()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create enrollment: %w", err)
|
||||
}
|
||||
|
||||
// format output as a table
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetRowLine(true)
|
||||
table.SetHeader([]string{"ID", "Type", "DEP Profile", "Enrollment URL"})
|
||||
table.SetAutoWrapText(false)
|
||||
table.SetRowLine(true)
|
||||
|
||||
for _, enrollment := range enrollments {
|
||||
var depProfile string
|
||||
if enrollment.DEPProfile != nil {
|
||||
depProfile = string(*enrollment.DEPProfile)
|
||||
}
|
||||
table.Append([]string{
|
||||
strconv.FormatUint(uint64(enrollment.ID), 10),
|
||||
string(enrollment.Type),
|
||||
depProfile,
|
||||
enrollment.EnrollmentURL,
|
||||
})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMEnqueueCommandCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "enqueue-command",
|
||||
Usage: "Enqueue an MDM command. See the results using the command-results command and passing the command UUID that is returned from this command.",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "device-ids",
|
||||
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "command-payload",
|
||||
Usage: "A plist file containing the raw MDM command payload. Note that a new CommandUUID will be generated automatically. See https://developer.apple.com/documentation/devicemanagement/commands_and_queries for available commands.",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
deviceIDs := strings.Split(c.String("device-ids"), ",")
|
||||
if len(deviceIDs) == 0 {
|
||||
return errors.New("must provide at least one device ID")
|
||||
}
|
||||
|
||||
payloadFilename := c.String("command-payload")
|
||||
if payloadFilename == "" {
|
||||
return errors.New("must provide a command payload file")
|
||||
}
|
||||
payloadBytes, err := os.ReadFile(payloadFilename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read payload: %w", err)
|
||||
}
|
||||
|
||||
result, err := fleet.EnqueueCommand(deviceIDs, payloadBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commandUUID := result.CommandUUID
|
||||
fmt.Printf("Command UUID: %s\n", commandUUID)
|
||||
|
||||
return nil
|
||||
},
|
||||
Subcommands: []*cli.Command{
|
||||
appleMDMEnqueueCommandInstallProfileCommand(),
|
||||
appleMDMEnqueueCommandSimpleCommand("ProfileList"),
|
||||
appleMDMEnqueueCommandRemoveProfileCommand(),
|
||||
appleMDMEnqueueCommandInstallEnterpriseApplicationCommand(),
|
||||
appleMDMEnqueueCommandSimpleCommand("ProvisioningProfileList"),
|
||||
appleMDMEnqueueCommandSimpleCommand("CertificateList"),
|
||||
appleMDMEnqueueCommandSimpleCommand("SecurityInfo"),
|
||||
appleMDMEnqueueCommandSimpleCommand("RestartDevice"),
|
||||
appleMDMEnqueueCommandSimpleCommand("ShutdownDevice"),
|
||||
appleMDMEnqueueCommandSimpleCommand("StopMirroring"),
|
||||
appleMDMEnqueueCommandSimpleCommand("ClearRestrictionsPassword"),
|
||||
appleMDMEnqueueCommandSimpleCommand("UserList"),
|
||||
appleMDMEnqueueCommandSimpleCommand("LogOutUser"),
|
||||
appleMDMEnqueueCommandSimpleCommand("PlayLostModeSound"),
|
||||
appleMDMEnqueueCommandSimpleCommand("DisableLostMode"),
|
||||
appleMDMEnqueueCommandSimpleCommand("DeviceLocation"),
|
||||
appleMDMEnqueueCommandSimpleCommand("ManagedMediaList"),
|
||||
appleMDMEnqueueCommandSimpleCommand("DeviceConfigured"),
|
||||
appleMDMEnqueueCommandSimpleCommand("AvailableOSUpdates"),
|
||||
appleMDMEnqueueCommandSimpleCommand("NSExtensionMappings"),
|
||||
appleMDMEnqueueCommandSimpleCommand("OSUpdateStatus"),
|
||||
appleMDMEnqueueCommandSimpleCommand("EnableRemoteDesktop"),
|
||||
appleMDMEnqueueCommandSimpleCommand("DisableRemoteDesktop"),
|
||||
appleMDMEnqueueCommandSimpleCommand("ActivationLockBypassCode"),
|
||||
appleMDMEnqueueCommandSimpleCommand("ScheduleOSUpdateScan"),
|
||||
appleMDMEnqueueCommandEraseDeviceCommand(),
|
||||
appleMDMEnqueueCommandDeviceLockCommand(),
|
||||
appleMDMEnqueueCommandDeviceInformationCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMEnqueueCommandInstallProfileCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "InstallProfile",
|
||||
Usage: "Enqueue the InstallProfile MDM command.",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "device-ids",
|
||||
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mobileconfig",
|
||||
Usage: "The mobileconfig file containing the profile to install.",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
deviceIDs := strings.Split(c.String("device-ids"), ",")
|
||||
if len(deviceIDs) == 0 {
|
||||
return errors.New("must provide at least one device ID")
|
||||
}
|
||||
|
||||
profilePayloadFilename := c.String("mobileconfig")
|
||||
if profilePayloadFilename == "" {
|
||||
return errors.New("must provide a mobileprofile payload")
|
||||
}
|
||||
profilePayloadBytes, err := os.ReadFile(profilePayloadFilename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read payload: %w", err)
|
||||
}
|
||||
|
||||
payload := &mdm.CommandPayload{
|
||||
Command: &mdm.Command{
|
||||
RequestType: "InstallProfile",
|
||||
InstallProfile: &mdm.InstallProfile{
|
||||
Payload: profilePayloadBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMEnqueueCommandRemoveProfileCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "RemoveProfile",
|
||||
Usage: "Enqueue the RemoveProfile MDM command.",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "device-ids",
|
||||
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "identifier",
|
||||
Usage: "The PayloadIdentifier value for the profile to remove eg cis.macOSBenchmark.section2.SecureKeyboard.",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
deviceIDs := strings.Split(c.String("device-ids"), ",")
|
||||
if len(deviceIDs) == 0 {
|
||||
return errors.New("must provide at least one device ID")
|
||||
}
|
||||
|
||||
identifier := c.String("identifier")
|
||||
if identifier == "" {
|
||||
return errors.New("must provide the identifier of the profile")
|
||||
}
|
||||
|
||||
payload := &mdm.CommandPayload{
|
||||
Command: &mdm.Command{
|
||||
RequestType: "RemoveProfile",
|
||||
RemoveProfile: &mdm.RemoveProfile{
|
||||
Identifier: identifier,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMEnqueueCommandSimpleCommand(name string) *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: name,
|
||||
Usage: fmt.Sprintf("Enqueue the %s MDM command.", name),
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "device-ids",
|
||||
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
return runSimpleCommand(c, name)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runSimpleCommand runs commands that do not have any extra arguments, like RestartDevice.
|
||||
func runSimpleCommand(c *cli.Context, name string) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
deviceIDs := strings.Split(c.String("device-ids"), ",")
|
||||
if len(deviceIDs) == 0 {
|
||||
return errors.New("must provide at least one device ID")
|
||||
}
|
||||
|
||||
payload := &mdm.CommandPayload{
|
||||
Command: &mdm.Command{
|
||||
RequestType: name,
|
||||
},
|
||||
}
|
||||
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
|
||||
}
|
||||
|
||||
func enqueueCommandAndPrintHelp(fleet *service.Client, deviceIDs []string, payload *mdm.CommandPayload) error {
|
||||
// convert to xml using tabs for indentation
|
||||
payloadBytes, err := plist.MarshalIndent(payload, " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal command payload plist: %w", err)
|
||||
}
|
||||
|
||||
result, err := fleet.EnqueueCommand(deviceIDs, payloadBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enqueue command: %w", err)
|
||||
}
|
||||
|
||||
commandUUID := result.CommandUUID
|
||||
fmt.Printf("Command UUID: %s\n", commandUUID)
|
||||
fmt.Printf("Use `fleetctl apple-mdm command-results --command-uuid %s` to get results.\n", commandUUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func appleMDMEnqueueCommandEraseDeviceCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "EraseDevice",
|
||||
Usage: "Enqueue the EraseDevice MDM command.",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "device-ids",
|
||||
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pin",
|
||||
Usage: "The six-character PIN for Find My.",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
deviceIDs := strings.Split(c.String("device-ids"), ",")
|
||||
if len(deviceIDs) == 0 {
|
||||
return errors.New("must provide at least one device ID")
|
||||
}
|
||||
|
||||
pin := c.String("pin")
|
||||
if len(pin) != 6 {
|
||||
return errors.New("must provide a six-character PIN for Find My")
|
||||
}
|
||||
|
||||
payload := &mdm.CommandPayload{
|
||||
Command: &mdm.Command{
|
||||
RequestType: "EraseDevice",
|
||||
EraseDevice: &mdm.EraseDevice{
|
||||
PIN: pin,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMEnqueueCommandDeviceLockCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "DeviceLock",
|
||||
Usage: "Enqueue the DeviceLock MDM command.",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "device-ids",
|
||||
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pin",
|
||||
Usage: "The six-character PIN for Find My.",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
deviceIDs := strings.Split(c.String("device-ids"), ",")
|
||||
if len(deviceIDs) == 0 {
|
||||
return errors.New("must provide at least one device ID")
|
||||
}
|
||||
|
||||
pin := c.String("pin")
|
||||
if len(pin) != 6 {
|
||||
return errors.New("must provide a six-character PIN for Find My")
|
||||
}
|
||||
|
||||
payload := &mdm.CommandPayload{
|
||||
Command: &mdm.Command{
|
||||
RequestType: "DeviceLock",
|
||||
DeviceLock: &mdm.DeviceLock{
|
||||
PIN: pin,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMEnqueueCommandDeviceInformationCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "DeviceInformation",
|
||||
Usage: "Enqueue the DeviceInformation MDM command.",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "device-ids",
|
||||
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "queries",
|
||||
Usage: "An array of query dictionaries to get information about a device. See https://developer.apple.com/documentation/devicemanagement/deviceinformationcommand/command/queries.",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
deviceIDs := strings.Split(c.String("device-ids"), ",")
|
||||
if len(deviceIDs) == 0 {
|
||||
return errors.New("must provide at least one device ID")
|
||||
}
|
||||
|
||||
queries := strings.Split(c.String("queries"), ",")
|
||||
if len(queries) == 0 {
|
||||
return errors.New("must provide queries for the device")
|
||||
}
|
||||
|
||||
payload := &mdm.CommandPayload{
|
||||
Command: &mdm.Command{
|
||||
RequestType: "DeviceInformation",
|
||||
DeviceInformation: &mdm.DeviceInformation{
|
||||
Queries: queries,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMEnqueueCommandInstallEnterpriseApplicationCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "InstallEnterpriseApplication",
|
||||
Usage: "Enqueue the InstallEnterpriseApplication MDM command.",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "device-ids",
|
||||
Usage: "Comma separated device IDs to send the MDM command to. This is the same as the hardware UUID.",
|
||||
},
|
||||
&cli.UintFlag{
|
||||
Name: "installer-id",
|
||||
Usage: "ID of the installer to install on the target devices.",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
deviceIDs := strings.Split(c.String("device-ids"), ",")
|
||||
if len(deviceIDs) == 0 {
|
||||
return errors.New("must provide at least one device ID")
|
||||
}
|
||||
|
||||
installerID := c.Uint("installer-id")
|
||||
if installerID == 0 {
|
||||
return errors.New("must provide an installer ID")
|
||||
}
|
||||
installer, err := fleet.MDMAppleGetInstallerDetails(installerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get installer: %w", err)
|
||||
}
|
||||
|
||||
var m appmanifest.Manifest
|
||||
if err := plist.NewDecoder(bytes.NewReader([]byte(installer.Manifest))).Decode(&m); err != nil {
|
||||
return fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
|
||||
payload := &mdm.CommandPayload{
|
||||
Command: &mdm.Command{
|
||||
RequestType: "InstallEnterpriseApplication",
|
||||
InstallEnterpriseApplication: &mdm.InstallEnterpriseApplication{
|
||||
Manifest: &m,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return enqueueCommandAndPrintHelp(fleet, deviceIDs, payload)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMDevicesCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "devices",
|
||||
Usage: "Inspect enrolled devices",
|
||||
Subcommands: []*cli.Command{
|
||||
appleMDMDevicesListCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMDevicesListCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List all devices",
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
devices, err := fleet.MDMAppleListDevices()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// format output as a table
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetRowLine(true)
|
||||
table.SetHeader([]string{"Device ID", "Serial Number", "Enrolled"})
|
||||
table.SetAutoWrapText(false)
|
||||
table.SetRowLine(true)
|
||||
|
||||
for _, device := range devices {
|
||||
table.Append([]string{
|
||||
device.ID,
|
||||
device.SerialNumber,
|
||||
strconv.FormatBool(device.Enabled),
|
||||
})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMDEPCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "dep",
|
||||
Usage: "Device Enrollment Program commands",
|
||||
Subcommands: []*cli.Command{
|
||||
appleMDMDEPListCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMDEPListCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List all DEP devices from the linked MDM server in Apple Business Manager",
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
devices, err := fleet.DEPListDevices()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// format output as a table
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetRowLine(true)
|
||||
table.SetHeader([]string{
|
||||
"Serial Number",
|
||||
"OS",
|
||||
"Family",
|
||||
"Model",
|
||||
"Description",
|
||||
"Color",
|
||||
"Profile Status",
|
||||
"Profile UUID",
|
||||
"Profile Assign Time",
|
||||
"Profile Push Time",
|
||||
"Device Assigned Date",
|
||||
"Assigned By",
|
||||
})
|
||||
table.SetAutoWrapText(false)
|
||||
table.SetRowLine(true)
|
||||
|
||||
const timeFmt = "2006-01-02T15:04:05Z"
|
||||
for _, device := range devices {
|
||||
table.Append([]string{
|
||||
device.SerialNumber,
|
||||
device.OS,
|
||||
device.DeviceFamily,
|
||||
device.Model,
|
||||
device.Description,
|
||||
device.Color,
|
||||
device.ProfileStatus,
|
||||
device.ProfileUUID,
|
||||
device.ProfileAssignTime.Format(timeFmt),
|
||||
device.ProfilePushTime.Format(timeFmt),
|
||||
device.DeviceAssignedDate.Format(timeFmt),
|
||||
device.DeviceAssignedBy,
|
||||
})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMCommandResultsCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "command-results",
|
||||
Usage: "Get MDM command results",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "command-uuid",
|
||||
Usage: "The command uuid.",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
commandUUID := c.String("command-uuid")
|
||||
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
|
||||
results, err := fleet.MDMAppleGetCommandResults(commandUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// format output as a table
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetRowLine(true)
|
||||
table.SetHeader([]string{"Device ID", "Status", "Result"})
|
||||
table.SetAutoWrapText(false)
|
||||
table.SetRowLine(true)
|
||||
|
||||
for _, result := range results {
|
||||
xml := bytes.ReplaceAll(result.Result, []byte{'\t'}, []byte{' '})
|
||||
table.Append([]string{result.DeviceID, result.Status, string(xml)})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMInstallersCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "installers",
|
||||
Usage: "Commands to manage macOS installers",
|
||||
Subcommands: []*cli.Command{
|
||||
appleMDMInstallersUploadCommand(),
|
||||
appleMDMInstallersListCommand(),
|
||||
appleMDMInstallersDeleteCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMInstallersUploadCommand() *cli.Command {
|
||||
var path string
|
||||
return &cli.Command{
|
||||
Name: "upload",
|
||||
Usage: "Upload an Apple installer to Fleet",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "path",
|
||||
Usage: "Path to the installer",
|
||||
Destination: &path,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
fp, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open path %q: %w", path, err)
|
||||
}
|
||||
defer fp.Close()
|
||||
installerID, err := fleet.UploadMDMAppleInstaller(c.Context, filepath.Base(path), fp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload installer: %w", err)
|
||||
}
|
||||
fmt.Printf("Installer uploaded successfully, id=%d", installerID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMInstallersListCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List all Apple installers",
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
installers, err := fleet.ListMDMAppleInstallers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list installers: %w", err)
|
||||
}
|
||||
// format output as a table
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetRowLine(true)
|
||||
table.SetHeader([]string{"ID", "Name", "Manifest", "URL"})
|
||||
table.SetAutoWrapText(false)
|
||||
table.SetRowLine(true)
|
||||
|
||||
for _, installer := range installers {
|
||||
manifest := strings.ReplaceAll(installer.Manifest, "\t", " ")
|
||||
table.Append([]string{strconv.FormatUint(uint64(installer.ID), 10), installer.Name, manifest, installer.URL})
|
||||
}
|
||||
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func appleMDMInstallersDeleteCommand() *cli.Command {
|
||||
var installerID uint
|
||||
return &cli.Command{
|
||||
Name: "delete",
|
||||
Usage: "Delete an Apple installer",
|
||||
Flags: []cli.Flag{
|
||||
&cli.UintFlag{
|
||||
Name: "id",
|
||||
Usage: "Identifier of the installer",
|
||||
Destination: &installerID,
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
fleet, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
if err := fleet.MDMDeleteAppleInstaller(installerID); err != nil {
|
||||
return fmt.Errorf("delete installer: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,6 @@ func createApp(reader io.Reader, writer io.Writer, exitErrHandler cli.ExitErrHan
|
||||
hostsCommand(),
|
||||
vulnerabilityDataStreamCommand(),
|
||||
packageCommand(),
|
||||
appleMDMCommand(),
|
||||
generateCommand(),
|
||||
{
|
||||
// It's become common for folks to unintentionally install fleetctl when they actually
|
||||
|
||||
@@ -8,6 +8,14 @@ import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
apnsKeyPath = "fleet-mdm-apple-apns.key"
|
||||
scepCACertPath = "fleet-mdm-apple-scep.crt"
|
||||
scepCAKeyPath = "fleet-mdm-apple-scep.key"
|
||||
bmPublicKeyCertPath = "fleet-apple-mdm-bm-public-key.crt"
|
||||
bmPrivateKeyPath = "fleet-apple-mdm-bm-private.key"
|
||||
)
|
||||
|
||||
func generateCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "generate",
|
||||
|
||||
@@ -292,6 +292,7 @@ func getCommand() *cli.Command {
|
||||
getMDMAppleCommand(),
|
||||
getMDMAppleBMCommand(),
|
||||
getMDMCommandResultsCommand(),
|
||||
getMDMCommandsCommand(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1271,3 +1272,52 @@ func getMDMCommandResultsCommand() *cli.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getMDMCommandsCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "mdm-commands",
|
||||
Aliases: []string{"mdm_commands"},
|
||||
Usage: "List information about MDM commands that were run.",
|
||||
Flags: []cli.Flag{
|
||||
configFlag(),
|
||||
contextFlag(),
|
||||
debugFlag(),
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
client, err := clientFromCLI(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// print an error if MDM is not configured
|
||||
if err := client.CheckMDMEnabled(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results, err := client.MDMAppleListCommands()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
log(c, "You haven't run any MDM commands. Run MDM commands with the `fleetctl mdm run-command` command.\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// print the results as a table
|
||||
data := [][]string{}
|
||||
for _, r := range results {
|
||||
data = append(data, []string{
|
||||
r.CommandUUID,
|
||||
r.UpdatedAt.Format(time.RFC3339),
|
||||
r.RequestType,
|
||||
r.Status,
|
||||
r.Hostname,
|
||||
})
|
||||
}
|
||||
columns := []string{"ID", "TIME", "TYPE", "STATUS", "HOSTNAME"}
|
||||
printTable(c, columns, data)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1529,7 +1529,6 @@ func TestGetMDMCommandResults(t *testing.T) {
|
||||
|
||||
buf, err = runAppNoChecks([]string{"get", "mdm-command-results", "--id", "valid-cmd"})
|
||||
require.NoError(t, err)
|
||||
fmt.Println(buf.String())
|
||||
require.Contains(t, buf.String(), strings.TrimSpace(`
|
||||
+-----------+----------------------+------+--------------+----------+---------------------------------------------------+
|
||||
| ID | TIME | TYPE | STATUS | HOSTNAME | RESULTS |
|
||||
@@ -1560,3 +1559,60 @@ func TestGetMDMCommandResults(t *testing.T) {
|
||||
+-----------+----------------------+------+--------------+----------+---------------------------------------------------+
|
||||
`))
|
||||
}
|
||||
|
||||
func TestGetMDMCommands(t *testing.T) {
|
||||
_, ds := runServerWithMockedDS(t)
|
||||
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
|
||||
}
|
||||
var empty bool
|
||||
var listErr error
|
||||
ds.ListMDMAppleCommandsFunc = func(ctx context.Context, tmFilter fleet.TeamFilter, listOpts *fleet.MDMAppleCommandListOptions) ([]*fleet.MDMAppleCommand, error) {
|
||||
if empty || listErr != nil {
|
||||
return nil, listErr
|
||||
}
|
||||
return []*fleet.MDMAppleCommand{
|
||||
{
|
||||
DeviceID: "h1",
|
||||
CommandUUID: "u1",
|
||||
UpdatedAt: time.Date(2023, 4, 12, 9, 5, 0, 0, time.UTC),
|
||||
RequestType: "ProfileList",
|
||||
Status: "Acknowledged",
|
||||
Hostname: "host1",
|
||||
},
|
||||
{
|
||||
DeviceID: "h2",
|
||||
CommandUUID: "u2",
|
||||
UpdatedAt: time.Date(2023, 4, 11, 9, 5, 0, 0, time.UTC),
|
||||
RequestType: "ListApps",
|
||||
Status: "Acknowledged",
|
||||
Hostname: "host2",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
listErr = io.ErrUnexpectedEOF
|
||||
_, err := runAppNoChecks([]string{"get", "mdm-commands"})
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, io.ErrUnexpectedEOF.Error())
|
||||
|
||||
listErr = nil
|
||||
empty = true
|
||||
buf, err := runAppNoChecks([]string{"get", "mdm-commands"})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, buf.String(), "You haven't run any MDM commands. Run MDM commands with the `fleetctl mdm run-command` command.")
|
||||
|
||||
empty = false
|
||||
buf, err = runAppNoChecks([]string{"get", "mdm-commands"})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, buf.String(), strings.TrimSpace(`
|
||||
+----+----------------------+-------------+--------------+----------+
|
||||
| ID | TIME | TYPE | STATUS | HOSTNAME |
|
||||
+----+----------------------+-------------+--------------+----------+
|
||||
| u1 | 2023-04-12T09:05:00Z | ProfileList | Acknowledged | host1 |
|
||||
+----+----------------------+-------------+--------------+----------+
|
||||
| u2 | 2023-04-11T09:05:00Z | ListApps | Acknowledged | host2 |
|
||||
+----+----------------------+-------------+--------------+----------+
|
||||
`))
|
||||
}
|
||||
|
||||
@@ -3519,6 +3519,7 @@ These API endpoints are used to automate MDM features in Fleet. Read more about
|
||||
- [Get macOS settings statistics](#get-macos-settings-statistics)
|
||||
- [Run custom MDM command](#run-custom-mdm-command)
|
||||
- [Get custom MDM command results](#get-custom-mdm-command-results)
|
||||
- [List custom MDM commands](#list-custom-mdm-commands)
|
||||
- [Get Apple Push Notification service (APNs)](#get-apple-push-notification-service-apns)
|
||||
- [Get Apple Business Manager (ABM)](#get-apple-business-manager-abm)
|
||||
- [Turn off MDM for a host](#turn-off-mdm-for-a-host)
|
||||
@@ -3601,7 +3602,7 @@ of duplicate payload display name or duplicate payload identifier.
|
||||
|
||||
### List custom macOS settings (configuration profiles)
|
||||
|
||||
Get a list of the configuration profiles in Fleet.
|
||||
Get a list of the configuration profiles in Fleet.
|
||||
|
||||
For Fleet Premium, the list can
|
||||
optionally be filtered by team ID. If no team ID is specified, team profiles are excluded from the
|
||||
@@ -3735,7 +3736,7 @@ _Available in Fleet Premium_
|
||||
|
||||
_Available in Fleet Premium_
|
||||
|
||||
Get aggregate status counts of disk encryption enforced on hosts.
|
||||
Get aggregate status counts of disk encryption enforced on hosts.
|
||||
|
||||
The summary can optionally be filtered by team id.
|
||||
|
||||
@@ -3769,7 +3770,7 @@ Get aggregate status counts of Apple disk encryption profiles applying to macOS
|
||||
|
||||
### Get macOS settings statistics
|
||||
|
||||
Get aggregate status counts of all macOS settings (configuraiton profiles and disk encryption) enforced on hosts.
|
||||
Get aggregate status counts of all macOS settings (configuraiton profiles and disk encryption) enforced on hosts.
|
||||
|
||||
For Fleet Premium uses, the statistics can
|
||||
optionally be filtered by team id. If no team id is specified, team profiles are excluded from the
|
||||
@@ -3854,13 +3855,53 @@ This endpoint returns the results for a specific custom MDM command.
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
"device_id": "145cafeb-87c7-4869-84d5-e4118a927746",
|
||||
"command_uuid": "a2064cef-0000-1234-afb9-283e3c1d487e",
|
||||
"status": "Acknowledged",
|
||||
"updated_at": "2023-04-04:00:00Z",
|
||||
"request_type": "ProfileList",
|
||||
"hostname": "mycomputer",
|
||||
"result": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHBsaXN0IFBVQkxJQyAiLS8vQXBwbGUvL0RURCBQTElTVCAxLjAvL0VOIiAiaHR0cDovL3d3dy5hcHBsZS5jb20vRFREcy9Qcm9wZXJ0eUxpc3QtMS4wLmR0ZCI-CjxwbGlzdCB2ZXJzaW9uPSIxLjAiPgo8ZGljdD4KICAgIDxrZXk-Q29tbWFuZDwva2V5PgogICAgPGRpY3Q-CiAgICAgICAgPGtleT5NYW5hZ2VkT25seTwva2V5PgogICAgICAgIDxmYWxzZS8-CiAgICAgICAgPGtleT5SZXF1ZXN0VHlwZTwva2V5PgogICAgICAgIDxzdHJpbmc-UHJvZmlsZUxpc3Q8L3N0cmluZz4KICAgIDwvZGljdD4KICAgIDxrZXk-Q29tbWFuZFVVSUQ8L2tleT4KICAgIDxzdHJpbmc-MDAwMV9Qcm9maWxlTGlzdDwvc3RyaW5nPgo8L2RpY3Q-CjwvcGxpc3Q-"
|
||||
{
|
||||
"device_id": "145cafeb-87c7-4869-84d5-e4118a927746",
|
||||
"command_uuid": "a2064cef-0000-1234-afb9-283e3c1d487e",
|
||||
"status": "Acknowledged",
|
||||
"updated_at": "2023-04-04:00:00Z",
|
||||
"request_type": "ProfileList",
|
||||
"hostname": "mycomputer",
|
||||
"result": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHBsaXN0IFBVQkxJQyAiLS8vQXBwbGUvL0RURCBQTElTVCAxLjAvL0VOIiAiaHR0cDovL3d3dy5hcHBsZS5jb20vRFREcy9Qcm9wZXJ0eUxpc3QtMS4wLmR0ZCI-CjxwbGlzdCB2ZXJzaW9uPSIxLjAiPgo8ZGljdD4KICAgIDxrZXk-Q29tbWFuZDwva2V5PgogICAgPGRpY3Q-CiAgICAgICAgPGtleT5NYW5hZ2VkT25seTwva2V5PgogICAgICAgIDxmYWxzZS8-CiAgICAgICAgPGtleT5SZXF1ZXN0VHlwZTwva2V5PgogICAgICAgIDxzdHJpbmc-UHJvZmlsZUxpc3Q8L3N0cmluZz4KICAgIDwvZGljdD4KICAgIDxrZXk-Q29tbWFuZFVVSUQ8L2tleT4KICAgIDxzdHJpbmc-MDAwMV9Qcm9maWxlTGlzdDwvc3RyaW5nPgo8L2RpY3Q-CjwvcGxpc3Q-"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### List custom MDM commands
|
||||
|
||||
This endpoint returns the list of custom MDM commands that have been executed.
|
||||
|
||||
`GET /api/v1/fleet/mdm/apple/commands`
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type | In | Description |
|
||||
| ------------------------- | ------ | ----- | ------------------------------------------------------------------------- |
|
||||
| page | integer | query | Page number of the results to fetch. |
|
||||
| per_page | integer | query | Results per page. |
|
||||
| order_key | string | query | What to order results by. Can be any field listed in the `results` array example below. |
|
||||
| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. |
|
||||
|
||||
#### Example
|
||||
|
||||
`GET /api/v1/fleet/mdm/apple/commands?per_page=5
|
||||
|
||||
##### Default response
|
||||
|
||||
`Status: 200`
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"device_id": "145cafeb-87c7-4869-84d5-e4118a927746",
|
||||
"command_uuid": "a2064cef-0000-1234-afb9-283e3c1d487e",
|
||||
"status": "Acknowledged",
|
||||
"updated_at": "2023-04-04:00:00Z",
|
||||
"request_type": "ProfileList",
|
||||
"hostname": "mycomputer"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -3936,7 +3977,7 @@ None.
|
||||
|
||||
`Status: 200`
|
||||
|
||||
###
|
||||
###
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ WHERE
|
||||
var results []*fleet.MDMAppleCommandResult
|
||||
err := sqlx.SelectContext(
|
||||
ctx,
|
||||
ds.writer,
|
||||
ds.reader,
|
||||
&results,
|
||||
query,
|
||||
commandUUID,
|
||||
@@ -317,6 +317,38 @@ WHERE
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) ListMDMAppleCommands(
|
||||
ctx context.Context,
|
||||
tmFilter fleet.TeamFilter,
|
||||
listOpts *fleet.MDMAppleCommandListOptions,
|
||||
) ([]*fleet.MDMAppleCommand, error) {
|
||||
stmt := fmt.Sprintf(`
|
||||
SELECT
|
||||
nvq.id as device_id,
|
||||
nvq.command_uuid,
|
||||
COALESCE(nvq.status, '') as status,
|
||||
COALESCE(nvq.result_updated_at, nvq.created_at) as updated_at,
|
||||
nvq.request_type,
|
||||
h.hostname,
|
||||
h.team_id
|
||||
FROM
|
||||
nano_view_queue nvq
|
||||
INNER JOIN
|
||||
hosts h
|
||||
ON
|
||||
nvq.id = h.uuid
|
||||
WHERE
|
||||
%s
|
||||
`, ds.whereFilterHostsByTeams(tmFilter, "h"))
|
||||
stmt, params := appendListOptionsWithCursorToSQL(stmt, nil, &listOpts.ListOptions)
|
||||
|
||||
var results []*fleet.MDMAppleCommand
|
||||
if err := sqlx.SelectContext(ctx, ds.reader, &results, stmt, params...); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "list commands")
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) NewMDMAppleInstaller(ctx context.Context, name string, size int64, manifest string, installer []byte, urlToken string) (*fleet.MDMAppleInstaller, error) {
|
||||
res, err := ds.writer.ExecContext(
|
||||
ctx,
|
||||
@@ -1681,17 +1713,17 @@ func getMDMAppleConfigProfileByTeamAndIdentifierDB(ctx context.Context, tx sqlx.
|
||||
}
|
||||
|
||||
stmt := `
|
||||
SELECT
|
||||
profile_id,
|
||||
team_id,
|
||||
name,
|
||||
identifier,
|
||||
mobileconfig,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
mdm_apple_configuration_profiles
|
||||
WHERE
|
||||
SELECT
|
||||
profile_id,
|
||||
team_id,
|
||||
name,
|
||||
identifier,
|
||||
mobileconfig,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
mdm_apple_configuration_profiles
|
||||
WHERE
|
||||
team_id=? AND identifier=?`
|
||||
|
||||
var profile fleet.MDMAppleConfigProfile
|
||||
|
||||
@@ -51,6 +51,7 @@ func TestMDMAppleConfigProfile(t *testing.T) {
|
||||
{"TestGetMDMAppleCommandResults", testGetMDMAppleCommandResults},
|
||||
{"TestBulkUpsertMDMAppleConfigProfiles", testBulkUpsertMDMAppleConfigProfile},
|
||||
{"TestMDMAppleBootstrapPackageCRUD", testMDMAppleBootstrapPackageCRUD},
|
||||
{"TestListMDMAppleCommands", testListMDMAppleCommands},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -2726,3 +2727,259 @@ func testMDMAppleBootstrapPackageCRUD(t *testing.T, ds *Datastore) {
|
||||
require.ErrorAs(t, err, &nfe)
|
||||
require.Nil(t, meta)
|
||||
}
|
||||
|
||||
func testListMDMAppleCommands(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
createRawCmd := func(reqType, cmdUUID string) string {
|
||||
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>ManagedOnly</key>
|
||||
<false/>
|
||||
<key>RequestType</key>
|
||||
<string>%s</string>
|
||||
</dict>
|
||||
<key>CommandUUID</key>
|
||||
<string>%s</string>
|
||||
</dict>
|
||||
</plist>`, reqType, cmdUUID)
|
||||
}
|
||||
|
||||
// create some enrolled hosts
|
||||
enrolledHosts := make([]*fleet.Host, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
h, err := ds.NewHost(ctx, &fleet.Host{
|
||||
Hostname: fmt.Sprintf("test-host%d-name", i),
|
||||
OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)),
|
||||
NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)),
|
||||
UUID: fmt.Sprintf("test-uuid-%d", i),
|
||||
Platform: "darwin",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
nanoEnroll(t, ds, h, false)
|
||||
enrolledHosts[i] = h
|
||||
t.Logf("enrolled host [%d]: %s", i, h.UUID)
|
||||
}
|
||||
// create a team
|
||||
tm1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
||||
require.NoError(t, err)
|
||||
// assign enrolledHosts[2] to tm1
|
||||
err = ds.AddHostsToTeam(ctx, &tm1.ID, []uint{enrolledHosts[2].ID})
|
||||
require.NoError(t, err)
|
||||
|
||||
commander, storage := createMDMAppleCommanderAndStorage(t, ds)
|
||||
|
||||
// no commands yet
|
||||
res, err := ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, res)
|
||||
|
||||
// enqueue a command for enrolled hosts [0] and [1]
|
||||
uuid1 := uuid.New().String()
|
||||
rawCmd1 := createRawCmd("ListApps", uuid1)
|
||||
err = commander.EnqueueCommand(ctx, []string{enrolledHosts[0].UUID, enrolledHosts[1].UUID}, rawCmd1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// command has no results yet, so the status is empty
|
||||
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res, 2)
|
||||
|
||||
require.NotZero(t, res[0].UpdatedAt)
|
||||
res[0].UpdatedAt = time.Time{}
|
||||
require.NotZero(t, res[1].UpdatedAt)
|
||||
res[1].UpdatedAt = time.Time{}
|
||||
|
||||
require.ElementsMatch(t, res, []*fleet.MDMAppleCommand{
|
||||
{
|
||||
DeviceID: enrolledHosts[0].UUID,
|
||||
CommandUUID: uuid1,
|
||||
Status: "",
|
||||
RequestType: "ListApps",
|
||||
Hostname: enrolledHosts[0].Hostname,
|
||||
TeamID: nil,
|
||||
},
|
||||
{
|
||||
DeviceID: enrolledHosts[1].UUID,
|
||||
CommandUUID: uuid1,
|
||||
Status: "",
|
||||
RequestType: "ListApps",
|
||||
Hostname: enrolledHosts[1].Hostname,
|
||||
TeamID: nil,
|
||||
},
|
||||
})
|
||||
|
||||
// simulate a result for enrolledHosts[0]
|
||||
err = storage.StoreCommandReport(&mdm.Request{
|
||||
EnrollID: &mdm.EnrollID{ID: enrolledHosts[0].UUID},
|
||||
Context: ctx,
|
||||
}, &mdm.CommandResults{
|
||||
CommandUUID: uuid1,
|
||||
Status: "Acknowledged",
|
||||
RequestType: "ListApps",
|
||||
Raw: []byte(rawCmd1),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// command is now listed with a status for this result
|
||||
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res, 2)
|
||||
|
||||
require.NotZero(t, res[0].UpdatedAt)
|
||||
res[0].UpdatedAt = time.Time{}
|
||||
require.NotZero(t, res[1].UpdatedAt)
|
||||
res[1].UpdatedAt = time.Time{}
|
||||
|
||||
require.ElementsMatch(t, res, []*fleet.MDMAppleCommand{
|
||||
{
|
||||
DeviceID: enrolledHosts[0].UUID,
|
||||
CommandUUID: uuid1,
|
||||
Status: "Acknowledged",
|
||||
RequestType: "ListApps",
|
||||
Hostname: enrolledHosts[0].Hostname,
|
||||
TeamID: nil,
|
||||
},
|
||||
{
|
||||
DeviceID: enrolledHosts[1].UUID,
|
||||
CommandUUID: uuid1,
|
||||
Status: "",
|
||||
RequestType: "ListApps",
|
||||
Hostname: enrolledHosts[1].Hostname,
|
||||
TeamID: nil,
|
||||
},
|
||||
})
|
||||
|
||||
// simulate a result for enrolledHosts[1]
|
||||
err = storage.StoreCommandReport(&mdm.Request{
|
||||
EnrollID: &mdm.EnrollID{ID: enrolledHosts[1].UUID},
|
||||
Context: ctx,
|
||||
}, &mdm.CommandResults{
|
||||
CommandUUID: uuid1,
|
||||
Status: "Error",
|
||||
RequestType: "ListApps",
|
||||
Raw: []byte(rawCmd1),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// both results are now listed
|
||||
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res, 2)
|
||||
|
||||
require.NotZero(t, res[0].UpdatedAt)
|
||||
res[0].UpdatedAt = time.Time{}
|
||||
require.NotZero(t, res[1].UpdatedAt)
|
||||
res[1].UpdatedAt = time.Time{}
|
||||
|
||||
require.ElementsMatch(t, res, []*fleet.MDMAppleCommand{
|
||||
{
|
||||
DeviceID: enrolledHosts[0].UUID,
|
||||
CommandUUID: uuid1,
|
||||
Status: "Acknowledged",
|
||||
RequestType: "ListApps",
|
||||
Hostname: enrolledHosts[0].Hostname,
|
||||
TeamID: nil,
|
||||
},
|
||||
{
|
||||
DeviceID: enrolledHosts[1].UUID,
|
||||
CommandUUID: uuid1,
|
||||
Status: "Error",
|
||||
RequestType: "ListApps",
|
||||
Hostname: enrolledHosts[1].Hostname,
|
||||
TeamID: nil,
|
||||
},
|
||||
})
|
||||
|
||||
// enqueue another command for enrolled hosts [1] and [2]
|
||||
uuid2 := uuid.New().String()
|
||||
rawCmd2 := createRawCmd("InstallApp", uuid2)
|
||||
err = commander.EnqueueCommand(ctx, []string{enrolledHosts[1].UUID, enrolledHosts[2].UUID}, rawCmd2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// simulate a result for enrolledHosts[1] and [2]
|
||||
err = storage.StoreCommandReport(&mdm.Request{
|
||||
EnrollID: &mdm.EnrollID{ID: enrolledHosts[1].UUID},
|
||||
Context: ctx,
|
||||
}, &mdm.CommandResults{
|
||||
CommandUUID: uuid2,
|
||||
Status: "Acknowledged",
|
||||
RequestType: "InstallApp",
|
||||
Raw: []byte(rawCmd2),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = storage.StoreCommandReport(&mdm.Request{
|
||||
EnrollID: &mdm.EnrollID{ID: enrolledHosts[2].UUID},
|
||||
Context: ctx,
|
||||
}, &mdm.CommandResults{
|
||||
CommandUUID: uuid2,
|
||||
Status: "Acknowledged",
|
||||
RequestType: "InstallApp",
|
||||
Raw: []byte(rawCmd2),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// results are listed
|
||||
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res, 4)
|
||||
|
||||
// page-by-page: first page
|
||||
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{
|
||||
ListOptions: fleet.ListOptions{Page: 0, PerPage: 3, OrderKey: "device_id", OrderDirection: fleet.OrderDescending},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res, 3)
|
||||
|
||||
// page-by-page: second page
|
||||
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMAppleCommandListOptions{
|
||||
ListOptions: fleet.ListOptions{Page: 1, PerPage: 3, OrderKey: "device_id", OrderDirection: fleet.OrderDescending},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res, 1)
|
||||
|
||||
// filter by a user from team tm1, can only see that team's host
|
||||
u1, err := ds.NewUser(ctx, &fleet.User{
|
||||
Password: []byte("garbage"),
|
||||
Salt: "garbage",
|
||||
Name: "user1",
|
||||
Email: "user1@example.com",
|
||||
GlobalRole: nil,
|
||||
Teams: []fleet.UserTeam{
|
||||
{Team: *tm1, Role: fleet.RoleObserver},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
u1, err = ds.UserByID(ctx, u1.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// u1 is an observer, so if IncludeObserver is not set, returns nothing
|
||||
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: u1}, &fleet.MDMAppleCommandListOptions{
|
||||
ListOptions: fleet.ListOptions{PerPage: 3},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res, 0)
|
||||
|
||||
// now with IncludeObserver set to true
|
||||
res, err = ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{User: u1, IncludeObserver: true}, &fleet.MDMAppleCommandListOptions{
|
||||
ListOptions: fleet.ListOptions{PerPage: 3, OrderKey: "updated_at", OrderDirection: fleet.OrderDescending},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res, 1)
|
||||
require.NotZero(t, res[0].UpdatedAt)
|
||||
res[0].UpdatedAt = time.Time{}
|
||||
require.ElementsMatch(t, res, []*fleet.MDMAppleCommand{
|
||||
{
|
||||
DeviceID: enrolledHosts[2].UUID,
|
||||
CommandUUID: uuid2,
|
||||
Status: "Acknowledged",
|
||||
RequestType: "InstallApp",
|
||||
Hostname: enrolledHosts[2].Hostname,
|
||||
TeamID: &tm1.ID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||||
"github.com/micromdm/nanodep/godep"
|
||||
"github.com/micromdm/nanomdm/mdm"
|
||||
)
|
||||
|
||||
type MDMAppleCommandIssuer interface {
|
||||
@@ -248,14 +247,14 @@ type CommandEnqueueResult struct {
|
||||
FailedUUIDs []string `json:"failed_uuids,omitempty"`
|
||||
}
|
||||
|
||||
// MDMAppleCommand represents an Apple MDM command.
|
||||
type MDMAppleCommand struct {
|
||||
*mdm.Command
|
||||
// MDMAppleCommandAuthz is used to check user authorization to read/write an
|
||||
// Apple MDM command.
|
||||
type MDMAppleCommandAuthz struct {
|
||||
TeamID *uint `json:"team_id"` // required for authorization by team
|
||||
}
|
||||
|
||||
// AuthzType implements authz.AuthzTyper.
|
||||
func (m MDMAppleCommand) AuthzType() string {
|
||||
func (m MDMAppleCommandAuthz) AuthzType() string {
|
||||
return "mdm_apple_command"
|
||||
}
|
||||
|
||||
@@ -428,3 +427,42 @@ type NanoEnrollment struct {
|
||||
Enabled bool `json:"-" db:"enabled"`
|
||||
TokenUpdateTally int `json:"-" db:"token_update_tally"`
|
||||
}
|
||||
|
||||
// MDMAppleCommandListOptions defines the options to control the list of MDM
|
||||
// Apple Commands to return. Although it only supports the standard list
|
||||
// options for now, in the future we expect to add filtering options.
|
||||
//
|
||||
// https://github.com/fleetdm/fleet/issues/11008#issuecomment-1503466119
|
||||
type MDMAppleCommandListOptions struct {
|
||||
ListOptions
|
||||
}
|
||||
|
||||
// MDMAppleCommand represents an MDM Apple command that has been enqueued for
|
||||
// execution. It is similar to MDMAppleCommandResult, but a separate struct is
|
||||
// used as there are plans to evolve the `fleetctl get mdm-commands` command
|
||||
// output in the future to list one row per command instead of one per
|
||||
// command-host combination, and this fleetctl command is the only use of this
|
||||
// struct at the moment. Also, it is filled a bit differently than what we do
|
||||
// in MDMAppleCommandResult, since it needs to join with the hosts in the
|
||||
// query to make authorization (retrieving the team id) manageable.
|
||||
//
|
||||
// https://github.com/fleetdm/fleet/issues/11008#issuecomment-1503466119
|
||||
type MDMAppleCommand struct {
|
||||
// DeviceID is the MDM enrollment ID. This is the same as the host UUID.
|
||||
DeviceID string `json:"device_id" db:"device_id"`
|
||||
// CommandUUID is the unique identifier of the command.
|
||||
CommandUUID string `json:"command_uuid" db:"command_uuid"`
|
||||
// UpdatedAt is the last update timestamp of the command result.
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
// RequestType is the command's request type, which is basically the
|
||||
// command name.
|
||||
RequestType string `json:"request_type" db:"request_type"`
|
||||
// Status is the command status. One of Acknowledged, Error, or NotNow.
|
||||
Status string `json:"status" db:"status"`
|
||||
// Hostname is the hostname of the host that executed the command.
|
||||
Hostname string `json:"hostname" db:"hostname"`
|
||||
// TeamID is the host's team, null if the host is in no team. This is used
|
||||
// to authorize the user to see the command, it is not returned as part of
|
||||
// the response payload.
|
||||
TeamID *uint `json:"-" db:"team_id"`
|
||||
}
|
||||
|
||||
@@ -774,6 +774,10 @@ type Datastore interface {
|
||||
// GetMDMAppleCommandResults returns the execution results of a command identified by a CommandUUID.
|
||||
GetMDMAppleCommandResults(ctx context.Context, commandUUID string) ([]*MDMAppleCommandResult, error)
|
||||
|
||||
// ListMDMAppleCommands returns a list of MDM Apple commands that have been
|
||||
// executed, based on the provided options.
|
||||
ListMDMAppleCommands(ctx context.Context, tmFilter TeamFilter, listOpts *MDMAppleCommandListOptions) ([]*MDMAppleCommand, error)
|
||||
|
||||
// NewMDMAppleInstaller creates and stores an Apple installer to Fleet.
|
||||
NewMDMAppleInstaller(ctx context.Context, name string, size int64, manifest string, installer []byte, urlToken string) (*MDMAppleInstaller, error)
|
||||
|
||||
|
||||
@@ -606,6 +606,10 @@ type Service interface {
|
||||
// GetMDMAppleCommandResults returns the execution results of a command identified by a CommandUUID.
|
||||
GetMDMAppleCommandResults(ctx context.Context, commandUUID string) ([]*MDMAppleCommandResult, error)
|
||||
|
||||
// ListMDMAppleCommands returns a list of MDM Apple commands corresponding to
|
||||
// the specified options.
|
||||
ListMDMAppleCommands(ctx context.Context, opts *MDMAppleCommandListOptions) ([]*MDMAppleCommand, error)
|
||||
|
||||
// UploadMDMAppleInstaller uploads an Apple installer to Fleet.
|
||||
UploadMDMAppleInstaller(ctx context.Context, name string, size int64, installer io.Reader) (*MDMAppleInstaller, error)
|
||||
|
||||
|
||||
@@ -540,6 +540,8 @@ type ListMDMAppleEnrollmentProfilesFunc func(ctx context.Context) ([]*fleet.MDMA
|
||||
|
||||
type GetMDMAppleCommandResultsFunc func(ctx context.Context, commandUUID string) ([]*fleet.MDMAppleCommandResult, error)
|
||||
|
||||
type ListMDMAppleCommandsFunc func(ctx context.Context, tmFilter fleet.TeamFilter, listOpts *fleet.MDMAppleCommandListOptions) ([]*fleet.MDMAppleCommand, error)
|
||||
|
||||
type NewMDMAppleInstallerFunc func(ctx context.Context, name string, size int64, manifest string, installer []byte, urlToken string) (*fleet.MDMAppleInstaller, error)
|
||||
|
||||
type MDMAppleInstallerFunc func(ctx context.Context, token string) (*fleet.MDMAppleInstaller, error)
|
||||
@@ -1381,6 +1383,9 @@ type DataStore struct {
|
||||
GetMDMAppleCommandResultsFunc GetMDMAppleCommandResultsFunc
|
||||
GetMDMAppleCommandResultsFuncInvoked bool
|
||||
|
||||
ListMDMAppleCommandsFunc ListMDMAppleCommandsFunc
|
||||
ListMDMAppleCommandsFuncInvoked bool
|
||||
|
||||
NewMDMAppleInstallerFunc NewMDMAppleInstallerFunc
|
||||
NewMDMAppleInstallerFuncInvoked bool
|
||||
|
||||
@@ -3299,6 +3304,13 @@ func (s *DataStore) GetMDMAppleCommandResults(ctx context.Context, commandUUID s
|
||||
return s.GetMDMAppleCommandResultsFunc(ctx, commandUUID)
|
||||
}
|
||||
|
||||
func (s *DataStore) ListMDMAppleCommands(ctx context.Context, tmFilter fleet.TeamFilter, listOpts *fleet.MDMAppleCommandListOptions) ([]*fleet.MDMAppleCommand, error) {
|
||||
s.mu.Lock()
|
||||
s.ListMDMAppleCommandsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ListMDMAppleCommandsFunc(ctx, tmFilter, listOpts)
|
||||
}
|
||||
|
||||
func (s *DataStore) NewMDMAppleInstaller(ctx context.Context, name string, size int64, manifest string, installer []byte, urlToken string) (*fleet.MDMAppleInstaller, error) {
|
||||
s.mu.Lock()
|
||||
s.NewMDMAppleInstallerFuncInvoked = true
|
||||
|
||||
+112
-9
@@ -256,7 +256,7 @@ func (svc *Service) GetMDMAppleCommandResults(ctx context.Context, commandUUID s
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// collect the team IDs and verify that the user has access to run commands
|
||||
// collect the team IDs and verify that the user has access to view commands
|
||||
// on all affected teams. Index the hosts by uuid for easly lookup as
|
||||
// afterwards we'll want to store the hostname on the returned results.
|
||||
hostsByUUID := make(map[string]*fleet.Host, len(hosts))
|
||||
@@ -270,14 +270,14 @@ func (svc *Service) GetMDMAppleCommandResults(ctx context.Context, commandUUID s
|
||||
hostsByUUID[h.UUID] = h
|
||||
}
|
||||
|
||||
var command fleet.MDMAppleCommand
|
||||
var commandAuthz fleet.MDMAppleCommandAuthz
|
||||
for tmID := range teamIDs {
|
||||
command.TeamID = &tmID
|
||||
commandAuthz.TeamID = &tmID
|
||||
if tmID == 0 {
|
||||
command.TeamID = nil
|
||||
commandAuthz.TeamID = nil
|
||||
}
|
||||
|
||||
if err := svc.authz.Authorize(ctx, command, fleet.ActionRead); err != nil {
|
||||
if err := svc.authz.Authorize(ctx, commandAuthz, fleet.ActionRead); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,109 @@ func (svc *Service) GetMDMAppleCommandResults(ctx context.Context, commandUUID s
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type listMDMAppleCommandsRequest struct {
|
||||
ListOptions fleet.ListOptions `url:"list_options"`
|
||||
}
|
||||
|
||||
type listMDMAppleCommandsResponse struct {
|
||||
Results []*fleet.MDMAppleCommand `json:"results"`
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r listMDMAppleCommandsResponse) error() error { return r.Err }
|
||||
|
||||
func listMDMAppleCommandsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
||||
req := request.(*listMDMAppleCommandsRequest)
|
||||
results, err := svc.ListMDMAppleCommands(ctx, &fleet.MDMAppleCommandListOptions{
|
||||
ListOptions: req.ListOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return listMDMAppleCommandsResponse{
|
||||
Err: err,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return listMDMAppleCommandsResponse{
|
||||
Results: results,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) ListMDMAppleCommands(ctx context.Context, opts *fleet.MDMAppleCommandListOptions) ([]*fleet.MDMAppleCommand, error) {
|
||||
// first, authorize that the user has the right to list hosts
|
||||
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
vc, ok := viewer.FromContext(ctx)
|
||||
if !ok {
|
||||
return nil, fleet.ErrNoContext
|
||||
}
|
||||
|
||||
// get the list of commands so we know what hosts (and therefore what teams)
|
||||
// we're dealing with. Including the observers as they are allowed to view
|
||||
// MDM Apple commands.
|
||||
results, err := svc.ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{
|
||||
User: vc.User,
|
||||
IncludeObserver: true,
|
||||
}, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// collect the different team IDs and verify that the user has access to view
|
||||
// commands on all affected teams, do not assume that ListMDMAppleCommands
|
||||
// only returned hosts that the user is authorized to view the command
|
||||
// results of (that is, always verify with our rego authz policy).
|
||||
teamIDs := make(map[uint]bool)
|
||||
for _, res := range results {
|
||||
var id uint
|
||||
if res.TeamID != nil {
|
||||
id = *res.TeamID
|
||||
}
|
||||
teamIDs[id] = true
|
||||
}
|
||||
|
||||
// instead of returning an authz error if the user is not authorized for a
|
||||
// team, we remove those commands from the results (as we want to return
|
||||
// whatever the user is allowed to see). Since this can only be done after
|
||||
// retrieving the list of commands, this may result in returning less results
|
||||
// than requested, but it's ok - it's expected that the results retrieved
|
||||
// from the datastore will all be authorized for the user.
|
||||
var commandAuthz fleet.MDMAppleCommandAuthz
|
||||
var authzErr error
|
||||
for tmID := range teamIDs {
|
||||
commandAuthz.TeamID = &tmID
|
||||
if tmID == 0 {
|
||||
commandAuthz.TeamID = nil
|
||||
}
|
||||
if err := svc.authz.Authorize(ctx, commandAuthz, fleet.ActionRead); err != nil {
|
||||
if authzErr == nil {
|
||||
authzErr = err
|
||||
}
|
||||
teamIDs[tmID] = false
|
||||
}
|
||||
}
|
||||
|
||||
if authzErr != nil {
|
||||
level.Error(svc.logger).Log("err", "unauthorized to view some team commands", "details", authzErr)
|
||||
|
||||
// filter-out the teams that the user is not allowed to view
|
||||
allowedResults := make([]*fleet.MDMAppleCommand, 0, len(results))
|
||||
for _, res := range results {
|
||||
var id uint
|
||||
if res.TeamID != nil {
|
||||
id = *res.TeamID
|
||||
}
|
||||
if teamIDs[id] {
|
||||
allowedResults = append(allowedResults, res)
|
||||
}
|
||||
}
|
||||
results = allowedResults
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type newMDMAppleConfigProfileRequest struct {
|
||||
TeamID uint
|
||||
Profile *multipart.FileHeader
|
||||
@@ -1008,14 +1111,14 @@ func (svc *Service) EnqueueMDMAppleCommand(
|
||||
teamIDs[id] = true
|
||||
}
|
||||
|
||||
var command fleet.MDMAppleCommand
|
||||
var commandAuthz fleet.MDMAppleCommandAuthz
|
||||
for tmID := range teamIDs {
|
||||
command.TeamID = &tmID
|
||||
commandAuthz.TeamID = &tmID
|
||||
if tmID == 0 {
|
||||
command.TeamID = nil
|
||||
commandAuthz.TeamID = nil
|
||||
}
|
||||
|
||||
if err := svc.authz.Authorize(ctx, command, fleet.ActionWrite); err != nil {
|
||||
if err := svc.authz.Authorize(ctx, commandAuthz, fleet.ActionWrite); err != nil {
|
||||
return 0, nil, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,36 +262,37 @@ func TestAppleMDMAuthorization(t *testing.T) {
|
||||
</dict>
|
||||
</plist>`))
|
||||
|
||||
enqueueCmdCases := []struct {
|
||||
desc string
|
||||
user *fleet.User
|
||||
uuids []string
|
||||
shoudFailWithAuth bool
|
||||
}{
|
||||
{"no role", test.UserNoRoles, []string{"host1", "host2", "host3", "host4"}, true},
|
||||
{"maintainer can run", test.UserMaintainer, []string{"host1", "host2", "host3", "host4"}, false},
|
||||
{"admin can run", test.UserAdmin, []string{"host1", "host2", "host3", "host4"}, false},
|
||||
{"observer cannot run", test.UserObserver, []string{"host1", "host2", "host3", "host4"}, true},
|
||||
{"team 1 admin can run team 1", test.UserTeamAdminTeam1, []string{"host1", "host2"}, false},
|
||||
{"team 2 admin can run team 2", test.UserTeamAdminTeam2, []string{"host3"}, false},
|
||||
{"team 1 maintainer can run team 1", test.UserTeamMaintainerTeam1, []string{"host1", "host2"}, false},
|
||||
{"team 1 observer cannot run team 1", test.UserTeamObserverTeam1, []string{"host1", "host2"}, true},
|
||||
{"team 1 admin cannot run team 2", test.UserTeamAdminTeam1, []string{"host3"}, true},
|
||||
{"team 1 admin cannot run no team", test.UserTeamAdminTeam1, []string{"host4"}, true},
|
||||
{"team 1 admin cannot run mix of team 1 and 2", test.UserTeamAdminTeam1, []string{"host1", "host3"}, true},
|
||||
}
|
||||
for _, c := range enqueueCmdCases {
|
||||
t.Run(c.desc, func(t *testing.T) {
|
||||
ctx = test.UserContext(ctx, c.user)
|
||||
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64FreeCmd, c.uuids, false)
|
||||
checkAuthErr(t, err, c.shoudFailWithAuth)
|
||||
})
|
||||
}
|
||||
t.Run("EnqueueMDMAppleCommand", func(t *testing.T) {
|
||||
enqueueCmdCases := []struct {
|
||||
desc string
|
||||
user *fleet.User
|
||||
uuids []string
|
||||
shoudFailWithAuth bool
|
||||
}{
|
||||
{"no role", test.UserNoRoles, []string{"host1", "host2", "host3", "host4"}, true},
|
||||
{"maintainer can run", test.UserMaintainer, []string{"host1", "host2", "host3", "host4"}, false},
|
||||
{"admin can run", test.UserAdmin, []string{"host1", "host2", "host3", "host4"}, false},
|
||||
{"observer cannot run", test.UserObserver, []string{"host1", "host2", "host3", "host4"}, true},
|
||||
{"team 1 admin can run team 1", test.UserTeamAdminTeam1, []string{"host1", "host2"}, false},
|
||||
{"team 2 admin can run team 2", test.UserTeamAdminTeam2, []string{"host3"}, false},
|
||||
{"team 1 maintainer can run team 1", test.UserTeamMaintainerTeam1, []string{"host1", "host2"}, false},
|
||||
{"team 1 observer cannot run team 1", test.UserTeamObserverTeam1, []string{"host1", "host2"}, true},
|
||||
{"team 1 admin cannot run team 2", test.UserTeamAdminTeam1, []string{"host3"}, true},
|
||||
{"team 1 admin cannot run no team", test.UserTeamAdminTeam1, []string{"host4"}, true},
|
||||
{"team 1 admin cannot run mix of team 1 and 2", test.UserTeamAdminTeam1, []string{"host1", "host3"}, true},
|
||||
}
|
||||
for _, c := range enqueueCmdCases {
|
||||
t.Run(c.desc, func(t *testing.T) {
|
||||
ctx = test.UserContext(ctx, c.user)
|
||||
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64FreeCmd, c.uuids, false)
|
||||
checkAuthErr(t, err, c.shoudFailWithAuth)
|
||||
})
|
||||
}
|
||||
|
||||
// test with a command that requires a premium license
|
||||
ctx = test.UserContext(ctx, test.UserAdmin)
|
||||
ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree})
|
||||
rawB64PremiumCmd := base64.RawStdEncoding.EncodeToString([]byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
// test with a command that requires a premium license
|
||||
ctx = test.UserContext(ctx, test.UserAdmin)
|
||||
ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree})
|
||||
rawB64PremiumCmd := base64.RawStdEncoding.EncodeToString([]byte(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>
|
||||
@@ -304,9 +305,10 @@ func TestAppleMDMAuthorization(t *testing.T) {
|
||||
<string>uuid</string>
|
||||
</dict>
|
||||
</plist>`, "DeviceLock")))
|
||||
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"}, false)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, fleet.ErrMissingLicense.Error())
|
||||
_, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"}, false)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, fleet.ErrMissingLicense.Error())
|
||||
})
|
||||
|
||||
cmdUUIDToHostUUIDs := map[string][]string{
|
||||
"uuidTm1": {"host1", "host2"},
|
||||
@@ -325,53 +327,94 @@ func TestAppleMDMAuthorization(t *testing.T) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
cmdResultsCases := []struct {
|
||||
desc string
|
||||
user *fleet.User
|
||||
cmdUUID string
|
||||
shoudFailWithAuth bool
|
||||
}{
|
||||
{"no role", test.UserNoRoles, "uuidTm1", true},
|
||||
{"maintainer can view", test.UserMaintainer, "uuidTm1", false},
|
||||
{"maintainer can view", test.UserMaintainer, "uuidTm2", false},
|
||||
{"maintainer can view", test.UserMaintainer, "uuidNoTm", false},
|
||||
{"maintainer can view", test.UserMaintainer, "uuidMixTm1Tm2", false},
|
||||
{"observer can view", test.UserObserver, "uuidTm1", false},
|
||||
{"observer can view", test.UserObserver, "uuidTm2", false},
|
||||
{"observer can view", test.UserObserver, "uuidNoTm", false},
|
||||
{"observer can view", test.UserObserver, "uuidMixTm1Tm2", false},
|
||||
{"observer+ can view", test.UserObserverPlus, "uuidTm1", false},
|
||||
{"observer+ can view", test.UserObserverPlus, "uuidTm2", false},
|
||||
{"observer+ can view", test.UserObserverPlus, "uuidNoTm", false},
|
||||
{"observer+ can view", test.UserObserverPlus, "uuidMixTm1Tm2", false},
|
||||
{"admin can view", test.UserAdmin, "uuidTm1", false},
|
||||
{"admin can view", test.UserAdmin, "uuidTm2", false},
|
||||
{"admin can view", test.UserAdmin, "uuidNoTm", false},
|
||||
{"admin can view", test.UserAdmin, "uuidMixTm1Tm2", false},
|
||||
{"tm1 maintainer can view tm1", test.UserTeamMaintainerTeam1, "uuidTm1", false},
|
||||
{"tm1 maintainer cannot view tm2", test.UserTeamMaintainerTeam1, "uuidTm2", true},
|
||||
{"tm1 maintainer cannot view no team", test.UserTeamMaintainerTeam1, "uuidNoTm", true},
|
||||
{"tm1 maintainer cannot view mix", test.UserTeamMaintainerTeam1, "uuidMixTm1Tm2", true},
|
||||
{"tm1 observer can view tm1", test.UserTeamObserverTeam1, "uuidTm1", false},
|
||||
{"tm1 observer cannot view tm2", test.UserTeamObserverTeam1, "uuidTm2", true},
|
||||
{"tm1 observer cannot view no team", test.UserTeamObserverTeam1, "uuidNoTm", true},
|
||||
{"tm1 observer cannot view mix", test.UserTeamObserverTeam1, "uuidMixTm1Tm2", true},
|
||||
{"tm1 observer+ can view tm1", test.UserTeamObserverPlusTeam1, "uuidTm1", false},
|
||||
{"tm1 observer+ cannot view tm2", test.UserTeamObserverPlusTeam1, "uuidTm2", true},
|
||||
{"tm1 observer+ cannot view no team", test.UserTeamObserverPlusTeam1, "uuidNoTm", true},
|
||||
{"tm1 observer+ cannot view mix", test.UserTeamObserverPlusTeam1, "uuidMixTm1Tm2", true},
|
||||
{"tm1 admin can view tm1", test.UserTeamAdminTeam1, "uuidTm1", false},
|
||||
{"tm1 admin cannot view tm2", test.UserTeamAdminTeam1, "uuidTm2", true},
|
||||
{"tm1 admin cannot view no team", test.UserTeamAdminTeam1, "uuidNoTm", true},
|
||||
{"tm1 admin cannot view mix", test.UserTeamAdminTeam1, "uuidMixTm1Tm2", true},
|
||||
}
|
||||
for _, c := range cmdResultsCases {
|
||||
t.Run(c.desc, func(t *testing.T) {
|
||||
ctx = test.UserContext(ctx, c.user)
|
||||
_, err = svc.GetMDMAppleCommandResults(ctx, c.cmdUUID)
|
||||
checkAuthErr(t, err, c.shoudFailWithAuth)
|
||||
})
|
||||
}
|
||||
t.Run("GetMDMAppleCommandResults", func(t *testing.T) {
|
||||
cmdResultsCases := []struct {
|
||||
desc string
|
||||
user *fleet.User
|
||||
cmdUUID string
|
||||
shoudFailWithAuth bool
|
||||
}{
|
||||
{"no role", test.UserNoRoles, "uuidTm1", true},
|
||||
{"maintainer can view", test.UserMaintainer, "uuidTm1", false},
|
||||
{"maintainer can view", test.UserMaintainer, "uuidTm2", false},
|
||||
{"maintainer can view", test.UserMaintainer, "uuidNoTm", false},
|
||||
{"maintainer can view", test.UserMaintainer, "uuidMixTm1Tm2", false},
|
||||
{"observer can view", test.UserObserver, "uuidTm1", false},
|
||||
{"observer can view", test.UserObserver, "uuidTm2", false},
|
||||
{"observer can view", test.UserObserver, "uuidNoTm", false},
|
||||
{"observer can view", test.UserObserver, "uuidMixTm1Tm2", false},
|
||||
{"observer+ can view", test.UserObserverPlus, "uuidTm1", false},
|
||||
{"observer+ can view", test.UserObserverPlus, "uuidTm2", false},
|
||||
{"observer+ can view", test.UserObserverPlus, "uuidNoTm", false},
|
||||
{"observer+ can view", test.UserObserverPlus, "uuidMixTm1Tm2", false},
|
||||
{"admin can view", test.UserAdmin, "uuidTm1", false},
|
||||
{"admin can view", test.UserAdmin, "uuidTm2", false},
|
||||
{"admin can view", test.UserAdmin, "uuidNoTm", false},
|
||||
{"admin can view", test.UserAdmin, "uuidMixTm1Tm2", false},
|
||||
{"tm1 maintainer can view tm1", test.UserTeamMaintainerTeam1, "uuidTm1", false},
|
||||
{"tm1 maintainer cannot view tm2", test.UserTeamMaintainerTeam1, "uuidTm2", true},
|
||||
{"tm1 maintainer cannot view no team", test.UserTeamMaintainerTeam1, "uuidNoTm", true},
|
||||
{"tm1 maintainer cannot view mix", test.UserTeamMaintainerTeam1, "uuidMixTm1Tm2", true},
|
||||
{"tm1 observer can view tm1", test.UserTeamObserverTeam1, "uuidTm1", false},
|
||||
{"tm1 observer cannot view tm2", test.UserTeamObserverTeam1, "uuidTm2", true},
|
||||
{"tm1 observer cannot view no team", test.UserTeamObserverTeam1, "uuidNoTm", true},
|
||||
{"tm1 observer cannot view mix", test.UserTeamObserverTeam1, "uuidMixTm1Tm2", true},
|
||||
{"tm1 observer+ can view tm1", test.UserTeamObserverPlusTeam1, "uuidTm1", false},
|
||||
{"tm1 observer+ cannot view tm2", test.UserTeamObserverPlusTeam1, "uuidTm2", true},
|
||||
{"tm1 observer+ cannot view no team", test.UserTeamObserverPlusTeam1, "uuidNoTm", true},
|
||||
{"tm1 observer+ cannot view mix", test.UserTeamObserverPlusTeam1, "uuidMixTm1Tm2", true},
|
||||
{"tm1 admin can view tm1", test.UserTeamAdminTeam1, "uuidTm1", false},
|
||||
{"tm1 admin cannot view tm2", test.UserTeamAdminTeam1, "uuidTm2", true},
|
||||
{"tm1 admin cannot view no team", test.UserTeamAdminTeam1, "uuidNoTm", true},
|
||||
{"tm1 admin cannot view mix", test.UserTeamAdminTeam1, "uuidMixTm1Tm2", true},
|
||||
}
|
||||
for _, c := range cmdResultsCases {
|
||||
t.Run(c.desc, func(t *testing.T) {
|
||||
ctx = test.UserContext(ctx, c.user)
|
||||
_, err = svc.GetMDMAppleCommandResults(ctx, c.cmdUUID)
|
||||
checkAuthErr(t, err, c.shoudFailWithAuth)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListMDMAppleCommands", func(t *testing.T) {
|
||||
ds.ListMDMAppleCommandsFunc = func(ctx context.Context, tmFilter fleet.TeamFilter, opt *fleet.MDMAppleCommandListOptions) ([]*fleet.MDMAppleCommand, error) {
|
||||
return []*fleet.MDMAppleCommand{
|
||||
{DeviceID: "no team", TeamID: nil},
|
||||
{DeviceID: "tm1", TeamID: ptr.Uint(1)},
|
||||
{DeviceID: "tm2", TeamID: ptr.Uint(2)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
listCmdsCases := []struct {
|
||||
desc string
|
||||
user *fleet.User
|
||||
want []string // the expected device ids in the results
|
||||
}{
|
||||
{"no role", test.UserNoRoles, []string{}},
|
||||
{"maintainer can view", test.UserMaintainer, []string{"no team", "tm1", "tm2"}},
|
||||
{"observer can view", test.UserObserver, []string{"no team", "tm1", "tm2"}},
|
||||
{"observer+ can view", test.UserObserverPlus, []string{"no team", "tm1", "tm2"}},
|
||||
{"admin can view", test.UserAdmin, []string{"no team", "tm1", "tm2"}},
|
||||
{"tm1 maintainer can view tm1", test.UserTeamMaintainerTeam1, []string{"tm1"}},
|
||||
{"tm1 observer can view tm1", test.UserTeamObserverTeam1, []string{"tm1"}},
|
||||
{"tm1 observer+ can view tm1", test.UserTeamObserverPlusTeam1, []string{"tm1"}},
|
||||
{"tm1 admin can view tm1", test.UserTeamAdminTeam1, []string{"tm1"}},
|
||||
}
|
||||
for _, c := range listCmdsCases {
|
||||
t.Run(c.desc, func(t *testing.T) {
|
||||
ctx = test.UserContext(ctx, c.user)
|
||||
res, err := svc.ListMDMAppleCommands(ctx, &fleet.MDMAppleCommandListOptions{})
|
||||
require.NoError(t, err) // never fails with authz error, it just filters out unauthorized results
|
||||
|
||||
got := make([]string, len(res))
|
||||
for i, r := range res {
|
||||
got[i] = r.DeviceID
|
||||
}
|
||||
require.Equal(t, c.want, got)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMDMAppleEnrollURL(t *testing.T) {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
@@ -17,27 +11,6 @@ import (
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
func (c *Client) CreateEnrollmentProfile(enrollmentProfileType fleet.MDMAppleEnrollmentType, depProfile *json.RawMessage) (*fleet.MDMAppleEnrollmentProfile, error) {
|
||||
request := createMDMAppleEnrollmentProfileRequest{
|
||||
Type: enrollmentProfileType,
|
||||
DEPProfile: depProfile,
|
||||
}
|
||||
var response createMDMAppleEnrollmentProfileResponse
|
||||
if err := c.authenticatedRequest(request, "POST", "/api/latest/fleet/mdm/apple/enrollmentprofiles", &response); err != nil {
|
||||
return nil, fmt.Errorf("request: %w", err)
|
||||
}
|
||||
return response.EnrollmentProfile, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListEnrollments() ([]*fleet.MDMAppleEnrollmentProfile, error) {
|
||||
request := listMDMAppleEnrollmentProfilesRequest{}
|
||||
var response listMDMAppleEnrollmentProfilesResponse
|
||||
if err := c.authenticatedRequest(request, "GET", "/api/latest/fleet/mdm/apple/enrollmentprofiles", &response); err != nil {
|
||||
return nil, fmt.Errorf("request: %w", err)
|
||||
}
|
||||
return response.EnrollmentProfiles, nil
|
||||
}
|
||||
|
||||
func (c *Client) EnqueueCommand(deviceIDs []string, rawPlist []byte) (*fleet.CommandEnqueueResult, error) {
|
||||
var commandPayload map[string]interface{}
|
||||
if _, err := plist.Unmarshal(rawPlist, &commandPayload); err != nil {
|
||||
@@ -78,98 +51,21 @@ func (c *Client) MDMAppleGetCommandResults(commandUUID string) ([]*fleet.MDMAppl
|
||||
return responseBody.Results, nil
|
||||
}
|
||||
|
||||
func (c *Client) UploadMDMAppleInstaller(ctx context.Context, name string, installer io.Reader) (uint, error) {
|
||||
if c.token == "" {
|
||||
return 0, errors.New("authentication token is empty")
|
||||
}
|
||||
func (c *Client) MDMAppleListCommands() ([]*fleet.MDMAppleCommand, error) {
|
||||
const defaultCommandsPerPage = 1000
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
fw, err := writer.CreateFormFile("installer", name)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create form file: %w", err)
|
||||
}
|
||||
_, err = io.Copy(fw, installer)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("write form file: %w", err)
|
||||
}
|
||||
writer.Close()
|
||||
verb, path := http.MethodGet, "/api/latest/fleet/mdm/apple/commands"
|
||||
|
||||
var (
|
||||
verb = "POST"
|
||||
path = "/api/latest/fleet/mdm/apple/installers"
|
||||
)
|
||||
response, err := c.doContextWithBodyAndHeaders(ctx, verb, path, "",
|
||||
body.Bytes(),
|
||||
map[string]string{
|
||||
"Content-Type": writer.FormDataContentType(),
|
||||
"Accept": "application/json",
|
||||
"Authorization": fmt.Sprintf("Bearer %s", c.token),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("do multipart request: %w", err)
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("per_page", fmt.Sprint(defaultCommandsPerPage))
|
||||
query.Set("order_key", "updated_at")
|
||||
query.Set("order_direction", "desc")
|
||||
|
||||
var installerResponse uploadAppleInstallerResponse
|
||||
if err := c.parseResponse(verb, path, response, &installerResponse); err != nil {
|
||||
return 0, fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return installerResponse.ID, nil
|
||||
}
|
||||
|
||||
func (c *Client) MDMAppleGetInstallerDetails(id uint) (*fleet.MDMAppleInstaller, error) {
|
||||
verb, path := http.MethodGet, fmt.Sprintf("/api/latest/fleet/mdm/apple/installers/%d", id)
|
||||
|
||||
var responseBody getAppleInstallerDetailsResponse
|
||||
err := c.authenticatedRequest(nil, verb, path, &responseBody)
|
||||
var responseBody listMDMAppleCommandsResponse
|
||||
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query.Encode())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
|
||||
return responseBody.Installer, nil
|
||||
}
|
||||
|
||||
func (c *Client) MDMAppleListDevices() ([]fleet.MDMAppleDevice, error) {
|
||||
verb, path := http.MethodGet, "/api/latest/fleet/mdm/apple/devices"
|
||||
|
||||
var responseBody listMDMAppleDevicesResponse
|
||||
err := c.authenticatedRequest(nil, verb, path, &responseBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
|
||||
return responseBody.Devices, nil
|
||||
}
|
||||
|
||||
func (c *Client) DEPListDevices() ([]fleet.MDMAppleDEPDevice, error) {
|
||||
verb, path := http.MethodGet, "/api/latest/fleet/mdm/apple/dep/devices"
|
||||
|
||||
var responseBody listMDMAppleDEPDevicesResponse
|
||||
err := c.authenticatedRequest(nil, verb, path, &responseBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
|
||||
return responseBody.Devices, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListMDMAppleInstallers() ([]fleet.MDMAppleInstaller, error) {
|
||||
request := listMDMAppleInstallersRequest{}
|
||||
var response listMDMAppleInstallersResponse
|
||||
if err := c.authenticatedRequest(request, "GET", "/api/latest/fleet/mdm/apple/installers", &response); err != nil {
|
||||
return nil, fmt.Errorf("request: %w", err)
|
||||
}
|
||||
return response.Installers, nil
|
||||
}
|
||||
|
||||
func (c *Client) MDMDeleteAppleInstaller(id uint) error {
|
||||
verb, path := http.MethodDelete, fmt.Sprintf("/api/latest/fleet/mdm/apple/installers/%d", id)
|
||||
|
||||
var responseBody deleteAppleInstallerDetailsResponse
|
||||
err := c.authenticatedRequest(nil, verb, path, &responseBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
return nil
|
||||
return responseBody.Results, nil
|
||||
}
|
||||
|
||||
@@ -436,16 +436,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
||||
// when you add an endpoint that's behind the mdmConfiguredMiddleware.
|
||||
mdmConfiguredMiddleware := mdmconfigured.NewAppleMiddleware(svc)
|
||||
mdm := ue.WithCustomMiddleware(mdmConfiguredMiddleware.Verify())
|
||||
mdm.POST("/api/_version_/fleet/mdm/apple/enrollmentprofiles", createMDMAppleEnrollmentProfilesEndpoint, createMDMAppleEnrollmentProfileRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/enrollmentprofiles", listMDMAppleEnrollmentsEndpoint, listMDMAppleEnrollmentProfilesRequest{})
|
||||
mdm.POST("/api/_version_/fleet/mdm/apple/enqueue", enqueueMDMAppleCommandEndpoint, enqueueMDMAppleCommandRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/commandresults", getMDMAppleCommandResultsEndpoint, getMDMAppleCommandResultsRequest{})
|
||||
mdm.POST("/api/_version_/fleet/mdm/apple/installers", uploadAppleInstallerEndpoint, uploadAppleInstallerRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", getAppleInstallerEndpoint, getAppleInstallerDetailsRequest{})
|
||||
mdm.DELETE("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", deleteAppleInstallerEndpoint, deleteAppleInstallerDetailsRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/installers", listMDMAppleInstallersEndpoint, listMDMAppleInstallersRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/devices", listMDMAppleDevicesEndpoint, listMDMAppleDevicesRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/dep/devices", listMDMAppleDEPDevicesEndpoint, listMDMAppleDEPDevicesRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/commands", listMDMAppleCommandsEndpoint, listMDMAppleCommandsRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/filevault/summary", getMdmAppleFileVaultSummaryEndpoint, getMDMAppleFileVaultSummaryRequest{})
|
||||
mdm.POST("/api/_version_/fleet/mdm/apple/profiles", newMDMAppleConfigProfileEndpoint, newMDMAppleConfigProfileRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesEndpoint, listMDMAppleConfigProfilesRequest{})
|
||||
@@ -453,6 +446,17 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
||||
mdm.DELETE("/api/_version_/fleet/mdm/apple/profiles/{profile_id:[0-9]+}", deleteMDMAppleConfigProfileEndpoint, deleteMDMAppleConfigProfileRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/profiles/summary", getMDMAppleProfilesSummaryEndpoint, getMDMAppleProfilesSummaryRequest{})
|
||||
|
||||
// TODO: are those undocumented endpoints still needed? I think they were only used
|
||||
// by 'fleetctl apple-mdm' sub-commands.
|
||||
mdm.POST("/api/_version_/fleet/mdm/apple/enrollmentprofiles", createMDMAppleEnrollmentProfilesEndpoint, createMDMAppleEnrollmentProfileRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/enrollmentprofiles", listMDMAppleEnrollmentsEndpoint, listMDMAppleEnrollmentProfilesRequest{})
|
||||
mdm.POST("/api/_version_/fleet/mdm/apple/installers", uploadAppleInstallerEndpoint, uploadAppleInstallerRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", getAppleInstallerEndpoint, getAppleInstallerDetailsRequest{})
|
||||
mdm.DELETE("/api/_version_/fleet/mdm/apple/installers/{installer_id:[0-9]+}", deleteAppleInstallerEndpoint, deleteAppleInstallerDetailsRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/installers", listMDMAppleInstallersEndpoint, listMDMAppleInstallersRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/devices", listMDMAppleDevicesEndpoint, listMDMAppleDevicesRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/dep/devices", listMDMAppleDEPDevicesEndpoint, listMDMAppleDEPDevicesRequest{})
|
||||
|
||||
// bootstrap-package routes
|
||||
mdm.POST("/api/_version_/fleet/mdm/apple/bootstrap", uploadBootstrapPackageEndpoint, uploadBootstrapPackageRequest{})
|
||||
mdm.GET("/api/_version_/fleet/mdm/apple/bootstrap/{team_id:[0-9]+}/metadata", bootstrapPackageMetadataEndpoint, bootstrapPackageMetadataRequest{})
|
||||
|
||||
@@ -2563,6 +2563,11 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
|
||||
var cmdResResp getMDMAppleCommandResultsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commandresults", nil, http.StatusNotFound, &cmdResResp, "command_uuid", uuid1)
|
||||
|
||||
// list commands returns empty set
|
||||
var listCmdResp listMDMAppleCommandsResponse
|
||||
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)
|
||||
require.Empty(t, listCmdResp.Results)
|
||||
|
||||
// call with unenrolled host UUID
|
||||
res := s.Do("POST", "/api/latest/fleet/mdm/apple/enqueue",
|
||||
enqueueMDMAppleCommandRequest{
|
||||
@@ -2620,6 +2625,19 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
|
||||
Result: []byte(rawCmd),
|
||||
Hostname: "test-host",
|
||||
}, cmdResResp.Results[0])
|
||||
|
||||
// list commands returns that command
|
||||
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)
|
||||
require.Len(t, listCmdResp.Results, 1)
|
||||
require.NotZero(t, listCmdResp.Results[0].UpdatedAt)
|
||||
listCmdResp.Results[0].UpdatedAt = time.Time{}
|
||||
require.Equal(t, &fleet.MDMAppleCommand{
|
||||
DeviceID: enrolledHost.uuid,
|
||||
CommandUUID: uuid2,
|
||||
Status: "Acknowledged",
|
||||
RequestType: "ProfileList",
|
||||
Hostname: "test-host",
|
||||
}, listCmdResp.Results[0])
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestBootstrapPackage() {
|
||||
|
||||
Reference in New Issue
Block a user