mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 17:05:18 +00:00
9082438580
Feature branch for the #9949 story. --------- Co-authored-by: Jahziel Villasana-Espinoza <jahziel@fleetdm.com> Co-authored-by: Roberto Dip <me@roperzh.com> Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com> Co-authored-by: Sarah Gillespie <sarah@fleetdm.com>
391 lines
12 KiB
Go
391 lines
12 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"
|
||
"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
|
||
}
|
||
|
||
// RequestAppleCSR requests a signed CSR from the Fleet server and returns the
|
||
// SCEP certificate and key along with the APNs key used for the CSR.
|
||
func (c *Client) RequestAppleCSR(email, org string) (*fleet.AppleCSR, error) {
|
||
verb, path := "POST", "/api/latest/fleet/mdm/apple/request_csr"
|
||
request := requestMDMAppleCSRRequest{
|
||
EmailAddress: email,
|
||
Organization: org,
|
||
}
|
||
var responseBody requestMDMAppleCSRResponse
|
||
err := c.authenticatedRequest(request, verb, path, &responseBody)
|
||
return responseBody.AppleCSR, 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)
|
||
request := bootstrapPackageMetadataRequest{}
|
||
var responseBody bootstrapPackageMetadataResponse
|
||
var err error
|
||
if forUpdate {
|
||
err = c.authenticatedRequestWithQuery(request, verb, path, &responseBody, "for_update=true")
|
||
} else {
|
||
err = c.authenticatedRequest(request, verb, path, &responseBody)
|
||
}
|
||
return responseBody.MDMAppleBootstrapPackage, err
|
||
}
|
||
|
||
func (c *Client) DeleteBootstrapPackage(teamID uint) error {
|
||
verb, path := "DELETE", fmt.Sprintf("/api/latest/fleet/mdm/bootstrap/%d", teamID)
|
||
request := deleteBootstrapPackageRequest{}
|
||
var responseBody deleteBootstrapPackageResponse
|
||
err := c.authenticatedRequest(request, verb, path, &responseBody)
|
||
return err
|
||
}
|
||
|
||
func (c *Client) UploadBootstrapPackage(pkg *fleet.MDMAppleBootstrapPackage) error {
|
||
verb, path := "POST", "/api/latest/fleet/mdm/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, "",
|
||
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) EnsureBootstrapPackage(bp *fleet.MDMAppleBootstrapPackage, teamID uint) 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, ¬FoundErr{}) {
|
||
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)
|
||
if err != nil {
|
||
return fmt.Errorf("deleting old bootstrap package: %w", err)
|
||
}
|
||
}
|
||
|
||
bp.TeamID = teamID
|
||
if err := c.UploadBootstrapPackage(bp); 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 extractFilenameFromPath(p string) string {
|
||
u, err := url.Parse(p)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
|
||
invalid := map[string]struct{}{
|
||
"": {},
|
||
".": {},
|
||
"/": {},
|
||
}
|
||
|
||
b := path.Base(u.Path)
|
||
if _, ok := invalid[b]; ok {
|
||
return ""
|
||
}
|
||
|
||
if _, ok := invalid[path.Ext(b)]; ok {
|
||
return b + ".pkg"
|
||
}
|
||
|
||
return b
|
||
}
|
||
|
||
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 = extractFilenameFromPath(pkgURL)
|
||
}
|
||
|
||
// 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 bootstrap_package. The file must be a package (.pkg).")
|
||
case errors.Is(err, file.ErrNotSigned):
|
||
return nil, errors.New("Couldn’t 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("Couldn’t 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("Couldn’t 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 := "POST", "/api/latest/fleet/mdm/apple/enrollment_profile"
|
||
request := createMDMAppleSetupAssistantRequest{
|
||
TeamID: teamID,
|
||
Name: name,
|
||
EnrollmentProfile: json.RawMessage(data),
|
||
}
|
||
return c.authenticatedRequest(request, verb, path, nil)
|
||
}
|
||
|
||
func (c *Client) MDMListCommands() ([]*fleet.MDMCommand, error) {
|
||
const defaultCommandsPerPage = 1000
|
||
|
||
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")
|
||
|
||
var responseBody listMDMCommandsResponse
|
||
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) 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 macOS 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
|
||
}
|