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.
+
+ Download the Fleet profile and select Allow in the
+ pop-up.
+
+
+ Download
+
+ -
+
+ 2.
+
+ Navigate to Settings and select Profile Downloaded.
+
+
+
+
![select profile downloaded in settings]()
+
+
+ -
+
+ 3.
+ Select Install.
+
+
+
![select install]()
+
+
+
+
+
+
+
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)
+}