fleet/server/service/client_mdm.go

547 lines
16 KiB
Go
Raw Normal View History

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
}
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 errors.As(err, &notFoundErr{}):
// 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 team_id field
if err := w.WriteField("team_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 !errors.As(err, &notFoundErr{}) {
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 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 bootstrap_package. The file must be a package (.pkg).")
case errors.Is(err, file.ErrNotSigned):
return nil, errors.New("Couldnt edit bootstrap_package. The bootstrap_package must be signed. Learn how to sign the package in the Fleet documentation: https://fleetdm.com/docs/using-fleet/mdm-macos-setup-experience#step-2-sign-the-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 macos_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 macos_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)
}
2024-07-09 17:06:06 +00:00
func (c *Client) MDMListCommands(opts fleet.MDMCommandListOptions) ([]*fleet.MDMCommand, error) {
const defaultCommandsPerPage = 20
verb, path := http.MethodGet, "/api/latest/fleet/mdm/commands"
query := url.Values{}
query.Set("per_page", fmt.Sprint(defaultCommandsPerPage))
query.Set("order_key", "updated_at")
query.Set("order_direction", "desc")
2024-07-09 17:06:06 +00:00
query.Set("host_identifier", opts.Filters.HostIdentifier)
query.Set("request_type", opts.Filters.RequestType)
var responseBody listMDMCommandsResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query.Encode())
if err != nil {
2024-07-09 17:06:06 +00:00
return nil, err
}
return responseBody.Results, nil
}
func (c *Client) MDMGetCommandResults(commandUUID string) ([]*fleet.MDMCommandResult, error) {
verb, path := http.MethodGet, "/api/latest/fleet/mdm/commandresults"
query := url.Values{}
query.Set("command_uuid", commandUUID)
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 errors.As(err, &notFoundErr{}):
// 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 !errors.As(err, &notFoundErr{}) {
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
}