diff --git a/changes/21557-ota-profile-endpoint b/changes/21557-ota-profile-endpoint new file mode 100644 index 0000000000..4acf2bbcf5 --- /dev/null +++ b/changes/21557-ota-profile-endpoint @@ -0,0 +1 @@ +- Adds an endpoint for getting an OTA MDM profile for enrolling iOS and iPadOS hosts. \ No newline at end of file diff --git a/server/fleet/service.go b/server/fleet/service.go index b81366fcc1..5a9b197bce 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -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 diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index d85377bb20..fd315ac353 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -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 +} diff --git a/server/mdm/apple/mobileconfig/profiles.go b/server/mdm/apple/mobileconfig/profiles.go index a2dfdf4438..b71d2db5ab 100644 --- a/server/mdm/apple/mobileconfig/profiles.go +++ b/server/mdm/apple/mobileconfig/profiles.go @@ -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 `)) + +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(` + + + + PayloadContent + + URL + {{ .URL }} + DeviceAttributes + + UDID + VERSION + PRODUCT + SERIAL + + + PayloadOrganization + {{ xml .Organization }} + PayloadDisplayName + {{ xml .Organization }} enrollment + PayloadVersion + 1 + PayloadUUID + fdb376e5-b5bb-4d8c-829e-e90865f990c9 + PayloadIdentifier + com.fleetdm.fleet.mdm.apple.ota + PayloadType + Profile Service + +`)) diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 4a02bd608e..9431dd884d 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -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 +} diff --git a/server/service/handler.go b/server/service/handler.go index c1cde37255..497fd8955f 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -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 diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index 597f6e4f3d..ad5ab7ed61 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -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) +}