fleet/orbit/pkg/profiles/profiles_darwin.go

294 lines
8.6 KiB
Go

//go:build darwin
package profiles
import (
"bytes"
"errors"
"fmt"
"net/url"
"os/exec"
"strings"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/micromdm/plist"
)
type profileItem[T any] struct {
PayloadContent T
PayloadType string
PayloadIdentifier string
}
type profilePayload[T any] struct {
ProfileItems []profileItem[T]
}
type profilesOutput[T any] struct {
ComputerLevel []profilePayload[T] `plist:"_computerlevel"`
}
// GetFleetdConfig searches and parses a device level configuration profile
// with Fleet's payload identifier.
func GetFleetdConfig() (*fleet.MDMAppleFleetdConfig, error) {
pc, err := getProfilePayloadContent[fleet.MDMAppleFleetdConfig](mobileconfig.FleetdConfigPayloadIdentifier)
if err != nil {
if err == ErrNotFound {
return &fleet.MDMAppleFleetdConfig{}, nil
}
return nil, err
}
return pc, nil
}
func GetCustomEnrollmentProfileEndUserEmail() (string, error) {
pc, err := getProfilePayloadContent[fleet.MDMCustomEnrollmentProfileItem](mobileconfig.FleetEnrollmentPayloadIdentifier)
if err != nil {
return "", err
}
if pc == nil || pc.EndUserEmail == "" {
return "", ErrNotFound
}
return pc.EndUserEmail, nil
}
func getProfilePayloadContent[T any](identifier string) (*T, error) {
outBuf, err := execProfileCmd()
if err != nil {
return nil, fmt.Errorf("get profile: %w", err)
}
var profiles profilesOutput[T]
if err := plist.Unmarshal(outBuf.Bytes(), &profiles); err != nil {
return nil, fmt.Errorf("get profile: %w", err)
}
for _, profile := range profiles.ComputerLevel {
for _, item := range profile.ProfileItems {
if item.PayloadIdentifier == identifier {
return &item.PayloadContent, nil
}
}
}
return nil, ErrNotFound
}
// execProfileCmd is declared as a variable so it can be overwritten by tests.
var execProfileCmd = func() (*bytes.Buffer, error) {
var outBuf bytes.Buffer
// TODO: check if there is a reason to prefer -L over -C in some cases
cmd := exec.Command("/usr/bin/profiles", "-C", "-o", "stdout-xml")
cmd.Stdout = &outBuf
cmd.Stderr = &outBuf
if err := cmd.Run(); err != nil {
return nil, err
}
return &outBuf, nil
}
// IsEnrolledInMDM runs the `profiles` command to get the current MDM
// enrollment information and reports if the host is enrolled, and the URL of
// the MDM server (if enrolled)
func IsEnrolledInMDM() (bool, string, error) {
out, err := getMDMInfoFromProfilesCmd()
if err != nil {
return false, "", fmt.Errorf("calling /usr/bin/profiles: %w", err)
}
// The output of the command is in the form:
//
// ```
// Enrolled via DEP: No
// MDM enrollment: Yes (User Approved)
// MDM server: https://test.example.com/mdm/apple/mdm
// ```
//
// If the host is not enrolled into an MDM, the last line is ommitted,
// so we need to check that:
//
// 1. We've got three rows
// 2. The last row matches our server URL
lines := bytes.Split(bytes.TrimSpace(out), []byte("\n"))
if len(lines) < 3 {
return false, "", nil
}
parts := bytes.SplitN(lines[2], []byte(":"), 2)
if len(parts) < 2 {
return false, "", fmt.Errorf("splitting profiles output to get MDM server URL: %w", err)
}
enrollmentURL := string(bytes.TrimSpace(parts[1]))
return true, enrollmentURL, nil
}
func IsManuallyEnrolledInMDM() (bool, error) {
out, err := getMDMInfoFromProfilesCmd()
if err != nil {
return false, fmt.Errorf("calling /usr/bin/profiles: %w", err)
}
// The output of the command is in the form:
//
// ```
// Enrolled via DEP: No
// MDM enrollment: Yes (User Approved)
// MDM server: https://test.example.com/mdm/apple/mdm
// ```
//
// If the host is not enrolled into an MDM, the last line is ommitted,
// so we need to check that:
//
// 1. We've got three rows
// 2. Whether the first line contains "Yes" or "No"
lines := bytes.Split(bytes.TrimSpace(out), []byte("\n"))
if len(lines) < 3 {
return false, nil
}
if strings.Contains(string(lines[0]), "Yes") {
return false, nil
}
return true, nil
}
// getMDMInfoFromProfilesCmd is declared as a variable so it can be overwritten by tests.
var getMDMInfoFromProfilesCmd = func() ([]byte, error) {
cmd := exec.Command("/usr/bin/profiles", "status", "-type", "enrollment")
return cmd.Output()
}
// CheckAssignedEnrollmentProfile runs the `profiles show -type enrollment` command to get the assigned
// MDM enrollment profile and reports if the hostname of the MDM server
// in the assigned profile the device matches the hostname of the provided URL.
func CheckAssignedEnrollmentProfile(expectedURL string) error {
expected, err := url.Parse(expectedURL)
if err != nil {
return fmt.Errorf("parsing expected URL: %w", err)
}
out, err := showEnrollmentProfileCmd()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return fmt.Errorf("show enrollment profile command: %w: %s", err, exitErr.Stderr)
}
return fmt.Errorf("show enrollment profile command: %w", err)
}
// If an enrollment profile is assigned, the output of the command is in the form:
//
// ```
// Device Enrollment configuration:
// {
// AllowPairing = 1;
// AutoAdvanceSetup = 0;
// AwaitDeviceConfigured = 0;
// ConfigurationURL = "https://test.example.com/mdm/apple/enroll?token=1234";
// ConfigurationWebURL = "https://test.example.com/mdm/apple/enroll?token=1234";
// ...
// }
// ```
//
// If the host is not enrolled into an MDM, the output of the command is in the form:
//
// ```
// Device Enrollment configuration:
// (null)
// ```
//
// We will check that the output is at least 2 lines and contains the expected URL
lines := bytes.Split(bytes.TrimSpace(out), []byte("\n"))
if len(lines) < 2 {
return fmt.Errorf("parsing profiles output: expected at least 2 lines but got %d", len(lines))
}
if !bytes.Equal(lines[0], []byte("Device Enrollment configuration:")) {
return errors.New("parsing profiles output: does not match expected device enrollment configuration format")
}
if bytes.Equal(lines[1], []byte("(null)")) {
return errors.New("parsing profiles output: received null device enrollment configuration")
}
var configURL, configWebURL string
var assignedURL string
for _, line := range lines {
// Note the output may contain both ConfigurationURL and ConfigurationWebURL but we check only
// the latter for backwards compatibility.
// See https://github.com/fleetdm/fleet/blob/963b2438537de14e7e16f1f18857ed8a66d51bfc/server/mdm/apple/apple_mdm.go#L195
if v, ok := parseEnrollmentProfileValue(line, "ConfigurationURL"); ok {
configURL = v
continue
}
if v, ok := parseEnrollmentProfileValue(line, "ConfigurationWebURL"); ok {
configWebURL = v
break
}
}
switch {
case configURL == "" && configWebURL == "":
return errors.New("parsing profiles output: missing both configuration web url and configuration url")
case configWebURL != "":
// Always prefer web URL for consistency.
assignedURL = configWebURL
default:
// Fallback to configuration URL.
assignedURL = configURL
}
assigned, err := url.Parse(assignedURL)
if err != nil {
return fmt.Errorf("parsing profiles output: unable to parse server url: %w", err)
}
if !strings.EqualFold(assigned.Hostname(), expected.Hostname()) {
return fmt.Errorf(`matching server url: expected '%s' but found '%s'`, expected.Hostname(), assigned.Hostname())
}
return nil
}
func parseEnrollmentProfileValue(line []byte, key string) (string, bool) {
// Output lines of `profiles show -type enrollment` take the form below:
// ```
// Device Enrollment configuration:
// {
// AllowPairing = 1;
// AutoAdvanceSetup = 0;
// AwaitDeviceConfigured = 0;
// ConfigurationURL = "https://test.example.com/mdm/apple/enroll?token=1234";
// ConfigurationWebURL = "https://test.example.com/mdm/apple/enroll?token=1234";
// ...
// }
// We are interested in the key-value pairs, which feature the separator " = ".
// Note that we want to include the spaces around the equals sign to avoid further splitting
// values, e.g., the url value may also contain an equals sign in the query string.
parts := bytes.SplitN(line, []byte(" = "), 3)
if len(parts) != 2 {
return "", false
}
k := strings.TrimSpace(string(parts[0]))
if k == key {
// The value may be quoted and may contain a trailing semicolon. Remove both.
v := strings.TrimSpace(string(parts[1]))
v = strings.TrimSuffix(v, `;`)
v = strings.Trim(v, `"`)
return v, true
}
return "", false
}
// showEnrollmentProfileCmd is declared as a variable so it can be overwritten by tests.
var showEnrollmentProfileCmd = func() ([]byte, error) {
cmd := exec.Command("sh", "-c", `launchctl asuser $(id -u $(stat -f "%u" /dev/console)) profiles show -type enrollment`)
return cmd.Output()
}