fleet/server/service/client_mdm.go

564 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/beevik/etree"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/google/uuid"
"howett.net/plist"
)
// GetAppleMDM retrieves the Apple MDM APNs information.
func (c *Client) GetAppleMDM() (*fleet.AppleMDM, error) {
verb, path := "GET", "/api/latest/fleet/mdm/apple"
var responseBody getAppleMDMResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, "")
return responseBody.AppleMDM, err
}
// GetAppleBM retrieves the Apple Business Manager information.
func (c *Client) GetAppleBM() (*fleet.AppleBM, error) {
verb, path := "GET", "/api/latest/fleet/mdm/apple_bm"
var responseBody getAppleBMResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, "")
return responseBody.AppleBM, err
}
// GetVPPTokens retrieves the List Volume Purchasing Program (VPP) tokens
func (c *Client) GetVPPTokens() ([]*fleet.VPPTokenDB, error) {
verb, path := "GET", "/api/latest/fleet/vpp_tokens"
var responseBody getVPPTokensResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, "")
return responseBody.Tokens, err
}
func (c *Client) CountABMTokens() (int, error) {
verb, path := "GET", "/api/latest/fleet/abm_tokens/count"
var responseBody countABMTokensResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, "")
return responseBody.Count, err
}
// RequestAppleCSR requests a signed CSR from the Fleet server and returns the
// CSR bytes
func (c *Client) RequestAppleCSR() ([]byte, error) {
verb, path := "GET", "/api/latest/fleet/mdm/apple/request_csr"
var resp getMDMAppleCSRResponse
err := c.authenticatedRequest(nil, verb, path, &resp)
return resp.CSR, err
}
// RequestAppleABM requests a signed CSR from the Fleet server and returns the
// public key bytes
func (c *Client) RequestAppleABM() ([]byte, error) {
verb, path := "GET", "/api/latest/fleet/mdm/apple/abm_public_key"
var resp generateABMKeyPairResponse
err := c.authenticatedRequest(nil, verb, path, &resp)
return resp.PublicKey, err
}
func (c *Client) GetBootstrapPackageMetadata(teamID uint, forUpdate bool) (*fleet.MDMAppleBootstrapPackage, error) {
verb, path := "GET", fmt.Sprintf("/api/latest/fleet/mdm/bootstrap/%d/metadata", teamID)
var responseBody bootstrapPackageMetadataResponse
var err error
if forUpdate {
err = c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, "for_update=true")
} else {
err = c.authenticatedRequest(nil, verb, path, &responseBody)
}
return responseBody.MDMAppleBootstrapPackage, err
}
func (c *Client) DeleteBootstrapPackageIfNeeded(teamID uint, dryRun bool) error {
_, err := c.GetBootstrapPackageMetadata(teamID, true)
switch {
case isNotFoundErr(err):
// not found is OK, it means there is nothing to delete
return nil
case err != nil:
return fmt.Errorf("getting bootstrap package metadata: %w", err)
}
err = c.DeleteBootstrapPackage(teamID, dryRun)
if err != nil {
return fmt.Errorf("deleting bootstrap package: %w", err)
}
return nil
}
func (c *Client) DeleteBootstrapPackage(teamID uint, dryRun bool) error {
verb, path := "DELETE", fmt.Sprintf("/api/latest/fleet/mdm/bootstrap/%d", teamID)
var responseBody deleteBootstrapPackageResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, fmt.Sprintf("dry_run=%t", dryRun))
return err
}
func (c *Client) UploadBootstrapPackage(pkg *fleet.MDMAppleBootstrapPackage, dryRun bool) error {
verb, path := "POST", "/api/latest/fleet/bootstrap"
var b bytes.Buffer
w := multipart.NewWriter(&b)
// add the package field
fw, err := w.CreateFormFile("package", pkg.Name)
if err != nil {
return err
}
if _, err := io.Copy(fw, bytes.NewBuffer(pkg.Bytes)); err != nil {
return err
}
// add the fleet_id field
if err := w.WriteField("fleet_id", fmt.Sprint(pkg.TeamID)); err != nil {
return err
}
w.Close()
response, err := c.doContextWithBodyAndHeaders(context.Background(), verb, path, fmt.Sprintf("dry_run=%t", dryRun),
b.Bytes(),
map[string]string{
"Content-Type": w.FormDataContentType(),
"Accept": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", c.token),
},
)
if err != nil {
return fmt.Errorf("do multipart request: %w", err)
}
defer response.Body.Close()
var bpResponse uploadBootstrapPackageResponse
if err := c.ParseResponse(verb, path, response, &bpResponse); err != nil {
return fmt.Errorf("parse response: %w", err)
}
return nil
}
func (c *Client) UploadBootstrapPackageIfNeeded(bp *fleet.MDMAppleBootstrapPackage, teamID uint, dryRun bool) error {
isFirstTime := false
oldMeta, err := c.GetBootstrapPackageMetadata(teamID, true)
if err != nil {
// not found is OK, it means this is our first time uploading a package
if !isNotFoundErr(err) {
return fmt.Errorf("getting bootstrap package metadata: %w", err)
}
isFirstTime = true
}
if !isFirstTime {
// compare checksums, if they're equal then we can skip the package upload.
if bytes.Equal(oldMeta.Sha256, bp.Sha256) {
return nil
}
// similar to the expected UI experience, delete the bootstrap package first
err = c.DeleteBootstrapPackage(teamID, dryRun)
if err != nil {
return fmt.Errorf("deleting old bootstrap package: %w", err)
}
}
bp.TeamID = teamID
if err := c.UploadBootstrapPackage(bp, dryRun); err != nil {
return err
}
return nil
}
func (c *Client) ValidateBootstrapPackageFromURL(url string) (*fleet.MDMAppleBootstrapPackage, error) {
if err := c.CheckPremiumMDMEnabled(); err != nil {
return nil, err
}
return downloadRemoteMacosBootstrapPackage(url)
}
func downloadRemoteMacosBootstrapPackage(pkgURL string) (*fleet.MDMAppleBootstrapPackage, error) {
resp, err := http.Get(pkgURL) // nolint:gosec // we want this URL to be provided by the user. It will run on their machine.
if err != nil {
return nil, fmt.Errorf("downloading bootstrap package: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("the URL to the macos_bootstrap_package doesn't exist. Please make this URL publicly accessible to the internet.")
}
// try to extract the name from a header
var filename string
cdh, ok := resp.Header["Content-Disposition"]
if ok && len(cdh) > 0 {
_, params, err := mime.ParseMediaType(cdh[0])
if err == nil {
filename = params["filename"]
}
}
// if it fails, try to extract it from the URL
if filename == "" {
filename = file.ExtractFilenameFromURLPath(pkgURL, "pkg")
}
// if all else fails, use a default name
if filename == "" {
filename = "bootstrap-package.pkg"
}
// get checksums
var pkgBuf bytes.Buffer
hash := sha256.New()
if _, err := io.Copy(hash, io.TeeReader(resp.Body, &pkgBuf)); err != nil {
return nil, fmt.Errorf("calculating sha256 of package: %w", err)
}
pkgReader := bytes.NewReader(pkgBuf.Bytes())
if err := file.CheckPKGSignature(pkgReader); err != nil {
switch {
case errors.Is(err, file.ErrInvalidType):
return nil, errors.New("Couldnt edit macos_bootstrap_package. The file must be a package (.pkg).")
case errors.Is(err, file.ErrNotSigned):
return nil, errors.New("Couldnt edit macos_bootstrap_package. The macos_bootstrap_package must be signed. Learn how to sign the package in the Fleet documentation: https://fleetdm.com/learn-more-about/setup-experience/bootstrap-package")
default:
return nil, fmt.Errorf("checking package signature: %w", err)
}
}
return &fleet.MDMAppleBootstrapPackage{
Name: filename,
Bytes: pkgBuf.Bytes(),
Sha256: hash.Sum(nil),
}, nil
}
func (c *Client) validateMacOSSetupAssistant(fileName string) ([]byte, error) {
if err := c.CheckAppleMDMEnabled(); err != nil {
return nil, err
}
if strings.ToLower(filepath.Ext(fileName)) != ".json" {
return nil, errors.New("Couldnt edit apple_setup_assistant. The file should be a .json file.")
}
b, err := os.ReadFile(fileName)
if err != nil {
return nil, err
}
var raw json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
return nil, fmt.Errorf("Couldnt edit apple_setup_assistant. The file should include valid JSON: %w", err)
}
return b, nil
}
func (c *Client) uploadMacOSSetupAssistant(data []byte, teamID *uint, name string) error {
verb, path := http.MethodPost, "/api/latest/fleet/enrollment_profiles/automatic"
request := createMDMAppleSetupAssistantRequest{
TeamID: teamID,
Name: name,
EnrollmentProfile: json.RawMessage(data),
}
return c.authenticatedRequest(request, verb, path, nil)
}
func (c *Client) deleteMacOSSetupAssistant(teamID *uint) error {
verb, path := http.MethodDelete, "/api/latest/fleet/enrollment_profiles/automatic"
request := deleteMDMAppleSetupAssistantRequest{
TeamID: teamID,
}
return c.authenticatedRequest(request, verb, path, nil)
}
func (c *Client) MDMListCommands(opts fleet.MDMCommandListOptions) ([]*fleet.MDMCommand, error) {
const defaultCommandsPerPage = 20
verb, path := http.MethodGet, "/api/latest/fleet/commands"
query := url.Values{}
query.Set("per_page", fmt.Sprint(defaultCommandsPerPage))
query.Set("order_key", "updated_at")
query.Set("order_direction", "desc")
query.Set("host_identifier", opts.Filters.HostIdentifier)
query.Set("request_type", opts.Filters.RequestType)
var statuses []string
if len(opts.Filters.CommandStatuses) > 0 {
statuses = make([]string, 0, len(opts.Filters.CommandStatuses))
for _, s := range opts.Filters.CommandStatuses {
statuses = append(statuses, string(s))
}
}
query.Set("command_status", strings.Join(statuses, ","))
var responseBody listMDMCommandsResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query.Encode())
if err != nil {
return nil, err
}
return responseBody.Results, nil
}
func (c *Client) MDMGetCommandResults(commandUUID, hostIdentifier string) ([]*fleet.MDMCommandResult, error) {
verb, path := http.MethodGet, "/api/latest/fleet/commands/results"
query := url.Values{}
query.Set("command_uuid", commandUUID)
query.Set("host_identifier", hostIdentifier)
var responseBody getMDMCommandResultsResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query.Encode())
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
return responseBody.Results, nil
}
func (c *Client) RunMDMCommand(hostUUIDs []string, rawCmd []byte, forPlatform string) (*fleet.CommandEnqueueResult, error) {
var prepareFn func([]byte) ([]byte, error)
switch forPlatform {
case "darwin":
prepareFn = c.prepareAppleMDMCommand
case "windows":
prepareFn = c.prepareWindowsMDMCommand
default:
return nil, fmt.Errorf("Invalid platform %q. You can only run MDM commands on Windows or Apple hosts.", forPlatform)
}
rawCmd, err := prepareFn(rawCmd)
if err != nil {
return nil, err
}
request := runMDMCommandRequest{
Command: base64.RawStdEncoding.EncodeToString(rawCmd),
HostUUIDs: hostUUIDs,
}
var response runMDMCommandResponse
if err := c.authenticatedRequest(request, "POST", "/api/latest/fleet/mdm/commands/run", &response); err != nil {
return nil, fmt.Errorf("run command request: %w", err)
}
return response.CommandEnqueueResult, nil
}
func (c *Client) prepareWindowsMDMCommand(rawCmd []byte) ([]byte, error) {
if _, err := fleet.ParseWindowsMDMCommand(rawCmd); err != nil {
return nil, err
}
// ensure there's a CmdID with a random UUID value, we're manipulating
// the document this way to make sure we don't introduce any unintended
// changes to the command XML.
doc := etree.NewDocument()
if err := doc.ReadFromBytes(rawCmd); err != nil {
return nil, err
}
element := doc.FindElement("//CmdID")
// if we can't find a CmdID, just add one.
if element == nil {
root := doc.Root()
element = root.CreateElement("CmdID")
}
element.SetText(uuid.NewString())
return doc.WriteToBytes()
}
func (c *Client) prepareAppleMDMCommand(rawCmd []byte) ([]byte, error) {
var commandPayload map[string]interface{}
if _, err := plist.Unmarshal(rawCmd, &commandPayload); err != nil {
return nil, fmt.Errorf("The payload isn't valid XML. Please provide a file with valid XML: %w", err)
}
if commandPayload == nil {
return nil, errors.New("The payload isn't valid. Please provide a valid MDM command in the form of a plist-encoded XML file.")
}
// generate a random command UUID
commandPayload["CommandUUID"] = uuid.New().String()
b, err := plist.Marshal(commandPayload, plist.XMLFormat)
if err != nil {
return nil, fmt.Errorf("marshal command plist: %w", err)
}
return b, nil
}
func (c *Client) MDMLockHost(hostID uint) error {
var response lockHostResponse
if err := c.authenticatedRequest(nil, "POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", hostID), &response); err != nil {
return fmt.Errorf("lock host request: %w", err)
}
return nil
}
func (c *Client) MDMUnlockHost(hostID uint) (string, error) {
var response unlockHostResponse
if err := c.authenticatedRequest(nil, "POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", hostID), &response); err != nil {
return "", fmt.Errorf("lock host request: %w", err)
}
return response.UnlockPIN, nil
}
func (c *Client) MDMWipeHost(hostID uint) error {
var response wipeHostResponse
if err := c.authenticatedRequest(nil, "POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", hostID), &response); err != nil {
return fmt.Errorf("wipe host request: %w", err)
}
return nil
}
type eulaContent struct {
Bytes []byte
}
// eulaContent implements the bodyHandler interface so that we can read the
// response body directly into a byte slice which represents the EULA file content.
// This handler will be called in the Client.parseResponse method.
func (ec *eulaContent) Handle(res *http.Response) error {
b, err := io.ReadAll(res.Body)
ec.Bytes = b
return err
}
func (c *Client) GetEULAContent(token string) ([]byte, error) {
verb, path := "GET", fmt.Sprintf("/api/latest/fleet/setup_experience/eula/%s", token)
var responseBody eulaContent
err := c.authenticatedRequest(nil, verb, path, &responseBody)
return responseBody.Bytes, err
}
func (c *Client) GetEULAMetadata() (*fleet.MDMEULA, error) {
verb, path := "GET", "/api/latest/fleet/setup_experience/eula/metadata"
var responseBody getMDMEULAMetadataResponse
err := c.authenticatedRequest(nil, verb, path, &responseBody)
return responseBody.MDMEULA, err
}
func (c *Client) DeleteEULAIfNeeded(dryRun bool) error {
eula, err := c.GetEULAMetadata()
switch {
case isNotFoundErr(err):
// not found is OK, it means there is nothing to delete
return nil
case err != nil:
return fmt.Errorf("getting eula metadata: %w", err)
}
err = c.DeleteEULA(eula.Token, dryRun)
if err != nil {
return fmt.Errorf("deleting eula: %w", err)
}
return nil
}
func (c *Client) DeleteEULA(token string, dryRun bool) error {
verb, path := "DELETE", fmt.Sprintf("/api/latest/fleet/setup_experience/eula/%s", token)
var responseBody deleteMDMEULAResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, fmt.Sprintf("dry_run=%t", dryRun))
return err
}
func (c *Client) UploadEULAIfNeeded(eulaPath string, dryRun bool) error {
isFirstTime := false
oldMeta, err := c.GetEULAMetadata()
if err != nil {
// not found is OK, it means this is our first time uploading a eula
if !isNotFoundErr(err) {
return fmt.Errorf("getting eula metadata: %w", err)
}
isFirstTime = true
}
// read file to get the new file bytes
eulaBytes, err := os.ReadFile(eulaPath)
if err != nil {
return fmt.Errorf("reading eula file: %w", err)
}
if !isFirstTime {
newChecksum := sha256.Sum256(eulaBytes)
// compare checksums, if they're equal then we can skip the eula upload
if bytes.Equal(oldMeta.Sha256, newChecksum[:]) && oldMeta.Name == filepath.Base(eulaPath) {
return nil
}
// similar to the expected UI experience, delete the old eula first
err = c.DeleteEULA(oldMeta.Token, dryRun)
if err != nil {
return fmt.Errorf("deleting old eula: %w", err)
}
}
if err := c.UploadEULA(eulaPath, dryRun); err != nil {
return err
}
return nil
}
func (c *Client) UploadEULA(eulaPath string, dryRun bool) error {
verb, path := "POST", "/api/latest/fleet/setup_experience/eula"
var b bytes.Buffer
w := multipart.NewWriter(&b)
// add the eula field
fw, err := w.CreateFormFile("eula", filepath.Base(eulaPath))
if err != nil {
return err
}
file, err := os.Open(eulaPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(fw, file); err != nil {
return err
}
err = w.Close()
if err != nil {
return fmt.Errorf("closing writer: %w", err)
}
resp, err := c.doContextWithBodyAndHeaders(context.Background(), verb, path, fmt.Sprintf("dry_run=%t", dryRun),
b.Bytes(),
map[string]string{
"Content-Type": w.FormDataContentType(),
"Accept": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", c.token),
})
if err != nil {
return fmt.Errorf("do multipart request: %w", err)
}
defer resp.Body.Close()
var eulaResponse createMDMEULAResponse
if err := c.ParseResponse(verb, path, resp, &eulaResponse); err != nil {
return fmt.Errorf("parse response: %w", err)
}
return nil
}