2022-12-05 16:35:45 +00:00
package service
2023-04-07 20:31:02 +00:00
import (
"bytes"
"context"
"crypto/sha256"
2023-11-01 14:13:12 +00:00
"encoding/base64"
2023-04-25 13:36:01 +00:00
"encoding/json"
2023-04-07 20:31:02 +00:00
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
2023-05-11 13:36:28 +00:00
"net/url"
2023-04-25 13:36:01 +00:00
"os"
"path/filepath"
"strings"
2023-04-07 20:31:02 +00:00
2023-11-02 18:06:37 +00:00
"github.com/beevik/etree"
2023-04-07 20:31:02 +00:00
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/server/fleet"
2023-11-01 14:13:12 +00:00
"github.com/google/uuid"
"howett.net/plist"
2023-04-07 20:31:02 +00:00
)
2022-12-05 16:35:45 +00:00
// 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
}
2022-12-12 20:45:53 +00:00
// 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
}
2023-01-25 19:44:29 +00:00
2024-12-03 16:12:07 +00:00
func ( c * Client ) CountABMTokens ( ) ( int , error ) {
verb , path := "GET" , "/api/latest/fleet/abm_tokens/count"
var responseBody countABMTokensResponse
2024-11-13 19:07:59 +00:00
err := c . authenticatedRequestWithQuery ( nil , verb , path , & responseBody , "" )
2024-12-03 16:12:07 +00:00
return responseBody . Count , err
2024-11-13 19:07:59 +00:00
}
2023-01-25 19:44:29 +00:00
// RequestAppleCSR requests a signed CSR from the Fleet server and returns the
2024-05-27 14:14:37 +00:00
// CSR bytes
func ( c * Client ) RequestAppleCSR ( ) ( [ ] byte , error ) {
2024-06-04 21:19:09 +00:00
verb , path := "GET" , "/api/latest/fleet/mdm/apple/request_csr"
var resp getMDMAppleCSRResponse
err := c . authenticatedRequest ( nil , verb , path , & resp )
return resp . CSR , err
2024-05-27 14:14:37 +00:00
}
// RequestAppleABM requests a signed CSR from the Fleet server and returns the
// public key bytes
func ( c * Client ) RequestAppleABM ( ) ( [ ] byte , error ) {
2024-06-04 21:19:09 +00:00
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
2023-01-25 19:44:29 +00:00
}
2023-04-07 20:31:02 +00:00
2023-06-07 17:29:36 +00:00
func ( c * Client ) GetBootstrapPackageMetadata ( teamID uint , forUpdate bool ) ( * fleet . MDMAppleBootstrapPackage , error ) {
2024-02-07 12:24:24 +00:00
verb , path := "GET" , fmt . Sprintf ( "/api/latest/fleet/mdm/bootstrap/%d/metadata" , teamID )
2023-04-07 20:31:02 +00:00
var responseBody bootstrapPackageMetadataResponse
2023-06-07 17:29:36 +00:00
var err error
if forUpdate {
2025-09-11 21:18:17 +00:00
err = c . authenticatedRequestWithQuery ( nil , verb , path , & responseBody , "for_update=true" )
2023-06-07 17:29:36 +00:00
} else {
2025-09-11 21:18:17 +00:00
err = c . authenticatedRequest ( nil , verb , path , & responseBody )
2023-06-07 17:29:36 +00:00
}
2023-04-22 15:23:38 +00:00
return responseBody . MDMAppleBootstrapPackage , err
2023-04-07 20:31:02 +00:00
}
2025-06-02 21:17:06 +00:00
func ( c * Client ) DeleteBootstrapPackageIfNeeded ( teamID uint , dryRun bool ) error {
2025-02-07 20:35:51 +00:00
_ , 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 )
}
2025-06-02 21:17:06 +00:00
err = c . DeleteBootstrapPackage ( teamID , dryRun )
2025-02-07 20:35:51 +00:00
if err != nil {
return fmt . Errorf ( "deleting bootstrap package: %w" , err )
}
return nil
}
2025-06-02 21:17:06 +00:00
func ( c * Client ) DeleteBootstrapPackage ( teamID uint , dryRun bool ) error {
2024-02-07 12:24:24 +00:00
verb , path := "DELETE" , fmt . Sprintf ( "/api/latest/fleet/mdm/bootstrap/%d" , teamID )
2023-04-07 20:31:02 +00:00
var responseBody deleteBootstrapPackageResponse
2025-09-11 21:18:17 +00:00
err := c . authenticatedRequestWithQuery ( nil , verb , path , & responseBody , fmt . Sprintf ( "dry_run=%t" , dryRun ) )
2023-04-07 20:31:02 +00:00
return err
}
2025-06-02 21:17:06 +00:00
func ( c * Client ) UploadBootstrapPackage ( pkg * fleet . MDMAppleBootstrapPackage , dryRun bool ) error {
2025-09-10 06:35:45 +00:00
verb , path := "POST" , "/api/latest/fleet/bootstrap"
2023-04-07 20:31:02 +00:00
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 ( )
2025-06-02 21:17:06 +00:00
response , err := c . doContextWithBodyAndHeaders ( context . Background ( ) , verb , path , fmt . Sprintf ( "dry_run=%t" , dryRun ) ,
2023-04-07 20:31:02 +00:00
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 )
}
2023-07-25 00:17:20 +00:00
defer response . Body . Close ( )
2023-04-07 20:31:02 +00:00
var bpResponse uploadBootstrapPackageResponse
if err := c . parseResponse ( verb , path , response , & bpResponse ) ; err != nil {
return fmt . Errorf ( "parse response: %w" , err )
}
return nil
}
2025-06-02 21:17:06 +00:00
func ( c * Client ) UploadBootstrapPackageIfNeeded ( bp * fleet . MDMAppleBootstrapPackage , teamID uint , dryRun bool ) error {
2023-04-07 20:31:02 +00:00
isFirstTime := false
2023-06-07 17:29:36 +00:00
oldMeta , err := c . GetBootstrapPackageMetadata ( teamID , true )
2023-04-07 20:31:02 +00:00
if err != nil {
// not found is OK, it means this is our first time uploading a package
2023-07-25 00:17:20 +00:00
if ! errors . As ( err , & notFoundErr { } ) {
2023-04-07 20:31:02 +00:00
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
2025-06-02 21:17:06 +00:00
err = c . DeleteBootstrapPackage ( teamID , dryRun )
2023-04-07 20:31:02 +00:00
if err != nil {
return fmt . Errorf ( "deleting old bootstrap package: %w" , err )
}
}
bp . TeamID = teamID
2025-06-02 21:17:06 +00:00
if err := c . UploadBootstrapPackage ( bp , dryRun ) ; err != nil {
2023-04-07 20:31:02 +00:00
return err
}
return nil
}
func ( c * Client ) ValidateBootstrapPackageFromURL ( url string ) ( * fleet . MDMAppleBootstrapPackage , error ) {
2023-05-02 19:03:10 +00:00
if err := c . CheckPremiumMDMEnabled ( ) ; err != nil {
2023-04-07 20:31:02 +00:00
return nil , err
}
return downloadRemoteMacosBootstrapPackage ( url )
}
2023-05-11 13:36:28 +00:00
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.
2023-04-07 20:31:02 +00:00
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
2023-04-22 15:23:38 +00:00
var filename string
2023-04-07 20:31:02 +00:00
cdh , ok := resp . Header [ "Content-Disposition" ]
if ok && len ( cdh ) > 0 {
_ , params , err := mime . ParseMediaType ( cdh [ 0 ] )
if err == nil {
filename = params [ "filename" ]
}
}
2023-05-11 13:36:28 +00:00
// if it fails, try to extract it from the URL
if filename == "" {
2024-05-14 18:06:33 +00:00
filename = file . ExtractFilenameFromURLPath ( pkgURL , "pkg" )
2023-05-11 13:36:28 +00:00
}
// if all else fails, use a default name
2023-04-22 15:23:38 +00:00
if filename == "" {
filename = "bootstrap-package.pkg"
}
2023-04-07 20:31:02 +00:00
// 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 ) :
2023-10-04 19:39:09 +00:00
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" )
2023-04-07 20:31:02 +00:00
default :
return nil , fmt . Errorf ( "checking package signature: %w" , err )
}
}
return & fleet . MDMAppleBootstrapPackage {
Name : filename ,
Bytes : pkgBuf . Bytes ( ) ,
Sha256 : hash . Sum ( nil ) ,
} , nil
}
2023-04-25 13:36:01 +00:00
func ( c * Client ) validateMacOSSetupAssistant ( fileName string ) ( [ ] byte , error ) {
2023-11-01 14:13:12 +00:00
if err := c . CheckAppleMDMEnabled ( ) ; err != nil {
2023-04-25 13:36:01 +00:00
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 {
2025-02-07 20:35:51 +00:00
verb , path := http . MethodPost , "/api/latest/fleet/enrollment_profiles/automatic"
2023-04-25 13:36:01 +00:00
request := createMDMAppleSetupAssistantRequest {
TeamID : teamID ,
Name : name ,
EnrollmentProfile : json . RawMessage ( data ) ,
}
return c . authenticatedRequest ( request , verb , path , nil )
}
2023-11-01 14:13:12 +00:00
2025-02-07 20:35:51 +00:00
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
2023-11-01 14:13:12 +00:00
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 )
2023-11-01 14:13:12 +00:00
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
2023-11-01 14:13:12 +00:00
}
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 :
2024-05-28 22:17:14 +00:00
return nil , fmt . Errorf ( "Invalid platform %q. You can only run MDM commands on Windows or Apple hosts." , forPlatform )
2023-11-01 14:13:12 +00:00
}
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
}
2023-11-02 18:06:37 +00:00
// 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 ( )
2023-11-01 14:13:12 +00:00
}
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
}
2024-02-13 18:03:53 +00:00
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
}
2024-02-29 17:13:25 +00:00
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
}
2025-07-01 16:28:13 +00:00
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
2025-09-11 21:18:17 +00:00
err := c . authenticatedRequest ( nil , verb , path , & responseBody )
2025-07-01 16:28:13 +00:00
return responseBody . Bytes , err
}
func ( c * Client ) GetEULAMetadata ( ) ( * fleet . MDMEULA , error ) {
verb , path := "GET" , "/api/latest/fleet/setup_experience/eula/metadata"
var responseBody getMDMEULAMetadataResponse
2025-09-11 21:18:17 +00:00
err := c . authenticatedRequest ( nil , verb , path , & responseBody )
2025-07-01 16:28:13 +00:00
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
2025-09-11 21:18:17 +00:00
err := c . authenticatedRequestWithQuery ( nil , verb , path , & responseBody , fmt . Sprintf ( "dry_run=%t" , dryRun ) )
2025-07-01 16:28:13 +00:00
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
}