diff --git a/assets/images/iPadOS-install-profile.png b/assets/images/iPadOS-install-profile.png new file mode 100644 index 0000000000..c398038517 Binary files /dev/null and b/assets/images/iPadOS-install-profile.png differ diff --git a/assets/images/iPadOS-profile-downloaded.png b/assets/images/iPadOS-profile-downloaded.png new file mode 100644 index 0000000000..f21b12615f Binary files /dev/null and b/assets/images/iPadOS-profile-downloaded.png differ diff --git a/assets/images/ios-install-profile.png b/assets/images/ios-install-profile.png new file mode 100644 index 0000000000..8266c55201 Binary files /dev/null and b/assets/images/ios-install-profile.png differ diff --git a/assets/images/ios-profile-downloaded.png b/assets/images/ios-profile-downloaded.png new file mode 100644 index 0000000000..7941a65815 Binary files /dev/null and b/assets/images/ios-profile-downloaded.png differ 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/changes/21559-add-end-user-enrolment-page b/changes/21559-add-end-user-enrolment-page new file mode 100644 index 0000000000..427f1c5beb --- /dev/null +++ b/changes/21559-add-end-user-enrolment-page @@ -0,0 +1 @@ +- add feature for end users to enroll their device into fleet mdm diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 6c363965a8..c8773ba765 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -967,7 +967,7 @@ the way that the Fleet server works. KeyPrefix: "ratelimit::", } - var apiHandler, frontendHandler http.Handler + var apiHandler, frontendHandler, endUserEnrollOTAHandler http.Handler { frontendHandler = service.PrometheusMetricsHandler( "get_frontend", @@ -985,8 +985,10 @@ the way that the Fleet server works. if setupRequired { apiHandler = service.WithSetup(svc, logger, apiHandler) frontendHandler = service.RedirectLoginToSetup(svc, logger, frontendHandler, config.Server.URLPrefix) + endUserEnrollOTAHandler = service.RedirectLoginToSetup(svc, logger, frontendHandler, config.Server.URLPrefix) } else { frontendHandler = service.RedirectSetupToLogin(svc, logger, frontendHandler, config.Server.URLPrefix) + endUserEnrollOTAHandler = service.ServeEndUserEnrollOTA(config.Server.URLPrefix, logger) } } @@ -1121,6 +1123,7 @@ the way that the Fleet server works. } apiHandler.ServeHTTP(rw, req) }) + rootMux.Handle("/enroll", endUserEnrollOTAHandler) rootMux.Handle("/", frontendHandler) debugHandler := &debugMux{ diff --git a/frontend/components/AddHostsModal/AddHostsModal.tests.tsx b/frontend/components/AddHostsModal/AddHostsModal.tests.tsx index 658bbd1188..2af31091f6 100644 --- a/frontend/components/AddHostsModal/AddHostsModal.tests.tsx +++ b/frontend/components/AddHostsModal/AddHostsModal.tests.tsx @@ -73,8 +73,9 @@ describe("AddHostsModal", () => { expect(screen.queryByText(/--enable-scripts/i)).not.toBeInTheDocument(); await user.click(screen.getByRole("tab", { name: "iOS & iPadOS" })); - expect(screen.queryByText(/Apple Business Manager/i)).toBeInTheDocument(); - expect(screen.queryByText(/Learn more/i)).toBeInTheDocument(); + expect( + screen.queryByText(/Send this to your end users:/i) + ).toBeInTheDocument(); await user.click(screen.getByRole("tab", { name: "Advanced" })); const advancedText = screen.getByText(/--type=YOUR_TYPE/i); diff --git a/frontend/components/AddHostsModal/AddHostsModal.tsx b/frontend/components/AddHostsModal/AddHostsModal.tsx index 04a42908ed..1cac771209 100644 --- a/frontend/components/AddHostsModal/AddHostsModal.tsx +++ b/frontend/components/AddHostsModal/AddHostsModal.tsx @@ -9,7 +9,6 @@ import Modal from "components/Modal"; import Spinner from "components/Spinner"; import PlatformWrapper from "./PlatformWrapper/PlatformWrapper"; -import DownloadInstallers from "./DownloadInstallers/DownloadInstallers"; const baseClass = "add-hosts-modal"; diff --git a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx new file mode 100644 index 0000000000..40d991e2f8 --- /dev/null +++ b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/IosIpadosPanel.tsx @@ -0,0 +1,46 @@ +import React, { useContext } from "react"; + +import { AppContext } from "context/app"; + +// @ts-ignore +import InputField from "components/forms/fields/InputField"; + +const generateUrl = (serverUrl: string, enrollSecret: string) => { + return `${serverUrl}/enroll?enroll_secret=${enrollSecret}`; +}; + +const baseClass = "ios-ipados-panel"; + +interface IosIpadosPanelProps { + enrollSecret: string; +} + +const IosIpadosPanel = ({ enrollSecret }: IosIpadosPanelProps) => { + const { config } = useContext(AppContext); + + const helpText = + "When the end user navigates to this URL, the enrollment profile " + + "will download in their browser. End users will have to install the profile " + + "to enroll to Fleet."; + + if (!config) return null; + + const url = generateUrl(config.server_settings.server_url, enrollSecret); + + return ( +
+ +
+ ); +}; + +export default IosIpadosPanel; diff --git a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/_styles.scss b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/_styles.scss new file mode 100644 index 0000000000..e781e7ca70 --- /dev/null +++ b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/_styles.scss @@ -0,0 +1,5 @@ +.ios-ipados-panel { + &__spinner { + margin: $pad-xxlarge auto; + } +} diff --git a/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/index.ts b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/index.ts new file mode 100644 index 0000000000..4dc628ef88 --- /dev/null +++ b/frontend/components/AddHostsModal/PlatformWrapper/IosIpadosPanel/index.ts @@ -0,0 +1 @@ +export { default } from "./IosIpadosPanel"; diff --git a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx index f10fbead55..a2a8ccb1d1 100644 --- a/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx +++ b/frontend/components/AddHostsModal/PlatformWrapper/PlatformWrapper.tsx @@ -19,6 +19,7 @@ import InfoBanner from "components/InfoBanner/InfoBanner"; import CustomLink from "components/CustomLink/CustomLink"; import { isValidPemCertificate } from "../../../pages/hosts/ManageHostsPage/helpers"; +import IosIpadosPanel from "./IosIpadosPanel"; interface IPlatformSubNav { name: string; @@ -324,7 +325,7 @@ const PlatformWrapper = ({ ); }; - const renderTab = (packageType: string) => { + const renderPanel = (packageType: string) => { const CHROME_OS_INFO = { extensionId: "fleeedmmihkfkeemmipgmhhjemlljidg", installationUrl: "https://chrome.fleetdm.com/updates.xml", @@ -395,19 +396,7 @@ const PlatformWrapper = ({ } if (packageType === "ios-ipados") { - return ( -
-

- Enroll iPhones and iPads by adding them to Fleet in Apple Business - Manager (ABM).{" "} - -

-
- ); + return ; } if (packageType === "advanced") { @@ -590,7 +579,7 @@ const PlatformWrapper = ({ return (
- {renderTab(navItem.type)} + {renderPanel(navItem.type)}
); diff --git a/frontend/components/forms/fields/InputField/InputField.jsx b/frontend/components/forms/fields/InputField/InputField.jsx index 09ffad2fa4..758ef70ab4 100644 --- a/frontend/components/forms/fields/InputField/InputField.jsx +++ b/frontend/components/forms/fields/InputField/InputField.jsx @@ -43,6 +43,7 @@ class InputField extends Component { PropTypes.object, ]), enableCopy: PropTypes.bool, + copyButtonPosition: PropTypes.oneOfType(["inside", "outside"]), ignore1password: PropTypes.bool, }; @@ -62,6 +63,7 @@ class InputField extends Component { labelTooltipPosition: undefined, helpText: "", enableCopy: false, + copyButtonPosition: "outside", ignore1password: false, }; @@ -97,6 +99,59 @@ class InputField extends Component { return onChange(value); }; + renderCopyButton = () => { + const { value, copyButtonPosition } = this.props; + + const copyValue = (e) => { + e.preventDefault(); + stringToClipboard(value).then(() => { + this.setState({ copied: true }); + setTimeout(() => { + this.setState({ copied: false }); + }, 2000); + }); + }; + + const copyButtonValue = + copyButtonPosition === "outside" ? ( + <> + + Copy + + ) : ( + + ); + + const wrapperClasses = classnames( + `${baseClass}__copy-wrapper`, + copyButtonPosition === "outside" + ? `${baseClass}__copy-wrapper-outside` + : `${baseClass}__copy-wrapper-inside` + ); + + const copiedConfirmationClasses = classnames( + `${baseClass}__copied-confirmation`, + copyButtonPosition === "outside" + ? `${baseClass}__copied-confirmation-outside` + : `${baseClass}__copied-confirmation-inside` + ); + + return ( +
+ + {this.state.copied && ( + Copied! + )} +
+ ); + }; + render() { const { readOnly, @@ -113,6 +168,8 @@ class InputField extends Component { blockAutoComplete, value, ignore1password, + enableCopy, + copyButtonPosition, } = this.props; const { onInputChange } = this; @@ -139,16 +196,6 @@ class InputField extends Component { "labelTooltipPosition", ]); - const copyValue = (e) => { - e.preventDefault(); - stringToClipboard(value).then(() => { - this.setState({ copied: true }); - setTimeout(() => { - this.setState({ copied: false }); - }, 2000); - }); - }; - if (type === "textarea") { return ( - {this.props.enableCopy && ( -
- - {this.state.copied && ( - - Copied! - - )} -
- )} + + {enableCopy && this.renderCopyButton()}
); diff --git a/frontend/components/forms/fields/InputField/InputField.stories.jsx b/frontend/components/forms/fields/InputField/InputField.stories.jsx index 09ccb5a197..7b27aa435c 100644 --- a/frontend/components/forms/fields/InputField/InputField.stories.jsx +++ b/frontend/components/forms/fields/InputField/InputField.stories.jsx @@ -8,3 +8,16 @@ const meta = { export default meta; export const Basic = {}; + +export const WithCopyEnabled = { + args: { + enableCopy: true, + }, +}; + +export const WithCopyEnabledInsideInput = { + args: { + enableCopy: true, + copyButtonPosition: "inside", + }, +}; diff --git a/frontend/components/forms/fields/InputField/_styles.scss b/frontend/components/forms/fields/InputField/_styles.scss index caf6c23713..c70b755175 100644 --- a/frontend/components/forms/fields/InputField/_styles.scss +++ b/frontend/components/forms/fields/InputField/_styles.scss @@ -86,17 +86,32 @@ } } - &__copy-wrapper { + &__copy-wrapper-outside { position: relative; } + &__copy-wrapper-inside { + position: absolute; + top: 50%; + transform: translateY(-50%); + right: 12px; + } + + &__input-container.copy-enabled { - display: flex; - align-items: center; - gap: $pad-medium; + &.copy-outside { + display: flex; + align-items: center; + gap: $pad-medium; + } + + &.copy-inside { + position: relative; + } } &__copied-confirmation { + font-size: $x-small; position: absolute; background-color: $ui-light-grey; border: solid 1px $ui-fleet-black-10; @@ -104,6 +119,13 @@ padding: $pad-xxsmall 6px; top: 50%; transform: translateY(-50%); + } + + &__copied-confirmation-inside { + right: 24px; + } + + &__copied-confirmation-outside { left: -90px; } } diff --git a/frontend/styles/byod.css b/frontend/styles/byod.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/templates/enroll-ota.html b/frontend/templates/enroll-ota.html new file mode 100644 index 0000000000..6aafd2870d --- /dev/null +++ b/frontend/templates/enroll-ota.html @@ -0,0 +1,180 @@ + + + + + + + Fleet + + + +
+ +
+
+

Enroll your device to Fleet

+

+ Follow the instructions below to download and install the Fleet profile + on your device. +

+
    +
  1. +

    + 1. + + Download the Fleet profile and select Allow in the + pop-up. + +

    + Download +
  2. +
  3. +

    + 2. + + Navigate to Settings and select Profile Downloaded. + +

    +
    + select profile downloaded in settings +
    +
  4. +
  5. +

    + 3. + Select Install. +

    +
    + select install +
    +
  6. +
+
+ + + diff --git a/server/fleet/service.go b/server/fleet/service.go index b975691da3..023c560eab 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 a280b36c28..eb047ad34a 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/v1/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 6460719268..3072cd710c 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -4147,3 +4147,45 @@ 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"` +} + +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 &getMDMAppleConfigProfileResponse{Err: err}, err + } + + reader := bytes.NewReader(profile) + return &getMDMAppleConfigProfileResponse{fileReader: io.NopCloser(reader), fileLength: reader.Size(), fileName: "fleet-mdm-enrollment-profile"}, 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) + + 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/frontend.go b/server/service/frontend.go index f5d884ec1f..a2a6058b54 100644 --- a/server/service/frontend.go +++ b/server/service/frontend.go @@ -1,9 +1,11 @@ package service import ( + "fmt" "html/template" "io" "net/http" + "net/url" assetfs "github.com/elazarl/go-bindata-assetfs" "github.com/fleetdm/fleet/v4/server/bindata" @@ -68,6 +70,69 @@ func ServeFrontend(urlPrefix string, sandbox bool, logger log.Logger) http.Handl }) } +func ServeEndUserEnrollOTA(urlPrefix string, logger log.Logger) http.Handler { + herr := func(w http.ResponseWriter, err string) { + logger.Log("err", err) + http.Error(w, err, http.StatusInternalServerError) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeBrowserSecurityHeaders(w) + + fs := newBinaryFileSystem("/frontend") + file, err := fs.Open("templates/enroll-ota.html") + if err != nil { + herr(w, "load enroll ota template: "+err.Error()) + return + } + + data, err := io.ReadAll(file) + if err != nil { + herr(w, "read bindata file: "+err.Error()) + return + } + + t, err := template.New("enroll-ota").Parse(string(data)) + if err != nil { + herr(w, "create react template: "+err.Error()) + return + } + + enrollURL, err := generateEnrollOTAURL(urlPrefix, r.URL.Query().Get("enroll_secret")) + if err != nil { + herr(w, "generate enroll ota url: "+err.Error()) + return + } + if err := t.Execute(w, struct { + EnrollURL string + URLPrefix string + }{ + URLPrefix: urlPrefix, + EnrollURL: enrollURL, + }); err != nil { + herr(w, "execute react template: "+err.Error()) + return + } + }) +} + +func generateEnrollOTAURL(fleetURL string, enrollSecret string) (string, error) { + path, err := url.JoinPath(fleetURL, "/api/v1/fleet/enrollment_profiles/ota") + if err != nil { + return "", fmt.Errorf("creating path for end user ota enrollment url: %w", err) + } + + enrollURL, err := url.Parse(path) + if err != nil { + return "", fmt.Errorf("parsing end user ota enrollment url: %w", err) + } + + q := enrollURL.Query() + q.Set("enroll_secret", enrollSecret) + enrollURL.RawQuery = q.Encode() + return enrollURL.String(), nil +} + func ServeStaticAssets(path string) http.Handler { return http.StripPrefix(path, http.FileServer(newBinaryFileSystem("/assets"))) } diff --git a/server/service/frontend_test.go b/server/service/frontend_test.go index 32363d6dd8..2710b69e9d 100644 --- a/server/service/frontend_test.go +++ b/server/service/frontend_test.go @@ -2,6 +2,7 @@ package service import ( "bytes" + "io" "net/http" "net/http/httptest" "os" @@ -40,3 +41,29 @@ func TestServeFrontend(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusMethodNotAllowed, response.StatusCode) } + +func TestServeEndUserEnrollOTA(t *testing.T) { + if !hasBuildTag("full") { + t.Skip("This test requires running with -tags full") + } + logger := log.NewLogfmtLogger(os.Stdout) + h := ServeEndUserEnrollOTA("", logger) + ts := httptest.NewServer(h) + t.Cleanup(func() { + ts.Close() + }) + + // assert html is returned + response, err := http.DefaultClient.Get(ts.URL + "?enroll_secret=foo") + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, response.Header.Get("Content-Type"), "text/html; charset=utf-8") + + // assert it contains the content we expect + defer response.Body.Close() + bodyBytes, err := io.ReadAll(response.Body) + require.NoError(t, err) + bodyString := string(bodyBytes) + require.Contains(t, bodyString, "Enroll your device to Fleet") + require.Contains(t, bodyString, "?enroll_secret=foo") +} diff --git a/server/service/handler.go b/server/service/handler.go index c1cde37255..23b30a3461 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -887,6 +887,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Deprecated: GET /mdm/apple/setup/eula/:token is now deprecated, replaced by the platform agnostic /mdm/setup/eula/:token neAppleMDM.GET("/api/_version_/fleet/mdm/apple/setup/eula/{token}", getMDMEULAEndpoint, getMDMEULARequest{}) + // Get OTA profile + neAppleMDM.GET("/api/_version_/fleet/enrollment_profiles/ota", getOTAProfileEndpoint, getOTAProfileRequest{}) + // These endpoint are used by Microsoft devices during MDM device enrollment phase neWindowsMDM := ne.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM()) diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index caae54a183..41b1fec9cf 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" @@ -4814,3 +4815,36 @@ func (s *integrationMDMTestSuite) TestHostMDMProfilesExcludeLabels() { }, }) } + +func (s *integrationMDMTestSuite) TestOTAProfile() { + t := s.T() + ctx := context.Background() + + // Getting profile for non-existent secret it's ok + s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "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;filename="fleet-mdm-enrollment-profile.mobileconfig"`) + 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/v1/fleet/ota_enrollment?enroll_secret=%s", cfg.ServerSettings.ServerURL, escSec)) + require.Contains(t, string(b), cfg.OrgInfo.OrgName) +}