feat: add endpoint for getting an ota profile (#21655)
> Related issue: #21557 # Checklist for submitter If some of the following don't apply, delete the relevant line. <!-- Note that API documentation changes are now addressed by the product design team. --> - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
This commit is contained in:
@@ -0,0 +1 @@
|
||||
- Adds an endpoint for getting an OTA MDM profile for enrolling iOS and iPadOS hosts.
|
||||
@@ -930,6 +930,9 @@ type Service interface {
|
||||
// CheckMDMAppleEnrollmentWithMinimumOSVersion checks if the minimum OS version is met for a MDM enrollment
|
||||
CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *MDMAppleMachineInfo) (*MDMAppleSoftwareUpdateRequired, error)
|
||||
|
||||
// GetOTAProfile gets the OTA (over-the-air) profile for a given team based on the enroll secret provided.
|
||||
GetOTAProfile(ctx context.Context, enrollSecret string) ([]byte, error)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// CronSchedulesService
|
||||
|
||||
|
||||
@@ -1041,3 +1041,36 @@ func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMApp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateOTAEnrollmentProfileMobileconfig(orgName, fleetURL, enrollSecret string) ([]byte, error) {
|
||||
path, err := url.JoinPath(fleetURL, "/api/fleet/ota_enrollment")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating path for ota enrollment url: %w", err)
|
||||
}
|
||||
|
||||
enrollURL, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing ota enrollment url: %w", err)
|
||||
}
|
||||
|
||||
q := enrollURL.Query()
|
||||
q.Set("enroll_secret", enrollSecret)
|
||||
enrollURL.RawQuery = q.Encode()
|
||||
|
||||
var profileBuf bytes.Buffer
|
||||
tmplArgs := struct {
|
||||
Organization string
|
||||
URL string
|
||||
EnrollSecret string
|
||||
}{
|
||||
Organization: orgName,
|
||||
URL: enrollURL.String(),
|
||||
}
|
||||
|
||||
err = mobileconfig.OTAMobileConfigTemplate.Execute(&profileBuf, tmplArgs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("executing ota profile template: %w", err)
|
||||
}
|
||||
|
||||
return profileBuf.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package mobileconfig
|
||||
|
||||
import "text/template"
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
var funcMap = map[string]any{
|
||||
"xml": XMLEscapeString,
|
||||
@@ -113,3 +118,40 @@ var FleetCARootTemplate = template.Must(template.New("").Option("missingkey=erro
|
||||
</dict>
|
||||
</plist>
|
||||
`))
|
||||
|
||||
var OTAMobileConfigTemplate = template.Must(template.New("").Funcs(template.FuncMap{"xml": func(v string) (string, error) {
|
||||
var escaped strings.Builder
|
||||
if err := xml.EscapeText(&escaped, []byte(v)); err != nil {
|
||||
return "", fmt.Errorf("XML escaping in OTA profile: %w", err)
|
||||
}
|
||||
return escaped.String(), nil
|
||||
}}).Option("missingkey=error").Parse(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Inc//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<dict>
|
||||
<key>URL</key>
|
||||
<string>{{ .URL }}</string>
|
||||
<key>DeviceAttributes</key>
|
||||
<array>
|
||||
<string>UDID</string>
|
||||
<string>VERSION</string>
|
||||
<string>PRODUCT</string>
|
||||
<string>SERIAL</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>{{ xml .Organization }}</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>{{ xml .Organization }} enrollment</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>PayloadUUID</key>
|
||||
<string>fdb376e5-b5bb-4d8c-829e-e90865f990c9</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.fleetdm.fleet.mdm.apple.ota</string>
|
||||
<key>PayloadType</key>
|
||||
<string>Profile Service</string>
|
||||
</dict>
|
||||
</plist>`))
|
||||
|
||||
@@ -4177,3 +4177,61 @@ func (svc *Service) RenewABMToken(ctx context.Context, token io.Reader, tokenID
|
||||
|
||||
return nil, fleet.ErrMissingLicense
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// GET /enrollment_profiles/ota
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type getOTAProfileRequest struct {
|
||||
EnrollSecret string `query:"enroll_secret"`
|
||||
}
|
||||
|
||||
type getOTAProfileResponse struct {
|
||||
Profile string `json:"profile"`
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r getOTAProfileResponse) error() error { return r.Err }
|
||||
|
||||
func getOTAProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
|
||||
req := request.(*getOTAProfileRequest)
|
||||
profile, err := svc.GetOTAProfile(ctx, req.EnrollSecret)
|
||||
if err != nil {
|
||||
return &getOTAProfileResponse{Err: err}, err
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(profile)
|
||||
return &getMDMAppleConfigProfileResponse{fileReader: io.NopCloser(reader), fileLength: reader.Size(), fileName: "foobar.mobileconfig"}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) GetOTAProfile(ctx context.Context, enrollSecret string) ([]byte, error) {
|
||||
// Skip authz as this endpoint is used by end users from their iPhones or iPads; authz is done
|
||||
// by the enroll secret verification below
|
||||
svc.authz.SkipAuthorization(ctx)
|
||||
|
||||
_, err := svc.ds.VerifyEnrollSecret(ctx, enrollSecret)
|
||||
if err != nil {
|
||||
if fleet.IsNotFound(err) {
|
||||
return nil, fleet.NewAuthFailedError("invalid enroll secret for OTA profile")
|
||||
}
|
||||
|
||||
return nil, ctxerr.Wrap(ctx, err, "verifying enroll secret")
|
||||
}
|
||||
|
||||
cfg, err := svc.ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting app config to get org name")
|
||||
}
|
||||
|
||||
profBytes, err := apple_mdm.GenerateOTAEnrollmentProfileMobileconfig(cfg.OrgInfo.OrgName, cfg.ServerSettings.ServerURL, enrollSecret)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "generating ota mobileconfig file")
|
||||
}
|
||||
|
||||
signed, err := mdmcrypto.Sign(ctx, profBytes, svc.ds)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "signing profile")
|
||||
}
|
||||
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
@@ -567,6 +567,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
||||
mdmAppleMW.GET("/api/_version_/fleet/mdm/manual_enrollment_profile", getManualEnrollmentProfileEndpoint, getManualEnrollmentProfileRequest{})
|
||||
mdmAppleMW.GET("/api/_version_/fleet/enrollment_profiles/manual", getManualEnrollmentProfileEndpoint, getManualEnrollmentProfileRequest{})
|
||||
|
||||
// Get OTA profile
|
||||
mdmAppleMW.GET("/api/_version_/fleet/enrollment_profiles/ota", getOTAProfileEndpoint, getOTAProfileRequest{})
|
||||
|
||||
// bootstrap-package routes
|
||||
|
||||
// Deprecated: POST /mdm/bootstrap is now deprecated, replaced by the
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -4812,3 +4813,36 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesExcludeLabels() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestOTAProfile() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
// Getting profile for non-existent secret should fail
|
||||
s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusUnauthorized, "enroll_secret", "not-real")
|
||||
|
||||
// Create an enroll secret; has some special characters that should be escaped in the profile
|
||||
globalEnrollSec := "global_enroll+_/sec"
|
||||
escSec := url.QueryEscape(globalEnrollSec)
|
||||
s.Do("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
|
||||
Spec: &fleet.EnrollSecretSpec{
|
||||
Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}},
|
||||
},
|
||||
}, http.StatusOK)
|
||||
|
||||
cfg, err := s.ds.AppConfig(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get profile with that enroll secret
|
||||
resp := s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", globalEnrollSec)
|
||||
require.NotZero(t, resp.ContentLength)
|
||||
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;")
|
||||
require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
|
||||
require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, resp.ContentLength, int64(len(b)))
|
||||
require.Contains(t, string(b), "com.fleetdm.fleet.mdm.apple.ota")
|
||||
require.Contains(t, string(b), fmt.Sprintf("%s/api/fleet/ota_enrollment?enroll_secret=%s", cfg.ServerSettings.ServerURL, escSec))
|
||||
require.Contains(t, string(b), cfg.OrgInfo.OrgName)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user