add ability for end users to enrol their device into fleet mdm (#21751)

relates to #21559

This adds the ability for end users to enrol their own device in fleet
mdm.

> NOTE: this new byod HTML page is a separate HTML asset that contains
all styles and scripts needed for the page to work. We do not send the
fleet UI assets and this drastically cuts down the response time to the
users who will be visiting this page on mobile devices

There are two sides included in this:

**Adding a new add host modal ios and iPad section for IT admins**


![image](https://github.com/user-attachments/assets/1008b190-9c38-4a0e-9b02-19df5da7937d)

**delivering a new byod HTML page to end users that will allow end users
to download the config profile to enrol into fleet mdm**


![image](https://github.com/user-attachments/assets/58d790e4-233b-4b03-ab36-9971aac075de)

<!-- 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.
- [ ] Added/updated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Gabriel Hernandez
2024-09-05 12:47:34 +01:00
committed by GitHub
parent 910b5a7b2b
commit 35c172dcfd
20 changed files with 440 additions and 53 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

@@ -0,0 +1 @@
- add feature for end users to enroll their device into fleet mdm
+4 -1
View File
@@ -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{
@@ -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);
@@ -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";
@@ -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 (
<div className={baseClass}>
<InputField
label="Send this to your end users:"
enableCopy
copyButtonPosition="inside"
readOnly
inputWrapperClass
name="enroll-link"
value={url}
helpText={helpText}
/>
</div>
);
};
export default IosIpadosPanel;
@@ -0,0 +1,5 @@
.ios-ipados-panel {
&__spinner {
margin: $pad-xxlarge auto;
}
}
@@ -0,0 +1 @@
export { default } from "./IosIpadosPanel";
@@ -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 (
<div className={`${baseClass}__ios-ipados--info`}>
<p>
Enroll iPhones and iPads by adding them to Fleet in Apple Business
Manager (ABM).{" "}
<CustomLink
url="https://fleetdm.com/learn-more-about/setup-abm"
text="Learn more"
newTab
/>
</p>
</div>
);
return <IosIpadosPanel enrollSecret={enrollSecret} />;
}
if (packageType === "advanced") {
@@ -590,7 +579,7 @@ const PlatformWrapper = ({
return (
<TabPanel className={`${baseClass}__info`} key={navItem.type}>
<div className={`${baseClass} form`}>
{renderTab(navItem.type)}
{renderPanel(navItem.type)}
</div>
</TabPanel>
);
@@ -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" ? (
<>
<Icon name="copy" />
<span>Copy</span>
</>
) : (
<Icon name="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 (
<div className={wrapperClasses}>
<Button
variant="text-icon"
onClick={copyValue}
className={`${baseClass}__copy-value-button`}
>
{copyButtonValue}
</Button>
{this.state.copied && (
<span className={copiedConfirmationClasses}>Copied!</span>
)}
</div>
);
};
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 (
<FormField
@@ -175,7 +222,9 @@ class InputField extends Component {
}
const inputContainerClasses = classnames(`${baseClass}__input-container`, {
"copy-enabled": this.props.enableCopy,
"copy-enabled": enableCopy,
"copy-outside": enableCopy && copyButtonPosition === "outside",
"copy-inside": enableCopy && copyButtonPosition === "inside",
});
return (
@@ -203,22 +252,8 @@ class InputField extends Component {
autoComplete={blockAutoComplete ? "new-password" : ""}
data-1p-ignore={ignore1password}
/>
{this.props.enableCopy && (
<div className={`${baseClass}__copy-wrapper`}>
<Button
variant="text-icon"
onClick={copyValue}
className={`${baseClass}__copy-value-button`}
>
<Icon name="copy" /> Copy
</Button>
{this.state.copied && (
<span className={`${baseClass}__copied-confirmation`}>
Copied!
</span>
)}
</div>
)}
{enableCopy && this.renderCopyButton()}
</div>
</FormField>
);
@@ -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",
},
};
@@ -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;
}
}
View File
+180
View File
@@ -0,0 +1,180 @@
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<link rel="shortcut icon" href="{{.URLPrefix}}/assets/favicon.ico" />
<title>Fleet</title>
<style>
@font-face {
font-family: "Inter";
font-weight: 400;
src: url("../assets/fonts/inter/Inter-Regular.woff2") format("woff2"),
url("../assets/fonts/inter/Inter-Regular.woff") format("woff");
}
@font-face {
font-family: "Inter";
font-weight: 700;
src: url("../assets/fonts/inter/Inter-Bold.woff2") format("woff2"),
url("../assets/fonts/inter/Inter-Bold.woff") format("woff");
}
html {
box-sizing: border-box;
font-family: "Inter", sans-serif;
color: #192147;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body,
h1,
p {
margin: 0;
}
.download-link {
color: #fff;
background-color: #6a67fe;
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
font-weight: 700;
font-size: 14px;
width: 100px;
line-height: 21px;
}
header {
background-color: #192147;
padding: 13px 20px;
}
h1 {
margin-bottom: 8px;
}
p {
font-size: 14px;
}
ol {
display: flex;
flex-direction: column;
gap: 48px;
list-style: none;
padding: 0;
}
li {
display: flex;
flex-direction: column;
gap: 20px;
}
li > p {
display: flex;
gap: 4px;
}
.page-description {
font-size: 16px;
margin-bottom: 48px;
}
.profile-downloaded-container,
.install-profile-container {
width: 100%;
background-color: #f9fafc;
text-align: center;
}
.profile-downloaded-img,
.install-profile-img {
max-width: 100%;
}
.profile-downloaded-img {
max-height: 150px;
}
.install-profile-img {
max-height: 118px;
}
#main-content {
padding: 48px 24px;
}
</style>
</head>
<body>
<header>
<img src="{{.URLPrefix}}/assets/images/fleet-logo.svg" />
</header>
<section id="main-content">
<h1>Enroll your device to Fleet</h1>
<p class="page-description">
Follow the instructions below to download and install the Fleet profile
on your device.
</p>
<ol>
<li>
<p>
<span>1.</span>
<span>
<b>Download</b> the Fleet profile and select <b>Allow</b> in the
pop-up.
</span>
</p>
<a class="download-link" href="{{.EnrollURL}}">Download</a>
</li>
<li>
<p>
<span>2.</span>
<span>
Navigate to <b>Settings</b> and select <b>Profile Downloaded</b>.
</span>
</p>
<div class="profile-downloaded-container">
<img
class="profile-downloaded-img"
src=""
alt="select profile downloaded in settings"
/>
</div>
</li>
<li>
<p>
<span>3.</span>
<span>Select <b>Install</b>.</span>
</p>
<div class="install-profile-container">
<img class="install-profile-img" src="" alt="select install" />
</div>
</li>
</ol>
</section>
<script>
const os = navigator.userAgent.includes("iPhone") ? "ios" : "iPadOS";
const profileDownloadedImg = document.querySelector(
".profile-downloaded-img"
);
const installProfileImg = document.querySelector(".install-profile-img");
// setting image src based on OS
profileDownloadedImg.setAttribute(
"src",
`{{.URLPrefix}}/assets/images/${os}-profile-downloaded.png`
);
installProfileImg.setAttribute(
"src",
`{{.URLPrefix}}/assets/images/${os}-install-profile.png`
);
</script>
</body>
</html>
+65
View File
@@ -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")))
}
+27
View File
@@ -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")
}
+3 -3
View File
@@ -567,9 +567,6 @@ 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
@@ -890,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())