mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
564 lines
17 KiB
Go
564 lines
17 KiB
Go
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("Couldn’t edit macos_bootstrap_package. The file must be a package (.pkg).")
|
||
case errors.Is(err, file.ErrNotSigned):
|
||
return nil, errors.New("Couldn’t 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("Couldn’t 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("Couldn’t 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
|
||
}
|