diff --git a/changes/issue-9643-fleetctl-mdm-run-command b/changes/issue-9643-fleetctl-mdm-run-command
new file mode 100644
index 0000000000..efa70aeb5b
--- /dev/null
+++ b/changes/issue-9643-fleetctl-mdm-run-command
@@ -0,0 +1 @@
+* Added the `fleetctl mdm run-command` command, to run any of the [Apple-supported MDM commands](https://developer.apple.com/documentation/devicemanagement/commands_and_queries) on a host.
diff --git a/cmd/fleetctl/apple_mdm.go b/cmd/fleetctl/apple_mdm.go
index a706f3e1f3..04c1fce012 100644
--- a/cmd/fleetctl/apple_mdm.go
+++ b/cmd/fleetctl/apple_mdm.go
@@ -11,7 +11,6 @@ import (
"strings"
"github.com/fleetdm/fleet/v4/server/fleet"
- apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/groob/plist"
"github.com/micromdm/micromdm/mdm/appmanifest"
@@ -50,172 +49,6 @@ func appleMDMCommand() *cli.Command {
}
}
-func generateCommand() *cli.Command {
- return &cli.Command{
- Name: "generate",
- // TODO: Remove when Apple MDM is production ready.
- Hidden: true,
- Flags: []cli.Flag{
- configFlag(),
- contextFlag(),
- debugFlag(),
- },
- Subcommands: []*cli.Command{
- generateMDMAppleCommand(),
- generateMDMAppleBMCommand(),
- },
- }
-}
-
-func generateMDMAppleCommand() *cli.Command {
- return &cli.Command{
- Name: "mdm-apple",
- Aliases: []string{"mdm_apple"},
- Usage: "Generates certificate signing request (CSR) and key for Apple Push Notification Service (APNs) and certificate and key for Simple Certificate Enrollment Protocol (SCEP) to turn on MDM features.",
- Flags: []cli.Flag{
- &cli.StringFlag{
- Name: "email",
- Usage: "The email address to send the signed APNS csr to.",
- Required: true,
- },
- &cli.StringFlag{
- Name: "org",
- Usage: "The organization requesting the signed APNS csr.",
- Required: true,
- },
- &cli.StringFlag{
- Name: "apns-key",
- Usage: "The output path for the APNs private key.",
- Value: apnsKeyPath,
- },
- &cli.StringFlag{
- Name: "scep-cert",
- Usage: "The output path for the SCEP CA certificate.",
- Value: scepCACertPath,
- },
- &cli.StringFlag{
- Name: "scep-key",
- Usage: "The output path for the SCEP CA private key.",
- Value: scepCAKeyPath,
- },
- },
- Action: func(c *cli.Context) error {
- email := c.String("email")
- org := c.String("org")
- apnsKeyPath := c.String("apns-key")
- scepCACertPath := c.String("scep-cert")
- scepCAKeyPath := c.String("scep-key")
-
- // get the fleet API client first, so that any login requirement are met
- // before printing the CSR output message.
- client, err := clientFromCLI(c)
- if err != nil {
- return err
- }
-
- fmt.Fprintf(
- c.App.Writer,
- `Sending certificate signing request (CSR) for Apple Push Notification service (APNs) to %s...
-Generating APNs key, Simple Certificate Enrollment Protocol (SCEP) certificate, and SCEP key...
-
-`,
- email,
- )
-
- csr, err := client.RequestAppleCSR(email, org)
- if err != nil {
- return err
- }
-
- if err := os.WriteFile(apnsKeyPath, csr.APNsKey, 0600); err != nil {
- return fmt.Errorf("failed to write APNs private key: %w", err)
- }
- if err := os.WriteFile(scepCACertPath, csr.SCEPCert, 0600); err != nil {
- return fmt.Errorf("failed to write SCEP CA certificate: %w", err)
- }
- if err := os.WriteFile(scepCAKeyPath, csr.SCEPKey, 0600); err != nil {
- return fmt.Errorf("failed to write SCEP CA private key: %w", err)
- }
-
- fmt.Fprintf(
- c.App.Writer,
- `Success!
-
-Generated your APNs key at %s
-
-Generated your SCEP certificate at %s
-
-Generated your SCEP key at %s
-
-Go to your email to download a CSR from Fleet. Then, visit https://identity.apple.com/pushcert to upload the CSR. You should receive an APNs certificate in return from Apple.
-
-Next, use the generated certificates to deploy Fleet with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm
-`,
- apnsKeyPath,
- scepCACertPath,
- scepCAKeyPath,
- )
-
- return nil
- },
- }
-}
-
-func generateMDMAppleBMCommand() *cli.Command {
- return &cli.Command{
- Name: "mdm-apple-bm",
- Aliases: []string{"mdm_apple_bm"},
- Usage: "Generate Apple Business Manager public and private keys to enable automatic enrollment for macOS hosts.",
- Flags: []cli.Flag{
- &cli.StringFlag{
- Name: "public-key",
- Usage: "The output path for the Apple Business Manager public key certificate.",
- Value: bmPublicKeyCertPath,
- },
- &cli.StringFlag{
- Name: "private-key",
- Usage: "The output path for the Apple Business Manager private key.",
- Value: bmPrivateKeyPath,
- },
- },
- Action: func(c *cli.Context) error {
- publicKeyPath := c.String("public-key")
- privateKeyPath := c.String("private-key")
-
- publicKeyPEM, privateKeyPEM, err := apple_mdm.NewDEPKeyPairPEM()
- if err != nil {
- return fmt.Errorf("generate key pair: %w", err)
- }
-
- if err := os.WriteFile(publicKeyPath, publicKeyPEM, defaultFileMode); err != nil {
- return fmt.Errorf("write public key: %w", err)
- }
-
- if err := os.WriteFile(privateKeyPath, privateKeyPEM, defaultFileMode); err != nil {
- return fmt.Errorf("write private key: %w", err)
- }
-
- fmt.Fprintf(
- c.App.Writer,
- `Success!
-
-Generated your public key at %s
-
-Generated your private key at %s
-
-Visit https://business.apple.com/ and create a new MDM server with the public key. Then, download the new MDM server's token.
-
-Next, deploy Fleet with with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm
-`,
- publicKeyPath,
- privateKeyPath,
- )
-
- return nil
- },
- }
-}
-
func appleMDMEnrollmentProfilesCommand() *cli.Command {
return &cli.Command{
Name: "enrollment-profiles",
diff --git a/cmd/fleetctl/fleetctl.go b/cmd/fleetctl/fleetctl.go
index 2b1bd006ed..f817d031df 100644
--- a/cmd/fleetctl/fleetctl.go
+++ b/cmd/fleetctl/fleetctl.go
@@ -101,6 +101,7 @@ func createApp(reader io.Reader, writer io.Writer, exitErrHandler cli.ExitErrHan
},
},
triggerCommand(),
+ mdmCommand(),
}
return app
}
diff --git a/cmd/fleetctl/generate.go b/cmd/fleetctl/generate.go
new file mode 100644
index 0000000000..79a4357292
--- /dev/null
+++ b/cmd/fleetctl/generate.go
@@ -0,0 +1,174 @@
+package main
+
+import (
+ "fmt"
+ "os"
+
+ apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
+ "github.com/urfave/cli/v2"
+)
+
+func generateCommand() *cli.Command {
+ return &cli.Command{
+ Name: "generate",
+ Usage: "Generate certificates and keys required for MDM",
+ Flags: []cli.Flag{
+ configFlag(),
+ contextFlag(),
+ debugFlag(),
+ },
+ Subcommands: []*cli.Command{
+ generateMDMAppleCommand(),
+ generateMDMAppleBMCommand(),
+ },
+ }
+}
+
+func generateMDMAppleCommand() *cli.Command {
+ return &cli.Command{
+ Name: "mdm-apple",
+ Aliases: []string{"mdm_apple"},
+ Usage: "Generates certificate signing request (CSR) and key for Apple Push Notification Service (APNs) and certificate and key for Simple Certificate Enrollment Protocol (SCEP) to turn on MDM features.",
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "email",
+ Usage: "The email address to send the signed APNS csr to.",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "org",
+ Usage: "The organization requesting the signed APNS csr.",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "apns-key",
+ Usage: "The output path for the APNs private key.",
+ Value: apnsKeyPath,
+ },
+ &cli.StringFlag{
+ Name: "scep-cert",
+ Usage: "The output path for the SCEP CA certificate.",
+ Value: scepCACertPath,
+ },
+ &cli.StringFlag{
+ Name: "scep-key",
+ Usage: "The output path for the SCEP CA private key.",
+ Value: scepCAKeyPath,
+ },
+ },
+ Action: func(c *cli.Context) error {
+ email := c.String("email")
+ org := c.String("org")
+ apnsKeyPath := c.String("apns-key")
+ scepCACertPath := c.String("scep-cert")
+ scepCAKeyPath := c.String("scep-key")
+
+ // get the fleet API client first, so that any login requirement are met
+ // before printing the CSR output message.
+ client, err := clientFromCLI(c)
+ if err != nil {
+ return err
+ }
+
+ fmt.Fprintf(
+ c.App.Writer,
+ `Sending certificate signing request (CSR) for Apple Push Notification service (APNs) to %s...
+Generating APNs key, Simple Certificate Enrollment Protocol (SCEP) certificate, and SCEP key...
+
+`,
+ email,
+ )
+
+ csr, err := client.RequestAppleCSR(email, org)
+ if err != nil {
+ return err
+ }
+
+ if err := os.WriteFile(apnsKeyPath, csr.APNsKey, defaultFileMode); err != nil {
+ return fmt.Errorf("failed to write APNs private key: %w", err)
+ }
+ if err := os.WriteFile(scepCACertPath, csr.SCEPCert, defaultFileMode); err != nil {
+ return fmt.Errorf("failed to write SCEP CA certificate: %w", err)
+ }
+ if err := os.WriteFile(scepCAKeyPath, csr.SCEPKey, defaultFileMode); err != nil {
+ return fmt.Errorf("failed to write SCEP CA private key: %w", err)
+ }
+
+ fmt.Fprintf(
+ c.App.Writer,
+ `Success!
+
+Generated your APNs key at %s
+
+Generated your SCEP certificate at %s
+
+Generated your SCEP key at %s
+
+Go to your email to download a CSR from Fleet. Then, visit https://identity.apple.com/pushcert to upload the CSR. You should receive an APNs certificate in return from Apple.
+
+Next, use the generated certificates to deploy Fleet with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm
+`,
+ apnsKeyPath,
+ scepCACertPath,
+ scepCAKeyPath,
+ )
+
+ return nil
+ },
+ }
+}
+
+func generateMDMAppleBMCommand() *cli.Command {
+ return &cli.Command{
+ Name: "mdm-apple-bm",
+ Aliases: []string{"mdm_apple_bm"},
+ Usage: "Generate Apple Business Manager public and private keys to enable automatic enrollment for macOS hosts.",
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "public-key",
+ Usage: "The output path for the Apple Business Manager public key certificate.",
+ Value: bmPublicKeyCertPath,
+ },
+ &cli.StringFlag{
+ Name: "private-key",
+ Usage: "The output path for the Apple Business Manager private key.",
+ Value: bmPrivateKeyPath,
+ },
+ },
+ Action: func(c *cli.Context) error {
+ publicKeyPath := c.String("public-key")
+ privateKeyPath := c.String("private-key")
+
+ publicKeyPEM, privateKeyPEM, err := apple_mdm.NewDEPKeyPairPEM()
+ if err != nil {
+ return fmt.Errorf("generate key pair: %w", err)
+ }
+
+ if err := os.WriteFile(publicKeyPath, publicKeyPEM, defaultFileMode); err != nil {
+ return fmt.Errorf("write public key: %w", err)
+ }
+
+ if err := os.WriteFile(privateKeyPath, privateKeyPEM, defaultFileMode); err != nil {
+ return fmt.Errorf("write private key: %w", err)
+ }
+
+ fmt.Fprintf(
+ c.App.Writer,
+ `Success!
+
+Generated your public key at %s
+
+Generated your private key at %s
+
+Visit https://business.apple.com/ and create a new MDM server with the public key. Then, download the new MDM server's token.
+
+Next, deploy Fleet with with `+"`mdm`"+` configuration: https://fleetdm.com/docs/deploying/configuration#mobile-device-management-mdm
+`,
+ publicKeyPath,
+ privateKeyPath,
+ )
+
+ return nil
+ },
+ }
+}
diff --git a/cmd/fleetctl/apple_mdm_test.go b/cmd/fleetctl/generate_test.go
similarity index 100%
rename from cmd/fleetctl/apple_mdm_test.go
rename to cmd/fleetctl/generate_test.go
diff --git a/cmd/fleetctl/get.go b/cmd/fleetctl/get.go
index 82ee784490..a5daa686b0 100644
--- a/cmd/fleetctl/get.go
+++ b/cmd/fleetctl/get.go
@@ -664,13 +664,9 @@ func getHostsCommand() *cli.Command {
if c.Bool("mdm") || c.Bool("mdm-pending") {
// print an error if MDM is not configured
- appCfg, err := client.GetAppConfig()
- if err != nil {
+ if err := checkMDMEnabled(client); err != nil {
return err
}
- if !appCfg.MDM.EnabledAndConfigured {
- return errors.New("MDM features aren't turned on. Use `fleetctl generate mdm-apple` and then `fleet serve` with `mdm` configuration to turn on MDM features.")
- }
// --mdm and --mdm-pending are mutually exclusive, return an error if
// both are set (one returns the enrolled hosts, the other the pending
@@ -1109,7 +1105,6 @@ func getSoftwareCommand() *cli.Command {
func getMDMAppleCommand() *cli.Command {
return &cli.Command{
Name: "mdm_apple",
- Hidden: true, // TODO: temporary, until the MDM feature is officially released
Aliases: []string{"mdm-apple"},
Usage: "Show Apple Push Notification Service (APNs) information",
Flags: []cli.Flag{
@@ -1159,7 +1154,6 @@ func getMDMAppleCommand() *cli.Command {
func getMDMAppleBMCommand() *cli.Command {
return &cli.Command{
Name: "mdm_apple_bm",
- Hidden: true, // TODO: temporary, until the MDM feature is officially released
Aliases: []string{"mdm-apple-bm"},
Usage: "Show information about Apple Business Manager for automatic enrollment",
Flags: []cli.Flag{
diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go
index 6fe0537e6d..6831944f0a 100644
--- a/cmd/fleetctl/get_test.go
+++ b/cmd/fleetctl/get_test.go
@@ -244,9 +244,8 @@ func TestGetTeamsByName(t *testing.T) {
func TestGetHosts(t *testing.T) {
_, ds := runServerWithMockedDS(t)
- var mdmEnabled bool
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
- return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: mdmEnabled}}, nil
+ return &fleet.AppConfig{}, nil
}
// this func is called when no host is specified i.e. `fleetctl get hosts --json`
diff --git a/cmd/fleetctl/mdm.go b/cmd/fleetctl/mdm.go
new file mode 100644
index 0000000000..63242eee3f
--- /dev/null
+++ b/cmd/fleetctl/mdm.go
@@ -0,0 +1,121 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/service"
+ kithttp "github.com/go-kit/kit/transport/http"
+ "github.com/urfave/cli/v2"
+)
+
+func mdmCommand() *cli.Command {
+ return &cli.Command{
+ Name: "mdm",
+ Usage: "Run MDM commands against your hosts",
+ Flags: []cli.Flag{
+ configFlag(),
+ contextFlag(),
+ debugFlag(),
+ },
+ Subcommands: []*cli.Command{
+ mdmRunCommand(),
+ },
+ }
+}
+
+func mdmRunCommand() *cli.Command {
+ return &cli.Command{
+ Name: "run-command",
+ Aliases: []string{"run_command"},
+ Usage: "Run a custom MDM command on one macOS host. Head to Apple's documentation for a list of available commands and example payloads here: https://developer.apple.com/documentation/devicemanagement/commands_and_queries",
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "host",
+ Usage: "The host, specified by hostname, uuid, osquery_host_id or node_key, that you want to run the MDM command on.",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "payload",
+ Usage: "A path to an XML file containing the raw MDM request payload.",
+ Required: true,
+ },
+ },
+ Action: func(c *cli.Context) error {
+ client, err := clientFromCLI(c)
+ if err != nil {
+ return fmt.Errorf("create client: %w", err)
+ }
+
+ // print an error if MDM is not configured
+ if err := checkMDMEnabled(client); err != nil {
+ return err
+ }
+
+ hostIdent := c.String("host")
+ payloadFile := c.String("payload")
+ payload, err := os.ReadFile(payloadFile)
+ if err != nil {
+ return fmt.Errorf("read payload: %w", err)
+ }
+
+ host, err := client.HostByIdentifier(hostIdent)
+ if err != nil {
+ var nfe service.NotFoundErr
+ if errors.As(err, &nfe) {
+ return errors.New("The host doesn't exist. Please provide a valid hostname, uuid, osquery_host_id or node_key.")
+ }
+ var sce kithttp.StatusCoder
+ if errors.As(err, &sce) {
+ if sce.StatusCode() == http.StatusForbidden {
+ return fmt.Errorf("Permission denied. You don't have permission to run an MDM command on this host: %w", err)
+ }
+ }
+ return err
+ }
+
+ // TODO(mna): this "On" check is brittle, but looks like it's the only
+ // enrollment indication we have right now...
+ if host.MDM.EnrollmentStatus == nil || !strings.HasPrefix(*host.MDM.EnrollmentStatus, "On") ||
+ host.MDM.Name != fleet.WellKnownMDMFleet {
+ return errors.New("Can't run the MDM command because the host doesn't have MDM turned on. Run the following command to see a list of hosts with MDM on: fleetctl get hosts --mdm")
+ }
+
+ result, err := client.EnqueueCommand([]string{host.UUID}, payload)
+ if err != nil {
+ var sce kithttp.StatusCoder
+ if errors.As(err, &sce) {
+ if sce.StatusCode() == http.StatusForbidden {
+ return fmt.Errorf("Permission denied. You don't have permission to run an MDM command on this host: %w", err)
+ }
+ }
+ return err
+ }
+
+ fmt.Fprintf(c.App.Writer, `
+The hosts will run the command the next time it checks into Fleet.
+
+Copy and run this command to see results:
+
+fleetctl get mdm-command-results --id=%v
+`, result.CommandUUID)
+
+ return nil
+ },
+ }
+}
+
+func checkMDMEnabled(client *service.Client) error {
+ appCfg, err := client.GetAppConfig()
+ if err != nil {
+ return err
+ }
+ if !appCfg.MDM.EnabledAndConfigured {
+ return errors.New("MDM features aren't turned on. Use `fleetctl generate mdm-apple` and then `fleet serve` with `mdm` configuration to turn on MDM features.")
+ }
+ return nil
+}
diff --git a/cmd/fleetctl/mdm_test.go b/cmd/fleetctl/mdm_test.go
new file mode 100644
index 0000000000..a54dfd7aa0
--- /dev/null
+++ b/cmd/fleetctl/mdm_test.go
@@ -0,0 +1,150 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ mock "github.com/fleetdm/fleet/v4/server/mock/nanomdm"
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/fleetdm/fleet/v4/server/service"
+ "github.com/google/uuid"
+ "github.com/micromdm/nanomdm/mdm"
+ "github.com/micromdm/nanomdm/push"
+ "github.com/stretchr/testify/require"
+)
+
+type mockPusher struct{}
+
+func (mockPusher) Push(ctx context.Context, ids []string) (map[string]*push.Response, error) {
+ m := make(map[string]*push.Response, len(ids))
+ for _, id := range ids {
+ m[id] = &push.Response{Id: id}
+ }
+ return m, nil
+}
+
+func TestMDMRunCommand(t *testing.T) {
+ enqueuer := new(mock.Storage)
+ _, ds := runServerWithMockedDS(t, &service.TestServerOpts{MDMStorage: enqueuer, MDMPusher: mockPusher{}})
+
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
+ }
+ ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) {
+ switch identifier {
+ case "no-such-host":
+ return nil, ¬FoundError{}
+ case "no-mdm-host":
+ return &fleet.Host{ID: 1, UUID: identifier}, nil
+ case "no-fleet-mdm-host":
+ return &fleet.Host{ID: 2, UUID: identifier, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMJamf, EnrollmentStatus: ptr.String("On (manual)")}}, nil
+ case "fleet-mdm-pending-host":
+ return &fleet.Host{ID: 3, UUID: identifier, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("Pending")}}, nil
+ default:
+ return &fleet.Host{ID: 4, UUID: identifier, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}}, nil
+ }
+ }
+ ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error {
+ return nil
+ }
+ ds.ListLabelsForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Label, error) {
+ return nil, nil
+ }
+ ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) (packs []*fleet.Pack, err error) {
+ return nil, nil
+ }
+ ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) {
+ return nil, nil
+ }
+ ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
+ return nil, nil
+ }
+ ds.GetHostMDMProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMAppleProfile, error) {
+ return nil, nil
+ }
+ ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) {
+ if len(uuids) == 0 {
+ return nil, nil
+ }
+ return []*fleet.Host{
+ {ID: 4, UUID: uuids[0], MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}},
+ }, nil
+ }
+ enqueuer.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
+ return map[string]error{}, nil
+ }
+
+ _, err := runAppNoChecks([]string{"mdm", "run-command"})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `Required flags "host, payload" not set`)
+
+ _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "abc"})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `Required flag "payload" not set`)
+
+ _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "abc", "--payload", "no-such-file"})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `open no-such-file: no such file or directory`)
+
+ // pass a yaml file instead of xml
+ yamlFilePath := writeTmpYml(t, `invalid`)
+ _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "valid", "--payload", yamlFilePath})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `The payload isn't valid XML.`)
+
+ // host not found
+ cmdFilePath := writeTmpMDMCmd(t, "FooBar")
+ _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "no-such-host", "--payload", cmdFilePath})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `The host doesn't exist.`)
+
+ // host not in mdm
+ _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "no-mdm-host", "--payload", cmdFilePath})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `Can't run the MDM command because the host doesn't have MDM turned on.`)
+
+ // host not in fleet mdm
+ _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "no-fleet-mdm-host", "--payload", cmdFilePath})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `Can't run the MDM command because the host doesn't have MDM turned on.`)
+
+ // host in fleet mdm but pending
+ _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "fleet-mdm-pending-host", "--payload", cmdFilePath})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `Can't run the MDM command because the host doesn't have MDM turned on.`)
+
+ // host enrolled in fleet mdm
+ buf, err := runAppNoChecks([]string{"mdm", "run-command", "--host", "valid-host", "--payload", cmdFilePath})
+ require.NoError(t, err)
+ require.Contains(t, buf.String(), `The hosts will run the command the next time it checks into Fleet.`)
+ require.Contains(t, buf.String(), `fleetctl get mdm-command-results --id=`)
+
+ // try to run a fleet premium command
+ cmdFilePath = writeTmpMDMCmd(t, "EraseDevice")
+ _, err = runAppNoChecks([]string{"mdm", "run-command", "--host", "valid-host", "--payload", cmdFilePath})
+ require.Error(t, err)
+ require.ErrorContains(t, err, `missing or invalid license`)
+}
+
+func writeTmpMDMCmd(t *testing.T, commandName string) string {
+ tmpFile, err := os.CreateTemp(t.TempDir(), "*.xml")
+ require.NoError(t, err)
+ _, err = tmpFile.WriteString(fmt.Sprintf(`
+
+
+
+ CommandUUID
+ %s
+ Command
+
+ RequestType
+ %s
+
+
+`, uuid.New().String(), commandName))
+ require.NoError(t, err)
+ return tmpFile.Name()
+}
diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md
index 6a4743acd6..3ada65db93 100644
--- a/docs/Contributing/API-for-contributors.md
+++ b/docs/Contributing/API-for-contributors.md
@@ -540,6 +540,7 @@ The MDM endpoints exist to support the related command-line interface sub-comman
- [Update Apple MDM settings](#update-apple-mdm-settings)
- [Download an enrollment profile using IdP authentication](#download-an-enrollment-profile-using-idp-authentication)
- [Get Apple disk encryption summary](#get-apple-disk-encryption-summary)
+- [Enqueue MDM command](#enqueue-mdm-command)
### Get Apple MDM
@@ -1015,6 +1016,36 @@ Get aggregate status counts of Apple disk encryption profiles applying to macOS
}
```
+### Enqueue MDM command
+
+This endpoint enqueues an MDM command to be executed on a list of hosts identified by their UUID.
+
+`POST /api/v1/fleet/mdm/apple/enqueue`
+
+#### Parameters
+
+| Name | Type | In | Description |
+| ------------------------- | ------ | ----- | ------------------------------------------------------------------------- |
+| command | string | json | A base64-encoded MDM command as described in [Apple's documentation](https://developer.apple.com/documentation/devicemanagement/commands_and_queries) |
+| device_ids | array | json | An array of host UUIDs enrolled in Fleet's MDM on which the command should run. |
+
+Note that the `EraseDevice` and `DeviceLock` commands are _available in Fleet Premium_ only.
+
+#### Example
+
+`POST /api/v1/fleet/mdm/apple/enqueue`
+
+##### Default response
+
+`Status: 200`
+
+```json
+{
+ "command_uuid": "a2064cef-0000-1234-afb9-283e3c1d487e",
+ "request_type": "ProfileList"
+}
+```
+
## Get or apply configuration files
These API routes are used by the `fleetctl` CLI tool. Users can manage Fleet with `fleetctl` and [configuration files in YAML syntax](https://fleetdm.com/docs/using-fleet/configuration-files/).
diff --git a/docs/Using-Fleet/Permissions.md b/docs/Using-Fleet/Permissions.md
index 53d0d1199a..c231238d09 100644
--- a/docs/Using-Fleet/Permissions.md
+++ b/docs/Using-Fleet/Permissions.md
@@ -46,7 +46,8 @@ Users with the Admin role receive all permissions.
| View Apple business manager (BM) information | | | ✅ |
| Generate Apple mobile device management (MDM) certificate signing request (CSR) | | | ✅ |
| View disk encryption key for macOS hosts enrolled in Fleet's MDM | ✅ | ✅ | ✅ |
-| Create edit and delete configuration profiles for macOS hosts enrolled in Fleet's MDM | | ✅ | ✅ |
+| Create edit and delete configuration profiles for macOS hosts enrolled in Fleet's MDM | | ✅ | ✅ |
+| Execute MDM commands on macOS hosts enrolled in Fleet's MDM, and read command results | | ✅ | ✅ |
\*Applies only to Fleet Premium
@@ -95,6 +96,7 @@ Users that are members of multiple teams can be assigned different roles for eac
| Initiate [file carving](https://fleetdm.com/docs/using-fleet/rest-api#file-carving) | | ✅ | ✅ |
| View disk encryption key for macOS hosts enrolled in Fleet's MDM | ✅ | ✅ | ✅ |
| Create edit and delete configuration profiles for macOS hosts enrolled in Fleet's MDM | | ✅ | ✅ |
+| Execute MDM commands on macOS hosts enrolled in Fleet's MDM, and read command results | | ✅ | ✅ |
\* Applies only to [Fleet REST API](https://fleetdm.com/docs/using-fleet/rest-api)
diff --git a/server/authz/policy.rego b/server/authz/policy.rego
index aab879dc0a..1e24633d18 100644
--- a/server/authz/policy.rego
+++ b/server/authz/policy.rego
@@ -563,10 +563,18 @@ allow {
action == [read, write][_]
}
-# Global admins can read and write Apple commands.
+# Global admins and maintainers can read and write (execute) MDM Apple commands.
allow {
object.type == "mdm_apple_command"
- subject.global_role == admin
+ subject.global_role == [admin, maintainer][_]
+ action == [read, write][_]
+}
+
+# Team admins and maintainers can read and write (execute) MDM Apple commands on hosts of their teams.
+allow {
+ not is_null(object.team_id)
+ object.type == "mdm_apple_command"
+ team_role(subject, object.team_id) == [admin, maintainer][_]
action == [read, write][_]
}
diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go
index c87d493892..ace656915e 100644
--- a/server/datastore/mysql/hosts.go
+++ b/server/datastore/mysql/hosts.go
@@ -1756,6 +1756,51 @@ func (ds *Datastore) HostIDsByName(ctx context.Context, filter fleet.TeamFilter,
return hostIDs, nil
}
+func (ds *Datastore) ListHostsLiteByUUIDs(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) {
+ if len(uuids) == 0 {
+ return nil, nil
+ }
+
+ stmt := fmt.Sprintf(`
+SELECT
+ id,
+ created_at,
+ updated_at,
+ osquery_host_id,
+ node_key,
+ hostname,
+ uuid,
+ hardware_serial,
+ hardware_model,
+ computer_name,
+ platform,
+ team_id,
+ distributed_interval,
+ logger_tls_period,
+ config_tls_refresh,
+ detail_updated_at,
+ label_updated_at,
+ last_enrolled_at,
+ policy_updated_at,
+ refetch_requested
+FROM hosts
+WHERE uuid IN (?) AND %s
+ `, ds.whereFilterHostsByTeams(filter, "hosts"),
+ )
+
+ stmt, args, err := sqlx.In(stmt, uuids)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "building query to select hosts by uuid")
+ }
+
+ var hosts []*fleet.Host
+ if err := sqlx.SelectContext(ctx, ds.reader, &hosts, stmt, args...); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "select hosts by uuid")
+ }
+
+ return hosts, nil
+}
+
func (ds *Datastore) HostByIdentifier(ctx context.Context, identifier string) (*fleet.Host, error) {
stmt := `
SELECT
diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go
index f57995bc0e..7db295d436 100644
--- a/server/datastore/mysql/hosts_test.go
+++ b/server/datastore/mysql/hosts_test.go
@@ -145,6 +145,7 @@ func TestHosts(t *testing.T) {
{"EnrollOrbit", testHostsEnrollOrbit},
{"EnrollUpdatesMissingInfo", testHostsEnrollUpdatesMissingInfo},
{"EncryptionKeyRawDecryption", testHostsEncryptionKeyRawDecryption},
+ {"ListHostsLiteByUUIDs", testHostsListHostsLiteByUUIDs},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -6554,3 +6555,178 @@ func testHostsEncryptionKeyRawDecryption(t *testing.T, ds *Datastore) {
require.NotNil(t, got.MDM.TestGetRawDecryptable())
require.Equal(t, 1, *got.MDM.TestGetRawDecryptable())
}
+
+func testHostsListHostsLiteByUUIDs(t *testing.T, ds *Datastore) {
+ ctx := context.Background()
+
+ // create hosts, UUID is the `i` index
+ hosts := make([]*fleet.Host, 10)
+ for i := range hosts {
+ h, err := ds.NewHost(ctx, &fleet.Host{
+ DetailUpdatedAt: time.Now(),
+ LabelUpdatedAt: time.Now(),
+ PolicyUpdatedAt: time.Now(),
+ SeenTime: time.Now(),
+ OsqueryHostID: ptr.String(fmt.Sprintf("host%d", i)),
+ NodeKey: ptr.String(fmt.Sprintf("%d", i)),
+ UUID: fmt.Sprintf("%d", i),
+ Hostname: fmt.Sprintf("foo.%d.local", i),
+ })
+ require.NoError(t, err)
+ hosts[i] = h
+ }
+
+ // move hosts 0, 1, 2 to team 1
+ team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
+ require.NoError(t, err)
+ require.NoError(t, ds.AddHostsToTeam(ctx, &team1.ID, []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID}))
+
+ // move hosts 3, 4, 5 to team 2
+ team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
+ require.NoError(t, err)
+ require.NoError(t, ds.AddHostsToTeam(ctx, &team2.ID, []uint{hosts[3].ID, hosts[4].ID, hosts[5].ID}))
+
+ // create a team 3 without any host
+ team3, err := ds.NewTeam(ctx, &fleet.Team{Name: "team3"})
+ require.NoError(t, err)
+
+ tm1Admin := &fleet.User{Teams: []fleet.UserTeam{{Team: *team1, Role: fleet.RoleAdmin}}}
+ tm1Maintainer := &fleet.User{Teams: []fleet.UserTeam{{Team: *team1, Role: fleet.RoleMaintainer}}}
+ tm1Observer := &fleet.User{Teams: []fleet.UserTeam{{Team: *team1, Role: fleet.RoleObserver}}}
+ tm2Admin := &fleet.User{Teams: []fleet.UserTeam{{Team: *team2, Role: fleet.RoleAdmin}}}
+ tm2Maintainer := &fleet.User{Teams: []fleet.UserTeam{{Team: *team2, Role: fleet.RoleMaintainer}}}
+ tm2Observer := &fleet.User{Teams: []fleet.UserTeam{{Team: *team2, Role: fleet.RoleObserver}}}
+ tm3Admin := &fleet.User{Teams: []fleet.UserTeam{{Team: *team3, Role: fleet.RoleAdmin}}}
+ tm1MaintainerTm2Observer := &fleet.User{Teams: []fleet.UserTeam{
+ {Team: *team1, Role: fleet.RoleMaintainer},
+ {Team: *team2, Role: fleet.RoleObserver},
+ }}
+
+ cases := []struct {
+ desc string
+ filter fleet.TeamFilter
+ uuids []string
+ wantIDs []uint
+ }{
+ {
+ "no user sees nothing",
+ fleet.TeamFilter{},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ nil,
+ },
+ {
+ "global admin no uuid provided",
+ fleet.TeamFilter{User: test.UserAdmin},
+ []string{},
+ nil,
+ },
+ {
+ "global admin sees everything",
+ fleet.TeamFilter{User: test.UserAdmin},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID, hosts[5].ID, hosts[6].ID, hosts[7].ID, hosts[8].ID, hosts[9].ID},
+ },
+ {
+ "global maintainer sees everything",
+ fleet.TeamFilter{User: test.UserMaintainer},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID, hosts[5].ID, hosts[6].ID, hosts[7].ID, hosts[8].ID, hosts[9].ID},
+ },
+ {
+ "global observer sees nothing",
+ fleet.TeamFilter{User: test.UserObserver},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ nil,
+ },
+ {
+ "global observer sees everything with observer allowed",
+ fleet.TeamFilter{User: test.UserObserver, IncludeObserver: true},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID, hosts[5].ID, hosts[6].ID, hosts[7].ID, hosts[8].ID, hosts[9].ID},
+ },
+ {
+ "team 1 admin sees team 1 hosts",
+ fleet.TeamFilter{User: tm1Admin},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID},
+ },
+ {
+ "team 1 maintainer sees team 1 hosts",
+ fleet.TeamFilter{User: tm1Maintainer},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID},
+ },
+ {
+ "team 1 observer sees nothing",
+ fleet.TeamFilter{User: tm1Observer},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ nil,
+ },
+ {
+ "team 1 observer sees team 1 hosts with observer allowed",
+ fleet.TeamFilter{User: tm1Observer, IncludeObserver: true},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID},
+ },
+ {
+ "team 2 admin sees team 2 hosts",
+ fleet.TeamFilter{User: tm2Admin},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[3].ID, hosts[4].ID, hosts[5].ID},
+ },
+ {
+ "team 2 maintainer sees team 2 hosts",
+ fleet.TeamFilter{User: tm2Maintainer},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[3].ID, hosts[4].ID, hosts[5].ID},
+ },
+ {
+ "team 2 observer sees nothing",
+ fleet.TeamFilter{User: tm2Observer},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ nil,
+ },
+ {
+ "team 2 observer sees team 2 hosts with observer allowed",
+ fleet.TeamFilter{User: tm2Observer, IncludeObserver: true},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[3].ID, hosts[4].ID, hosts[5].ID},
+ },
+ {
+ "team 3 admin sees nothing even with observer",
+ fleet.TeamFilter{User: tm3Admin, IncludeObserver: true},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ nil,
+ },
+ {
+ "filtering on a specific team ID returns only those hosts",
+ fleet.TeamFilter{User: test.UserAdmin, TeamID: &team1.ID},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID},
+ },
+ {
+ "team 1 maintainer team 2 observer sees team 1",
+ fleet.TeamFilter{User: tm1MaintainerTm2Observer},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID},
+ },
+ {
+ "team 1 maintainer team 2 observer sees team 1 and 2 with observer",
+ fleet.TeamFilter{User: tm1MaintainerTm2Observer, IncludeObserver: true},
+ []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"},
+ []uint{hosts[0].ID, hosts[1].ID, hosts[2].ID, hosts[3].ID, hosts[4].ID, hosts[5].ID},
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.desc, func(t *testing.T) {
+ hosts, err := ds.ListHostsLiteByUUIDs(ctx, c.filter, c.uuids)
+ require.NoError(t, err)
+
+ gotIDs := make([]uint, len(hosts))
+ for i, h := range hosts {
+ gotIDs[i] = h.ID
+ }
+ require.ElementsMatch(t, c.wantIDs, gotIDs)
+ })
+ }
+}
diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go
index 0b076a9438..269c61b85d 100644
--- a/server/fleet/apple_mdm.go
+++ b/server/fleet/apple_mdm.go
@@ -235,25 +235,18 @@ type EnrolledAPIResults map[string]*EnrolledAPIResult
// CommandEnqueueResult is the result of a command execution on enrolled Apple devices.
type CommandEnqueueResult struct {
- // Status is the status of the command.
- Status EnrolledAPIResults `json:"status,omitempty"`
- // NoPush indicates whether the command was issued with no_push.
- // If this is true, then Fleet won't send a push notification to devices.
- NoPush bool `json:"no_push,omitempty"`
- // PushError indicates the error when trying to send push notification
- // to target devices.
- PushError string `json:"push_error,omitempty"`
- // CommandError holds the error when enqueueing the command.
- CommandError string `json:"command_error,omitempty"`
// CommandUUID is the unique identifier for the command.
CommandUUID string `json:"command_uuid,omitempty"`
// RequestType is the name of the command.
RequestType string `json:"request_type,omitempty"`
+ // FailedUUIDs is the list of host UUIDs that failed to receive the command.
+ FailedUUIDs []string `json:"failed_uuids,omitempty"`
}
// MDMAppleCommand represents an Apple MDM command.
type MDMAppleCommand struct {
*mdm.Command
+ TeamID *uint `json:"team_id"` // required for authorization by team
}
// AuthzType implements authz.AuthzTyper.
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index da5c9e74a0..26774bea00 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -195,6 +195,7 @@ type Datastore interface {
DeleteHost(ctx context.Context, hid uint) error
Host(ctx context.Context, id uint) (*Host, error)
ListHosts(ctx context.Context, filter TeamFilter, opt HostListOptions) ([]*Host, error)
+ ListHostsLiteByUUIDs(ctx context.Context, filter TeamFilter, uuids []string) ([]*Host, error)
MarkHostsSeen(ctx context.Context, hostIDs []uint, t time.Time) error
SearchHosts(ctx context.Context, filter TeamFilter, query string, omit ...uint) ([]*Host, error)
@@ -913,6 +914,11 @@ const (
UnknownMigrations
)
+// TODO: we have a similar but different interface in the service package,
+// service.NotFoundErr - at the very least, the IsNotFound method should be the
+// same in both (the other is currently NotFound), and ideally we'd just have
+// one of those interfaces.
+
// NotFoundError is returned when the datastore resource cannot be found.
type NotFoundError interface {
error
diff --git a/server/fleet/service.go b/server/fleet/service.go
index 908f56bb1a..e3383f17c9 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -633,8 +633,9 @@ type Service interface {
// NewMDMAppleDEPKeyPair creates a public private key pair for use with the Apple MDM DEP token.
NewMDMAppleDEPKeyPair(ctx context.Context) (*MDMAppleDEPKeyPair, error)
- // EnqueueMDMAppleCommand enqueues a command for execution on the given devices.
- EnqueueMDMAppleCommand(ctx context.Context, command *MDMAppleCommand, deviceIDs []string, noPush bool) (status int, result *CommandEnqueueResult, err error)
+ // EnqueueMDMAppleCommand enqueues a command for execution on the given
+ // devices. Note that a deviceID is the same as a host's UUID.
+ EnqueueMDMAppleCommand(ctx context.Context, rawBase64Cmd string, deviceIDs []string, noPush bool) (status int, result *CommandEnqueueResult, err error)
// EnqueueMDMAppleCommandRemoveEnrollmentProfile enqueues a command to remove the
// profile used for Fleet MDM enrollment from the specified device.
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index 10dc55e3da..225e965ade 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -154,6 +154,8 @@ type HostFunc func(ctx context.Context, id uint) (*fleet.Host, error)
type ListHostsFunc func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error)
+type ListHostsLiteByUUIDsFunc func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error)
+
type MarkHostsSeenFunc func(ctx context.Context, hostIDs []uint, t time.Time) error
type SearchHostsFunc func(ctx context.Context, filter fleet.TeamFilter, query string, omit ...uint) ([]*fleet.Host, error)
@@ -788,6 +790,9 @@ type DataStore struct {
ListHostsFunc ListHostsFunc
ListHostsFuncInvoked bool
+ ListHostsLiteByUUIDsFunc ListHostsLiteByUUIDsFunc
+ ListHostsLiteByUUIDsFuncInvoked bool
+
MarkHostsSeenFunc MarkHostsSeenFunc
MarkHostsSeenFuncInvoked bool
@@ -1913,6 +1918,13 @@ func (s *DataStore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt
return s.ListHostsFunc(ctx, filter, opt)
}
+func (s *DataStore) ListHostsLiteByUUIDs(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) {
+ s.mu.Lock()
+ s.ListHostsLiteByUUIDsFuncInvoked = true
+ s.mu.Unlock()
+ return s.ListHostsLiteByUUIDsFunc(ctx, filter, uuids)
+}
+
func (s *DataStore) MarkHostsSeen(ctx context.Context, hostIDs []uint, t time.Time) error {
s.mu.Lock()
s.MarkHostsSeenFuncInvoked = true
diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go
index 40ff722b1c..43f56dc7b2 100644
--- a/server/service/apple_mdm.go
+++ b/server/service/apple_mdm.go
@@ -17,23 +17,24 @@ import (
"sync"
"time"
+ "github.com/VividCortex/mysqlerr"
"github.com/docker/go-units"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
+ "github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
+ "github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"github.com/groob/plist"
"github.com/micromdm/micromdm/mdm/appmanifest"
"github.com/micromdm/nanodep/godep"
"github.com/micromdm/nanomdm/mdm"
- "github.com/micromdm/nanomdm/push"
nanomdm_push "github.com/micromdm/nanomdm/push"
- "github.com/micromdm/nanomdm/storage"
nanomdm_storage "github.com/micromdm/nanomdm/storage"
)
@@ -878,13 +879,12 @@ func (svc *Service) NewMDMAppleDEPKeyPair(ctx context.Context) (*fleet.MDMAppleD
type enqueueMDMAppleCommandRequest struct {
Command string `json:"command"`
DeviceIDs []string `json:"device_ids"`
- NoPush bool `json:"no_push"`
}
type enqueueMDMAppleCommandResponse struct {
- status int `json:"-"`
- Result fleet.CommandEnqueueResult `json:"result"`
- Err error `json:"error,omitempty"`
+ *fleet.CommandEnqueueResult
+ status int `json:"-"`
+ Err error `json:"error,omitempty"`
}
func (r enqueueMDMAppleCommandResponse) error() error { return r.Err }
@@ -892,146 +892,119 @@ func (r enqueueMDMAppleCommandResponse) Status() int { return r.status }
func enqueueMDMAppleCommandEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*enqueueMDMAppleCommandRequest)
- rawCommand, err := base64.RawStdEncoding.DecodeString(req.Command)
- if err != nil {
- return enqueueMDMAppleCommandResponse{Err: err}, nil
- }
- command, err := mdm.DecodeCommand(rawCommand)
- if err != nil {
- return enqueueMDMAppleCommandResponse{Err: err}, nil
- }
- status, result, err := svc.EnqueueMDMAppleCommand(ctx, &fleet.MDMAppleCommand{Command: command}, req.DeviceIDs, req.NoPush)
+ status, result, err := svc.EnqueueMDMAppleCommand(ctx, req.Command, req.DeviceIDs, false)
if err != nil {
return enqueueMDMAppleCommandResponse{Err: err}, nil
}
return enqueueMDMAppleCommandResponse{
- status: status,
- Result: *result,
+ status: status,
+ CommandEnqueueResult: result,
}, nil
}
func (svc *Service) EnqueueMDMAppleCommand(
ctx context.Context,
- command *fleet.MDMAppleCommand,
+ rawBase64Cmd string,
deviceIDs []string,
noPush bool,
) (status int, result *fleet.CommandEnqueueResult, err error) {
- if err := svc.authz.Authorize(ctx, command, fleet.ActionWrite); err != nil {
+ var premiumCommands = map[string]bool{
+ "EraseDevice": true,
+ "DeviceLock": true,
+ }
+
+ // load hosts (lite) by uuids, check that the user has the rigts to run
+ // commands for every affected team.
+ if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return 0, nil, ctxerr.Wrap(ctx, err)
}
- return deprecatedRawCommandEnqueue(ctx, svc.mdmStorage, svc.mdmPushService, command.Command, deviceIDs, noPush, svc.logger)
-}
-// deprecatedRawCommandEnqueue enqueues a command to be executed on the given devices.
-//
-// This method was extracted from:
-// https://github.com/fleetdm/nanomdm/blob/a261f081323c80fb7f6575a64ac1a912dffe44ba/http/api/api.go#L134-L261
-// NOTE(lucas): At the time, I found no way to reuse Fleet's gokit middlewares with a raw http.Handler
-// like api.RawCommandEnqueueHandler.
-func deprecatedRawCommandEnqueue(
- ctx context.Context,
- enqueuer storage.CommandEnqueuer,
- pusher push.Pusher,
- command *mdm.Command,
- deviceIDs []string,
- noPush bool,
- logger kitlog.Logger,
-) (status int, result *fleet.CommandEnqueueResult, err error) {
- output := fleet.CommandEnqueueResult{
- Status: make(fleet.EnrolledAPIResults),
- NoPush: noPush,
- CommandUUID: command.CommandUUID,
- RequestType: command.Command.RequestType,
+ vc, ok := viewer.FromContext(ctx)
+ if !ok {
+ return 0, nil, fleet.ErrNoContext
}
-
- logger = kitlog.With(
- logger,
- "command_uuid", command.CommandUUID,
- "request_type", command.Command.RequestType,
- )
- logs := []interface{}{
- "msg", "enqueue",
- }
- idErrs, err := enqueuer.EnqueueCommand(ctx, deviceIDs, command)
- ct := len(deviceIDs) - len(idErrs)
+ // for the team filter, we don't include observers as we require maintainer
+ // and up to run commands.
+ filter := fleet.TeamFilter{User: vc.User, IncludeObserver: false}
+ hosts, err := svc.ds.ListHostsLiteByUUIDs(ctx, filter, deviceIDs)
if err != nil {
- logs = append(logs, "err", err)
- output.CommandError = err.Error()
- if len(idErrs) == 0 {
- // we assume if there were no ID-specific errors but
- // there was a general error then all IDs failed
- ct = 0
+ return 0, nil, err
+ }
+ if len(hosts) == 0 {
+ return 0, nil, newNotFoundError()
+ }
+
+ // collect the team IDs and verify that the user has access to run commands
+ // on all affected teams.
+ teamIDs := make(map[uint]bool)
+ for _, h := range hosts {
+ var id uint
+ if h.TeamID != nil {
+ id = *h.TeamID
+ }
+ teamIDs[id] = true
+ }
+
+ var command fleet.MDMAppleCommand
+ for tmID := range teamIDs {
+ command.TeamID = &tmID
+ if tmID == 0 {
+ command.TeamID = nil
+ }
+
+ if err := svc.authz.Authorize(ctx, command, fleet.ActionWrite); err != nil {
+ return 0, nil, ctxerr.Wrap(ctx, err)
}
}
- logs = append(logs, "count", ct)
- if len(idErrs) > 0 {
- logs = append(logs, "errs", len(idErrs))
+
+ rawXMLCmd, err := base64.RawStdEncoding.DecodeString(rawBase64Cmd)
+ if err != nil {
+ return 0, nil, ctxerr.Wrap(ctx, err, "decode base64 command")
}
- if err != nil || len(idErrs) > 0 {
- level.Info(logger).Log(logs...)
- } else {
- level.Debug(logger).Log(logs...)
+ cmd, err := mdm.DecodeCommand(rawXMLCmd)
+ if err != nil {
+ return 0, nil, ctxerr.Wrap(ctx, err, "decode plist command")
}
- // loop through our command errors, if any, and add to output
- for id, err := range idErrs {
+
+ if premiumCommands[strings.TrimSpace(cmd.Command.RequestType)] {
+ lic, err := svc.License(ctx)
if err != nil {
- output.Status[id] = &fleet.EnrolledAPIResult{
- CommandError: err.Error(),
+ return 0, nil, ctxerr.Wrap(ctx, err, "get license")
+ }
+ if !lic.IsPremium() {
+ return 0, nil, fleet.ErrMissingLicense
+ }
+ }
+
+ if err := svc.mdmAppleCommander.enqueue(ctx, deviceIDs, string(rawXMLCmd)); err != nil {
+ // if at least one UUID enqueued properly, return success, otherwise return 500
+ var apnsErr *APNSDeliveryError
+ var mysqlErr *mysql.MySQLError
+ if errors.As(err, &apnsErr) {
+ if len(apnsErr.FailedUUIDs) < len(deviceIDs) {
+ // some hosts properly received the command, so return success, with the list
+ // of failed uuids.
+ return http.StatusOK, &fleet.CommandEnqueueResult{
+ CommandUUID: cmd.CommandUUID,
+ RequestType: cmd.Command.RequestType,
+ FailedUUIDs: apnsErr.FailedUUIDs,
+ }, nil
+ }
+ } else if errors.As(err, &mysqlErr) {
+ // enqueue may fail with a foreign key constraint error 1452 when one of
+ // the hosts provided is not enrolled in nano_enrollments. Detect when
+ // that's the case and add information to the error.
+ if mysqlErr.Number == mysqlerr.ER_NO_REFERENCED_ROW_2 {
+ err := fleet.NewInvalidArgumentError("device_ids", fmt.Sprintf("at least one of the hosts is not enrolled in MDM: %v", err))
+ return http.StatusInternalServerError, nil, ctxerr.Wrap(ctx, err, "enqueue command")
}
}
+ return http.StatusInternalServerError, nil, ctxerr.Wrap(ctx, err, "enqueue command")
}
- // optionally send pushes
- pushResp := make(map[string]*push.Response)
- var pushErr error
- if !noPush {
- pushResp, pushErr = pusher.Push(ctx, deviceIDs)
- if err != nil {
- level.Info(logger).Log("msg", "push", "err", err)
- output.PushError = err.Error()
- }
- } else {
- pushErr = nil
- }
- // loop through our push errors, if any, and add to output
- var pushCt, pushErrCt int
- for id, resp := range pushResp {
- if _, ok := output.Status[id]; ok {
- output.Status[id].PushResult = resp.Id
- } else {
- output.Status[id] = &fleet.EnrolledAPIResult{
- PushResult: resp.Id,
- }
- }
- if resp.Err != nil {
- output.Status[id].PushError = resp.Err.Error()
- pushErrCt++
- } else {
- pushCt++
- }
- }
- logs = []interface{}{
- "msg", "push",
- "count", pushCt,
- }
- if pushErr != nil {
- logs = append(logs, "err", pushErr)
- }
- if pushErrCt > 0 {
- logs = append(logs, "errs", pushErrCt)
- }
- if pushErr != nil || pushErrCt > 0 {
- level.Info(logger).Log(logs...)
- } else {
- level.Debug(logger).Log(logs...)
- }
- // generate response codes depending on if everything succeeded, failed, or parially succedded
- header := http.StatusInternalServerError
- if (len(idErrs) > 0 || err != nil || (!noPush && (pushErrCt > 0 || pushErr != nil))) && (ct > 0 || (!noPush && (pushCt > 0))) {
- header = http.StatusMultiStatus
- } else if (len(idErrs) == 0 && err == nil && (noPush || (pushErrCt == 0 && pushErr == nil))) && (ct >= 1 && (noPush || (pushCt >= 1))) {
- header = http.StatusOK
- }
- return header, &output, nil
+ return http.StatusOK, &fleet.CommandEnqueueResult{
+ CommandUUID: cmd.CommandUUID,
+ RequestType: cmd.Command.RequestType,
+ }, nil
}
type mdmAppleEnrollRequest struct {
diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go
index d751b80684..4ef3de024d 100644
--- a/server/service/apple_mdm_test.go
+++ b/server/service/apple_mdm_test.go
@@ -162,7 +162,7 @@ func setupAppleMDMService(t *testing.T) (fleet.Service, context.Context, *mock.S
}
func TestAppleMDMAuthorization(t *testing.T) {
- svc, ctx, _ := setupAppleMDMService(t)
+ svc, ctx, ds := setupAppleMDMService(t)
checkAuthErr := func(t *testing.T, err error, shouldFailWithAuth bool) {
t.Helper()
@@ -195,8 +195,6 @@ func TestAppleMDMAuthorization(t *testing.T) {
checkAuthErr(t, err, shouldFailWithAuth)
_, err = svc.ListMDMAppleDEPDevices(ctx)
checkAuthErr(t, err, shouldFailWithAuth)
- _, _, err = svc.EnqueueMDMAppleCommand(ctx, &fleet.MDMAppleCommand{Command: &mdm.Command{}}, nil, false)
- checkAuthErr(t, err, shouldFailWithAuth)
}
// Only global admins can access the endpoints.
@@ -231,6 +229,83 @@ func TestAppleMDMAuthorization(t *testing.T) {
ctx = test.HostContext(context.Background(), &fleet.Host{})
_, err = svc.GetDeviceMDMAppleEnrollmentProfile(ctx)
require.NoError(t, err)
+
+ hostUUIDsToTeamID := map[string]uint{
+ "host1": 1,
+ "host2": 1,
+ "host3": 2,
+ "host4": 0,
+ }
+ ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) {
+ hosts := make([]*fleet.Host, 0, len(uuids))
+ for _, uuid := range uuids {
+ tmID := hostUUIDsToTeamID[uuid]
+ if tmID == 0 {
+ hosts = append(hosts, &fleet.Host{UUID: uuid, TeamID: nil})
+ } else {
+ hosts = append(hosts, &fleet.Host{UUID: uuid, TeamID: &tmID})
+ }
+ }
+ return hosts, nil
+ }
+
+ rawB64FreeCmd := base64.RawStdEncoding.EncodeToString([]byte(`
+
+
+
+ Command
+
+ RequestType
+ FooBar
+
+ CommandUUID
+ uuid
+
+`))
+
+ cases := []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},
+ {"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 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 cases {
+ 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(`
+
+
+
+ Command
+
+ RequestType
+ %s
+
+ CommandUUID
+ uuid
+
+`, "DeviceLock")))
+ _, _, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"}, false)
+ require.Error(t, err)
+ require.ErrorContains(t, err, fleet.ErrMissingLicense.Error())
}
func TestMDMAppleEnrollURL(t *testing.T) {
diff --git a/server/service/base_client.go b/server/service/base_client.go
index c708829772..784b2df532 100644
--- a/server/service/base_client.go
+++ b/server/service/base_client.go
@@ -50,12 +50,11 @@ func (bc *baseClient) parseResponse(verb, path string, response *http.Response,
break
}
- return fmt.Errorf(
- "%s %s received status %d %s",
- verb, path,
- response.StatusCode,
- extractServerErrorText(response.Body),
- )
+ e := &statusCodeErr{
+ code: response.StatusCode,
+ body: extractServerErrorText(response.Body),
+ }
+ return fmt.Errorf("%s %s received status %w", verb, path, e)
}
bc.setServerCapabilities(response)
diff --git a/server/service/base_client_errors.go b/server/service/base_client_errors.go
index 24339f8d25..7bea24d99a 100644
--- a/server/service/base_client_errors.go
+++ b/server/service/base_client_errors.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"errors"
+ "fmt"
"io"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -44,6 +45,10 @@ func (e notSetupErr) NotSetup() bool {
return true
}
+// TODO: we have a similar but different interface in the fleet package,
+// fleet.NotFoundError - at the very least, the NotFound method should be the
+// same in both (the other is currently IsNotFound), and ideally we'd just have
+// one of those interfaces.
type NotFoundErr interface {
NotFound() bool
Error() string
@@ -111,3 +116,16 @@ func extractServerErrorText(body io.Reader) string {
return errText
}
+
+type statusCodeErr struct {
+ code int
+ body string
+}
+
+func (e *statusCodeErr) Error() string {
+ return fmt.Sprintf("%d %s", e.code, e.body)
+}
+
+func (e *statusCodeErr) StatusCode() int {
+ return e.code
+}
diff --git a/server/service/client_apple_mdm.go b/server/service/client_apple_mdm.go
index ed6f486d23..6dd34eccf6 100644
--- a/server/service/client_apple_mdm.go
+++ b/server/service/client_apple_mdm.go
@@ -41,7 +41,7 @@ func (c *Client) ListEnrollments() ([]*fleet.MDMAppleEnrollmentProfile, error) {
func (c *Client) EnqueueCommand(deviceIDs []string, rawPlist []byte) (*fleet.CommandEnqueueResult, error) {
var commandPayload map[string]interface{}
if _, err := plist.Unmarshal(rawPlist, &commandPayload); err != nil {
- return nil, fmt.Errorf("unmarshal command plist: %w", err)
+ return nil, fmt.Errorf("The payload isn't valid XML. Please provide a file with valid XML: %w", err)
}
// generate a random command UUID
@@ -55,13 +55,12 @@ func (c *Client) EnqueueCommand(deviceIDs []string, rawPlist []byte) (*fleet.Com
request := enqueueMDMAppleCommandRequest{
Command: base64.RawStdEncoding.EncodeToString(b),
DeviceIDs: deviceIDs,
- NoPush: false,
}
var response enqueueMDMAppleCommandResponse
if err := c.authenticatedRequest(request, "POST", "/api/latest/fleet/mdm/apple/enqueue", &response); err != nil {
- return nil, fmt.Errorf("request: %w", err)
+ return nil, fmt.Errorf("run command request: %w", err)
}
- return &response.Result, nil
+ return response.CommandEnqueueResult, nil
}
func (c *Client) MDMAppleGetCommandResults(commandUUID string) (map[string]*fleet.MDMAppleCommandResult, error) {
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index fb8e4ea356..2c22ad7a62 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -2398,6 +2398,62 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesStatus() {
})
}
+func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
+ t := s.T()
+
+ unenrolledHost := createHostAndDeviceToken(t, s.ds, "unused")
+ enrolledHost := newMDMEnrolledDevice(s)
+
+ newRawCmd := func() string {
+ return base64.RawStdEncoding.EncodeToString([]byte(fmt.Sprintf(`
+
+
+
+ Command
+
+ ManagedOnly
+
+ RequestType
+ ProfileList
+
+ CommandUUID
+ %s
+
+`, uuid.New().String())))
+ }
+
+ // call with unknown host UUID
+ s.Do("POST", "/api/latest/fleet/mdm/apple/enqueue",
+ enqueueMDMAppleCommandRequest{
+ Command: newRawCmd(),
+ DeviceIDs: []string{"no-such-host"},
+ }, http.StatusNotFound)
+
+ // call with unenrolled host UUID
+ res := s.Do("POST", "/api/latest/fleet/mdm/apple/enqueue",
+ enqueueMDMAppleCommandRequest{
+ Command: newRawCmd(),
+ DeviceIDs: []string{unenrolledHost.UUID},
+ }, http.StatusUnprocessableEntity)
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "at least one of the hosts is not enrolled in MDM")
+
+ // call with enrolled host UUID
+ rawCmd := newRawCmd()
+ var resp enqueueMDMAppleCommandResponse
+ s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enqueue",
+ enqueueMDMAppleCommandRequest{
+ Command: rawCmd,
+ DeviceIDs: []string{enrolledHost.uuid},
+ }, http.StatusOK, &resp)
+ require.NotEmpty(t, resp.CommandUUID)
+ decodedCmd, err := base64.RawStdEncoding.DecodeString(rawCmd)
+ require.NoError(t, err)
+ require.Contains(t, string(decodedCmd), resp.CommandUUID)
+ require.Empty(t, resp.FailedUUIDs)
+ require.Equal(t, "ProfileList", resp.RequestType)
+}
+
// only asserts the profile identifier, status and operation (per host)
func (s *integrationMDMTestSuite) assertHostConfigProfiles(want map[*fleet.Host][]fleet.HostMDMAppleProfile) {
t := s.T()