mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 17:08:53 +00:00
add end user BYOD enrollment into Fleet MDM (#21836)
relates to #19448 Adds the ability for a user to enroll a their device into fleet MDM. > NOTE: this is the PR for the feature branch to go into main so all code has already been approved.
This commit is contained in:
commit
c0373cbe51
26 changed files with 596 additions and 51 deletions
BIN
assets/images/iPadOS-install-profile.png
Normal file
BIN
assets/images/iPadOS-install-profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
assets/images/iPadOS-profile-downloaded.png
Normal file
BIN
assets/images/iPadOS-profile-downloaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
BIN
assets/images/ios-install-profile.png
Normal file
BIN
assets/images/ios-install-profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
BIN
assets/images/ios-profile-downloaded.png
Normal file
BIN
assets/images/ios-profile-downloaded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
1
changes/21557-ota-profile-endpoint
Normal file
1
changes/21557-ota-profile-endpoint
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Adds an endpoint for getting an OTA MDM profile for enrolling iOS and iPadOS hosts.
|
||||
1
changes/21559-add-end-user-enrolment-page
Normal file
1
changes/21559-add-end-user-enrolment-page
Normal file
|
|
@ -0,0 +1 @@
|
|||
- add feature for end users to enroll their device into fleet mdm
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
0
frontend/styles/byod.css
Normal file
0
frontend/styles/byod.css
Normal file
180
frontend/templates/enroll-ota.html
Normal file
180
frontend/templates/enroll-ota.html
Normal 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>
|
||||
|
|
@ -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/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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue