mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Show configuration profile name and more fine-grained status (#42126)
Resolves #40177 and subissues. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [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/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [sorta] QA'd all new/changed functionality manually ## Database migrations - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Profile names are now displayed alongside mobile device management commands for installing or removing profiles. These names are visible in command details modals and within device activity timelines. * Added "NotNow" status for deferred profile commands, providing improved transparency into which profiles are being managed and the current status of profile installation or removal operations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
d2c485a5f7
commit
da6cfd8e9f
23 changed files with 514 additions and 74 deletions
2
changes/40177-config-profile-name-status
Normal file
2
changes/40177-config-profile-name-status
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
* Added logging of profile names alongside MDM commands installing or removing them
|
||||
* Added indication in the UI when a profile command was deferred ("NotNow" status)
|
||||
|
|
@ -9,6 +9,7 @@ const DEFAULT_COMMAND_MOCK: ICommand = {
|
|||
updated_at: "2024-01-01T00:00:00Z",
|
||||
request_type: "InstallProfile",
|
||||
hostname: "default-hostname",
|
||||
name: null,
|
||||
};
|
||||
|
||||
export const createMockCommand = (overrides?: Partial<ICommand>): ICommand => ({
|
||||
|
|
@ -47,6 +48,7 @@ const DEFAULT_COMMAND_RESULT_MOCK: ICommandResult = {
|
|||
<Status>Acknowledged</Status>
|
||||
<Message>Installation complete</Message>
|
||||
</Result>`),
|
||||
name: null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface ICommand {
|
|||
updated_at: string;
|
||||
request_type: string;
|
||||
hostname: string;
|
||||
name: string | null; // Profile name when command is for installing/removing a macOS profile
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -30,10 +31,9 @@ export interface ICommandResult {
|
|||
payload: string;
|
||||
/** Base64-encoded string containing the MDM command response */
|
||||
result: string;
|
||||
name: string | null; // Profile name when command is for installing/removing a macOS profile
|
||||
/** ResultsMetadata contains command-specific metadata.
|
||||
* VPP install commands include a "software_installed" boolean and
|
||||
* "vpp_verify_timeout_seconds" integer. */
|
||||
results_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type IMDMCommandResult = ICommandResult;
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ import commandApi, {
|
|||
IGetHostCommandResultsQueryKey,
|
||||
} from "services/entities/command";
|
||||
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import Modal from "components/Modal";
|
||||
import Spinner from "components/Spinner";
|
||||
import DataError from "components/DataError";
|
||||
import IconStatusMessage from "components/IconStatusMessage";
|
||||
import { IconNames } from "components/icons";
|
||||
import Textarea from "components/Textarea";
|
||||
import ModalFooter from "components/ModalFooter";
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
|
|
@ -50,12 +50,19 @@ const getStatusMessage = (result: ICommandResult): React.ReactNode => {
|
|||
})})`
|
||||
: null;
|
||||
|
||||
const namePart = result.name ? (
|
||||
<>
|
||||
{" "}
|
||||
for <b>{result.name}</b>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
switch (result.status) {
|
||||
case "CommandFormatError":
|
||||
case "Error":
|
||||
return (
|
||||
<span>
|
||||
The <b>{result.request_type}</b> command failed on{" "}
|
||||
The <b>{result.request_type}</b> command{namePart} failed on{" "}
|
||||
<b>{result.hostname}</b>
|
||||
{displayTime}.
|
||||
</span>
|
||||
|
|
@ -64,7 +71,7 @@ const getStatusMessage = (result: ICommandResult): React.ReactNode => {
|
|||
case "Acknowledged":
|
||||
return (
|
||||
<span>
|
||||
The <b>{result.request_type}</b> command ran on{" "}
|
||||
The <b>{result.request_type}</b> command{namePart} was acknowledged by{" "}
|
||||
<b>{result.hostname}</b>
|
||||
{displayTime}.
|
||||
</span>
|
||||
|
|
@ -73,15 +80,15 @@ const getStatusMessage = (result: ICommandResult): React.ReactNode => {
|
|||
case "Pending":
|
||||
return (
|
||||
<span>
|
||||
The <b>{result.request_type}</b> command is running or will run on{" "}
|
||||
<b>{result.hostname}</b> when it comes online.
|
||||
The <b>{result.request_type}</b> command{namePart} is pending on{" "}
|
||||
<b>{result.hostname}</b>.
|
||||
</span>
|
||||
);
|
||||
|
||||
case "NotNow":
|
||||
return (
|
||||
<span>
|
||||
The <b>{result.request_type}</b> command didn't run on{" "}
|
||||
The <b>{result.request_type}</b> command{namePart} is deferred on{" "}
|
||||
<b>{result.hostname}</b> because the host was locked or was running on
|
||||
battery power while in Power Nap. Fleet will try again.
|
||||
</span>
|
||||
|
|
@ -135,21 +142,26 @@ const ModalContent = ({
|
|||
message={getStatusMessage(result)}
|
||||
/>
|
||||
{!!result.payload && (
|
||||
<Textarea label="Request payload:" variant="code">
|
||||
{result.payload}
|
||||
</Textarea>
|
||||
<InputField
|
||||
type="textarea"
|
||||
label="Request payload:"
|
||||
value={result.payload}
|
||||
readOnly
|
||||
enableCopy
|
||||
/>
|
||||
)}
|
||||
{!!result.result && (
|
||||
<Textarea
|
||||
<InputField
|
||||
type="textarea"
|
||||
label={
|
||||
<>
|
||||
Response from <b>{result.hostname}</b>:
|
||||
</>
|
||||
}
|
||||
variant="code"
|
||||
>
|
||||
{result.result}
|
||||
</Textarea>
|
||||
value={result.result}
|
||||
readOnly
|
||||
enableCopy
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,15 +4,16 @@
|
|||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
padding-bottom: $pad-small;
|
||||
|
||||
.textarea-wrapper {
|
||||
gap: $pad-small;
|
||||
}
|
||||
}
|
||||
|
||||
// shortening the hight of the textarea
|
||||
// so the modal height stays within the viewport
|
||||
.textarea--code {
|
||||
.input-field__textarea {
|
||||
// shortening the height of the textarea
|
||||
// so the modal height stays within the viewport
|
||||
max-height: 200px;
|
||||
height: auto;
|
||||
field-sizing: content;
|
||||
overflow-y: auto;
|
||||
background-color: $ui-off-white;
|
||||
font-family: "SourceCodePro", $monospace;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,27 +18,48 @@ interface ICommandItemProps {
|
|||
onShowDetails: ShowCommandDetailsHandler;
|
||||
}
|
||||
|
||||
const CommandItem = ({ command, onShowDetails }: ICommandItemProps) => {
|
||||
const { command_status, request_type, updated_at } = command;
|
||||
const isProfileCommand = (requestType: string): boolean =>
|
||||
requestType === "InstallProfile" || requestType === "RemoveProfile";
|
||||
|
||||
const getStatusText = (command: ICommand): string => {
|
||||
const { command_status, status } = command;
|
||||
|
||||
// Differentiate NotNow from regular Pending
|
||||
if (status === "NotNow") {
|
||||
return "is deferred";
|
||||
}
|
||||
|
||||
let statusVerb = "";
|
||||
switch (command_status) {
|
||||
case "pending":
|
||||
statusVerb = "will run";
|
||||
break;
|
||||
case "ran":
|
||||
return "is pending";
|
||||
case "failed":
|
||||
statusVerb = command_status;
|
||||
break;
|
||||
return "failed";
|
||||
case "ran":
|
||||
default:
|
||||
statusVerb = "ran";
|
||||
return "was acknowledged";
|
||||
}
|
||||
};
|
||||
|
||||
const CommandItem = ({ command, onShowDetails }: ICommandItemProps) => {
|
||||
const { request_type, updated_at, name } = command;
|
||||
|
||||
const statusText = getStatusText(command);
|
||||
|
||||
const onShowCommandDetails = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onShowDetails(command);
|
||||
};
|
||||
|
||||
const activityText = name ? (
|
||||
<>
|
||||
The <b>{request_type}</b> command for <b>{name}</b> {statusText}.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The <b>{request_type}</b> command {statusText}.
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<FeedListItem
|
||||
className={baseClass}
|
||||
|
|
@ -47,7 +68,7 @@ const CommandItem = ({ command, onShowDetails }: ICommandItemProps) => {
|
|||
createdAt={new Date(updated_at)}
|
||||
onClickFeedItem={onShowCommandDetails}
|
||||
>
|
||||
The <b>{request_type}</b> command {statusVerb}.
|
||||
{activityText}
|
||||
</FeedListItem>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1032,7 +1032,8 @@ SELECT
|
|||
ncr.result,
|
||||
ncr.updated_at,
|
||||
nc.request_type,
|
||||
nc.command as payload
|
||||
nc.command as payload,
|
||||
nc.name
|
||||
FROM
|
||||
nano_command_results ncr
|
||||
INNER JOIN
|
||||
|
|
@ -1085,7 +1086,8 @@ SELECT
|
|||
COALESCE(ncr.status, 'Pending') AS status,
|
||||
request_type,
|
||||
nc.command AS payload,
|
||||
COALESCE(ncr.result, '') AS result
|
||||
COALESCE(ncr.result, '') AS result,
|
||||
nc.name
|
||||
FROM
|
||||
nano_enrollment_queue nq
|
||||
JOIN nano_commands nc ON nq.command_uuid = nc.command_uuid
|
||||
|
|
@ -1130,7 +1132,8 @@ SELECT
|
|||
COALESCE(nvq.result_updated_at, nvq.created_at) as updated_at,
|
||||
nvq.request_type,
|
||||
h.hostname,
|
||||
h.team_id
|
||||
h.team_id,
|
||||
nvq.name
|
||||
FROM
|
||||
nano_view_queue nvq
|
||||
INNER JOIN
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ SELECT
|
|||
COALESCE(nvq.result_updated_at, nvq.created_at) as updated_at,
|
||||
nvq.request_type as request_type,
|
||||
h.hostname,
|
||||
h.team_id
|
||||
h.team_id,
|
||||
nvq.name
|
||||
FROM
|
||||
nano_view_queue nvq
|
||||
INNER JOIN
|
||||
|
|
@ -67,7 +68,8 @@ SELECT
|
|||
COALESCE(wmc.updated_at, wmc.created_at) as updated_at,
|
||||
wmc.target_loc_uri as request_type,
|
||||
h.hostname,
|
||||
h.team_id
|
||||
h.team_id,
|
||||
NULL as name
|
||||
FROM windows_mdm_commands wmc
|
||||
LEFT JOIN windows_mdm_command_queue wmcq ON wmcq.command_uuid = wmc.command_uuid
|
||||
LEFT JOIN windows_mdm_command_results wmcr ON wmc.command_uuid = wmcr.command_uuid
|
||||
|
|
@ -225,7 +227,8 @@ SELECT
|
|||
WHEN COALESCE(NULLIF(ncr.status, ''), 'Pending') = 'Error' THEN 'failed'
|
||||
ELSE 'pending'
|
||||
END AS command_status,
|
||||
request_type
|
||||
request_type,
|
||||
nc.name
|
||||
FROM
|
||||
nano_enrollment_queue nq
|
||||
JOIN nano_commands nc ON nq.command_uuid = nc.command_uuid
|
||||
|
|
@ -251,7 +254,8 @@ WHERE
|
|||
wc.created_at AS updated_at,
|
||||
'101' AS status,
|
||||
'pending' AS command_status,
|
||||
wc.target_loc_uri AS request_type
|
||||
wc.target_loc_uri AS request_type,
|
||||
NULL AS name
|
||||
FROM
|
||||
windows_mdm_command_queue wq
|
||||
JOIN mdm_windows_enrollments mwe ON mwe.id = wq.enrollment_id
|
||||
|
|
@ -287,7 +291,8 @@ WHERE
|
|||
) AS UNSIGNED
|
||||
) >= 400 THEN 'failed'
|
||||
END AS command_status,
|
||||
wc.target_loc_uri AS request_type
|
||||
wc.target_loc_uri AS request_type,
|
||||
NULL AS name
|
||||
FROM
|
||||
windows_mdm_command_results wcr
|
||||
JOIN mdm_windows_enrollments mwe ON mwe.id = wcr.enrollment_id
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ func TestMDMShared(t *testing.T) {
|
|||
{"TestDeleteMDMProfilesCancelsInstalls", testDeleteMDMProfilesCancelsInstalls},
|
||||
{"TestDeleteTeamCancelsWindowsProfileInstalls", testDeleteTeamCancelsWindowsProfileInstalls},
|
||||
{"TestCleanUpMDMManagedCertificates", testCleanUpMDMManagedCertificates},
|
||||
{"TestEnqueueCommandWithName", testEnqueueCommandWithName},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
|
@ -9142,7 +9143,7 @@ func testDeleteMDMProfilesCancelsInstalls(t *testing.T, ds *Datastore) {
|
|||
forceSetAppleHostProfileStatus(t, ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToProf["A2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
|
||||
// enqueue the corresponding command for the installed profile
|
||||
cmdUUID := uuid.New().String()
|
||||
err = commander.InstallProfile(ctx, []string{host2.UUID}, appleProfs[1].Mobileconfig, cmdUUID)
|
||||
err = commander.InstallProfile(ctx, []string{host2.UUID}, appleProfs[1].Mobileconfig, cmdUUID, "")
|
||||
require.NoError(t, err)
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET command_uuid = ? WHERE host_uuid = ? AND profile_uuid = ?`, cmdUUID, host2.UUID, profNameToProf["A2"].ProfileUUID)
|
||||
|
|
@ -9173,7 +9174,7 @@ func testDeleteMDMProfilesCancelsInstalls(t *testing.T, ds *Datastore) {
|
|||
forceSetAppleHostProfileStatus(t, ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToProf["A3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
|
||||
// enqueue the corresponding command for the installed profile
|
||||
cmdUUID = uuid.New().String()
|
||||
err = commander.InstallProfile(ctx, []string{host1.UUID}, appleProfs[2].Mobileconfig, cmdUUID)
|
||||
err = commander.InstallProfile(ctx, []string{host1.UUID}, appleProfs[2].Mobileconfig, cmdUUID, "")
|
||||
require.NoError(t, err)
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET command_uuid = ? WHERE host_uuid = ? AND profile_uuid = ?`, cmdUUID, host1.UUID, profNameToProf["A3"].ProfileUUID)
|
||||
|
|
@ -9394,6 +9395,155 @@ func forceSetAndroidHostProfileStatus(t *testing.T, ds *Datastore, hostUUID stri
|
|||
})
|
||||
}
|
||||
|
||||
func testEnqueueCommandWithName(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
SetTestABMAssets(t, ds, "fleet")
|
||||
|
||||
// Create and enroll a macOS host
|
||||
macH, err := ds.NewHost(ctx, &fleet.Host{
|
||||
Hostname: "test-host-cmd-name",
|
||||
OsqueryHostID: new("osquery-macos-cmd-name"),
|
||||
NodeKey: new("node-key-macos-cmd-name"),
|
||||
UUID: uuid.NewString(),
|
||||
Platform: "darwin",
|
||||
HardwareSerial: "CMDNAME123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
nanoEnroll(t, ds, macH, false)
|
||||
|
||||
commander, mdmStorage := createMDMAppleCommanderAndStorage(t, ds)
|
||||
|
||||
// Test 1: InstallProfile with a name
|
||||
cmdUUID1 := uuid.New().String()
|
||||
mc := mobileconfig.Mobileconfig(fmt.Appendf(nil, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array/>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Test Profile</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.test.profile</string>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>%s</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>`, uuid.New().String()))
|
||||
err = commander.InstallProfile(ctx, []string{macH.UUID}, mc, cmdUUID1, "Test Profile Name")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify name in DB
|
||||
var storedName sql.NullString
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &storedName, `SELECT name FROM nano_commands WHERE command_uuid = ?`, cmdUUID1)
|
||||
})
|
||||
require.True(t, storedName.Valid)
|
||||
require.Equal(t, "Test Profile Name", storedName.String)
|
||||
|
||||
// Also verify via ListMDMCommands
|
||||
cmds, _, _, err := ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cmds, 1)
|
||||
require.NotNil(t, cmds[0].Name)
|
||||
require.Equal(t, "Test Profile Name", *cmds[0].Name)
|
||||
|
||||
// Test 2: EnqueueCommand without a name
|
||||
cmdUUID2 := uuid.New().String()
|
||||
rawCmd := createRawAppleCmd("ProfileList", cmdUUID2)
|
||||
err = commander.EnqueueCommand(ctx, []string{macH.UUID}, rawCmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify name is NULL in DB
|
||||
var storedName2 sql.NullString
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &storedName2, `SELECT name FROM nano_commands WHERE command_uuid = ?`, cmdUUID2)
|
||||
})
|
||||
require.False(t, storedName2.Valid)
|
||||
|
||||
// Verify name is null in the API
|
||||
// Verify ListMDMCommands also exposes nil Name for unnamed commands
|
||||
cmds, _, _, err = ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cmds, 2)
|
||||
|
||||
gotByUUID := make(map[string]*fleet.MDMCommand, len(cmds))
|
||||
for _, c := range cmds {
|
||||
gotByUUID[c.CommandUUID] = c
|
||||
}
|
||||
require.Equal(t, "Test Profile Name", *gotByUUID[cmdUUID1].Name)
|
||||
require.Nil(t, gotByUUID[cmdUUID2].Name)
|
||||
|
||||
// Test 3: Name exactly 255 characters — should NOT be truncated
|
||||
cmdUUID3 := uuid.New().String()
|
||||
name255 := strings.Repeat("a", 255)
|
||||
rawXML3 := createRawAppleCmd("InstallProfile", cmdUUID3)
|
||||
decodedCmd3, err := mdm.DecodeCommand([]byte(rawXML3))
|
||||
require.NoError(t, err)
|
||||
_, err = mdmStorage.EnqueueCommand(ctx, []string{macH.UUID}, &mdm.CommandWithSubtype{
|
||||
Command: *decodedCmd3,
|
||||
Subtype: mdm.CommandSubtypeNone,
|
||||
Name: name255,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var storedName3 sql.NullString
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &storedName3, `SELECT name FROM nano_commands WHERE command_uuid = ?`, cmdUUID3)
|
||||
})
|
||||
require.True(t, storedName3.Valid)
|
||||
require.Equal(t, name255, storedName3.String)
|
||||
require.Len(t, []rune(storedName3.String), 255)
|
||||
|
||||
// Test 4: Name longer than 255 ASCII characters — should be truncated to 255
|
||||
cmdUUID4 := uuid.New().String()
|
||||
name300 := strings.Repeat("b", 300)
|
||||
rawXML4 := createRawAppleCmd("InstallProfile", cmdUUID4)
|
||||
decodedCmd4, err := mdm.DecodeCommand([]byte(rawXML4))
|
||||
require.NoError(t, err)
|
||||
_, err = mdmStorage.EnqueueCommand(ctx, []string{macH.UUID}, &mdm.CommandWithSubtype{
|
||||
Command: *decodedCmd4,
|
||||
Subtype: mdm.CommandSubtypeNone,
|
||||
Name: name300,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var storedName4 sql.NullString
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &storedName4, `SELECT name FROM nano_commands WHERE command_uuid = ?`, cmdUUID4)
|
||||
})
|
||||
require.True(t, storedName4.Valid)
|
||||
require.Equal(t, strings.Repeat("b", 255), storedName4.String)
|
||||
require.Len(t, []rune(storedName4.String), 255)
|
||||
|
||||
// Test 5: Name with multi-byte utf8mb4 characters exceeding 255 — should truncate to 255 runes (not bytes)
|
||||
cmdUUID5 := uuid.New().String()
|
||||
// Each emoji is a single rune but 4 bytes in utf8mb4. Build a 300-rune string.
|
||||
nameMultibyte := strings.Repeat("🍎", 300)
|
||||
require.Len(t, []rune(nameMultibyte), 300)
|
||||
rawXML5 := createRawAppleCmd("InstallProfile", cmdUUID5)
|
||||
decodedCmd5, err := mdm.DecodeCommand([]byte(rawXML5))
|
||||
require.NoError(t, err)
|
||||
_, err = mdmStorage.EnqueueCommand(ctx, []string{macH.UUID}, &mdm.CommandWithSubtype{
|
||||
Command: *decodedCmd5,
|
||||
Subtype: mdm.CommandSubtypeNone,
|
||||
Name: nameMultibyte,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var storedName5 sql.NullString
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.GetContext(ctx, q, &storedName5, `SELECT name FROM nano_commands WHERE command_uuid = ?`, cmdUUID5)
|
||||
})
|
||||
require.True(t, storedName5.Valid)
|
||||
require.Equal(t, strings.Repeat("🍎", 255), storedName5.String)
|
||||
require.Len(t, []rune(storedName5.String), 255)
|
||||
}
|
||||
|
||||
func testCleanUpMDMManagedCertificates(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260409153713, Down_20260409153713)
|
||||
}
|
||||
|
||||
func Up_20260409153713(tx *sql.Tx) error {
|
||||
if !columnExists(tx, "nano_commands", "name") {
|
||||
_, err := tx.Exec(`
|
||||
ALTER TABLE nano_commands ADD COLUMN name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add nano_commands.name column: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate the view to include the new name column
|
||||
_, err := tx.Exec(`
|
||||
CREATE OR REPLACE SQL SECURITY INVOKER VIEW nano_view_queue AS
|
||||
SELECT
|
||||
q.id COLLATE utf8mb4_unicode_ci AS id,
|
||||
q.created_at,
|
||||
q.active,
|
||||
q.priority,
|
||||
c.command_uuid COLLATE utf8mb4_unicode_ci AS command_uuid,
|
||||
c.request_type COLLATE utf8mb4_unicode_ci AS request_type,
|
||||
c.command COLLATE utf8mb4_unicode_ci AS command,
|
||||
c.name AS name,
|
||||
r.updated_at AS result_updated_at,
|
||||
r.status COLLATE utf8mb4_unicode_ci AS status,
|
||||
r.result COLLATE utf8mb4_unicode_ci AS result
|
||||
FROM
|
||||
nano_enrollment_queue AS q
|
||||
|
||||
INNER JOIN nano_commands AS c
|
||||
ON q.command_uuid = c.command_uuid
|
||||
|
||||
LEFT JOIN nano_command_results r
|
||||
ON r.command_uuid = q.command_uuid AND r.id = q.id
|
||||
ORDER BY
|
||||
q.priority DESC,
|
||||
q.created_at;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to recreate nano_view_queue with name column: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260409153713(_ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -30,8 +30,8 @@ var (
|
|||
)
|
||||
|
||||
type MDMAppleCommandIssuer interface {
|
||||
InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string) error
|
||||
RemoveProfile(ctx context.Context, hostUUIDs []string, identifier string, uuid string) error
|
||||
InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string, name string) error
|
||||
RemoveProfile(ctx context.Context, hostUUIDs []string, identifier string, uuid string, name string) error
|
||||
DeviceLock(ctx context.Context, host *Host, uuid string) (unlockPIN string, err error)
|
||||
EnableLostMode(ctx context.Context, host *Host, commandUUID string, orgName string) error
|
||||
DisableLostMode(ctx context.Context, host *Host, commandUUID string) error
|
||||
|
|
@ -631,6 +631,8 @@ type MDMAppleCommand struct {
|
|||
// to authorize the user to see the command, it is not returned as part of
|
||||
// the response payload.
|
||||
TeamID *uint `json:"-" db:"team_id"`
|
||||
// Name is the optional human-readable name of the command, used for indicating the profile added/removed, if any.
|
||||
Name *string `json:"name" db:"name"`
|
||||
}
|
||||
|
||||
// MDMAppleSetupAssistant represents the setup assistant set for a given team
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
type CmdTarget struct {
|
||||
CmdUUID string
|
||||
ProfileIdentifier string
|
||||
ProfileName string
|
||||
EnrollmentIDs []string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -374,6 +374,8 @@ type MDMCommandResult struct {
|
|||
Hostname string `json:"hostname" db:"-"`
|
||||
// Payload is the contents of the command
|
||||
Payload []byte `json:"payload" db:"payload"`
|
||||
// Name is the optional human-readable name of the command, currently used for profile name when adding/removing
|
||||
Name *string `json:"name" db:"name"`
|
||||
// ResultsMetadata contains command-specific metadata.
|
||||
// VPP install commands include a "software_installed" boolean and
|
||||
// "vpp_verify_timeout_seconds" integer.
|
||||
|
|
@ -400,6 +402,8 @@ type MDMCommand struct {
|
|||
// to authorize the user to see the command, it is not returned as part of
|
||||
// the response payload.
|
||||
TeamID *uint `json:"-" db:"team_id"`
|
||||
// Name is the optional human-readable name of the command, currently used for profile name when adding/removing
|
||||
Name *string `json:"name" db:"name"`
|
||||
// CommandStatus is the fleet computed field representing the status of the command
|
||||
// based on the MDM protocol status
|
||||
CommandStatus MDMCommandStatusFilter `json:"command_status" db:"command_status"`
|
||||
|
|
|
|||
|
|
@ -45,12 +45,16 @@ func NewMDMAppleCommander(mdmStorage fleet.MDMAppleStore, mdmPushService nanomdm
|
|||
|
||||
// InstallProfile sends the homonymous MDM command to the given hosts, it also
|
||||
// takes care of the base64 encoding of the provided profile bytes.
|
||||
func (svc *MDMAppleCommander) InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string) error {
|
||||
func (svc *MDMAppleCommander) InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string, name string) error {
|
||||
raw, err := svc.SignAndEncodeInstallProfile(ctx, profile, uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = svc.EnqueueCommand(ctx, hostUUIDs, raw)
|
||||
cmd, err := mdm.DecodeCommand([]byte(raw))
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "decoding InstallProfile command")
|
||||
}
|
||||
err = svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone, name)
|
||||
return ctxerr.Wrap(ctx, err, "commander install profile")
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +84,7 @@ func (svc *MDMAppleCommander) SignAndEncodeInstallProfile(ctx context.Context, p
|
|||
}
|
||||
|
||||
// RemoveProfile sends the homonymous MDM command to the given hosts.
|
||||
func (svc *MDMAppleCommander) RemoveProfile(ctx context.Context, hostUUIDs []string, profileIdentifier string, uuid string) error {
|
||||
func (svc *MDMAppleCommander) RemoveProfile(ctx context.Context, hostUUIDs []string, profileIdentifier string, uuid string, name string) error {
|
||||
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
|
|
@ -96,7 +100,11 @@ func (svc *MDMAppleCommander) RemoveProfile(ctx context.Context, hostUUIDs []str
|
|||
</dict>
|
||||
</dict>
|
||||
</plist>`, uuid, profileIdentifier)
|
||||
err := svc.EnqueueCommand(ctx, hostUUIDs, raw)
|
||||
cmd, err := mdm.DecodeCommand([]byte(raw))
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "decoding RemoveProfile command")
|
||||
}
|
||||
err = svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone, name)
|
||||
return ctxerr.Wrap(ctx, err, "commander remove profile")
|
||||
}
|
||||
|
||||
|
|
@ -193,7 +201,7 @@ func (svc *MDMAppleCommander) EnableLostMode(ctx context.Context, host *fleet.Ho
|
|||
|
||||
cmd, err := mdm.DecodeCommand([]byte(raw))
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "decoding command")
|
||||
return ctxerr.Wrap(ctx, err, "decoding EnableLostMode command")
|
||||
}
|
||||
|
||||
if err := svc.storage.EnqueueDeviceLockCommand(ctx, host, cmd, ""); err != nil {
|
||||
|
|
@ -263,7 +271,7 @@ func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, host *fleet.Host,
|
|||
|
||||
cmd, err := mdm.DecodeCommand([]byte(raw))
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "decoding command")
|
||||
return ctxerr.Wrap(ctx, err, "decoding DeviceWipe command")
|
||||
}
|
||||
|
||||
if err := svc.storage.EnqueueDeviceWipeCommand(ctx, host, cmd); err != nil {
|
||||
|
|
@ -516,7 +524,7 @@ func (svc *MDMAppleCommander) ClearPasscode(ctx context.Context, hostUUIDs []str
|
|||
}
|
||||
cmd.Command.RequestType = fleet.AppleMDMCommandTypeClearPasscode
|
||||
|
||||
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone)
|
||||
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone, "")
|
||||
}
|
||||
|
||||
// EnqueueCommand takes care of enqueuing the commands and sending push
|
||||
|
|
@ -531,14 +539,14 @@ func (svc *MDMAppleCommander) EnqueueCommand(ctx context.Context, hostUUIDs []st
|
|||
return ctxerr.Wrap(ctx, err, "decoding command")
|
||||
}
|
||||
|
||||
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone)
|
||||
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone, "")
|
||||
}
|
||||
|
||||
func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs []string, cmd *mdm.Command,
|
||||
subtype mdm.CommandSubtype,
|
||||
subtype mdm.CommandSubtype, name string,
|
||||
) error {
|
||||
if _, err := svc.storage.EnqueueCommand(ctx, hostUUIDs,
|
||||
&mdm.CommandWithSubtype{Command: *cmd, Subtype: subtype}); err != nil {
|
||||
&mdm.CommandWithSubtype{Command: *cmd, Subtype: subtype, Name: name}); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "enqueuing command")
|
||||
}
|
||||
|
||||
|
|
@ -551,7 +559,7 @@ func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs []
|
|||
// EnqueueCommandInstallProfileWithSecrets is a special case of EnqueueCommand that does not expand secret variables.
|
||||
// Secret variables are expanded when the command is sent to the device, and secrets are never stored in the database unencrypted.
|
||||
func (svc *MDMAppleCommander) EnqueueCommandInstallProfileWithSecrets(ctx context.Context, hostUUIDs []string,
|
||||
rawCommand mobileconfig.Mobileconfig, commandUUID string,
|
||||
rawCommand mobileconfig.Mobileconfig, commandUUID string, name string,
|
||||
) error {
|
||||
cmd := &mdm.Command{
|
||||
CommandUUID: commandUUID,
|
||||
|
|
@ -559,7 +567,7 @@ func (svc *MDMAppleCommander) EnqueueCommandInstallProfileWithSecrets(ctx contex
|
|||
}
|
||||
cmd.Command.RequestType = "InstallProfile"
|
||||
|
||||
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeProfileWithSecrets)
|
||||
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeProfileWithSecrets, name)
|
||||
}
|
||||
|
||||
func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs []string) error {
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ func TestMDMAppleCommander(t *testing.T) {
|
|||
}
|
||||
|
||||
cmdUUID := uuid.New().String()
|
||||
err := cmdr.InstallProfile(ctx, hostUUIDs, mc, cmdUUID)
|
||||
err := cmdr.InstallProfile(ctx, hostUUIDs, mc, cmdUUID, "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
|
||||
mdmStorage.EnqueueCommandFuncInvoked = false
|
||||
|
|
@ -118,7 +118,7 @@ func TestMDMAppleCommander(t *testing.T) {
|
|||
return nil, nil
|
||||
}
|
||||
cmdUUID = uuid.New().String()
|
||||
err = cmdr.RemoveProfile(ctx, hostUUIDs, payloadIdentifier, cmdUUID)
|
||||
err = cmdr.RemoveProfile(ctx, hostUUIDs, payloadIdentifier, cmdUUID, "")
|
||||
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
|
||||
mdmStorage.EnqueueCommandFuncInvoked = false
|
||||
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
|
||||
|
|
@ -673,3 +673,140 @@ func TestMDMAppleCommanderClearPasscode(t *testing.T) {
|
|||
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
|
||||
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
|
||||
}
|
||||
|
||||
func TestMDMAppleCommanderPassesCommandName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mdmStorage := &mdmmock.MDMAppleStore{}
|
||||
pushFactory, _ := newMockAPNSPushProviderFactory()
|
||||
pusher := nanomdm_pushsvc.New(
|
||||
mdmStorage,
|
||||
mdmStorage,
|
||||
pushFactory,
|
||||
stdlogfmt.New(),
|
||||
)
|
||||
cmdr := NewMDMAppleCommander(mdmStorage, pusher)
|
||||
|
||||
hostUUIDs := []string{"A"}
|
||||
payloadName := "com.foo.bar"
|
||||
payloadIdentifier := "com-foo-bar"
|
||||
mc := mobileconfigForTest(payloadName, payloadIdentifier)
|
||||
|
||||
mdmStorage.RetrievePushInfoFunc = func(p0 context.Context, targetUUIDs []string) (map[string]*mdm.Push, error) {
|
||||
pushes := make(map[string]*mdm.Push, len(targetUUIDs))
|
||||
for _, uuid := range targetUUIDs {
|
||||
pushes[uuid] = &mdm.Push{
|
||||
PushMagic: "magic" + uuid,
|
||||
Token: []byte("token" + uuid),
|
||||
Topic: "topic" + uuid,
|
||||
}
|
||||
}
|
||||
return pushes, nil
|
||||
}
|
||||
mdmStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) {
|
||||
cert, err := tls.LoadX509KeyPair("../../service/testdata/server.pem", "../../service/testdata/server.key")
|
||||
return &cert, "", err
|
||||
}
|
||||
mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
mdmStorage.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
|
||||
_ sqlx.QueryerContext,
|
||||
) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
|
||||
certPEM, err := os.ReadFile("../../service/testdata/server.pem")
|
||||
require.NoError(t, err)
|
||||
keyPEM, err := os.ReadFile("../../service/testdata/server.key")
|
||||
require.NoError(t, err)
|
||||
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
|
||||
fleet.MDMAssetCACert: {Value: certPEM},
|
||||
fleet.MDMAssetCAKey: {Value: keyPEM},
|
||||
}, nil
|
||||
}
|
||||
|
||||
t.Run("InstallProfile with a name", func(t *testing.T) {
|
||||
var gotName string
|
||||
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
|
||||
gotName = cmd.Name
|
||||
return nil, nil
|
||||
}
|
||||
mdmStorage.EnqueueCommandFuncInvoked = false
|
||||
|
||||
cmdUUID := uuid.New().String()
|
||||
err := cmdr.InstallProfile(ctx, hostUUIDs, mc, cmdUUID, "My Profile")
|
||||
require.NoError(t, err)
|
||||
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
|
||||
require.Equal(t, "My Profile", gotName)
|
||||
})
|
||||
|
||||
t.Run("InstallProfile without a name", func(t *testing.T) {
|
||||
var gotName string
|
||||
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
|
||||
gotName = cmd.Name
|
||||
return nil, nil
|
||||
}
|
||||
mdmStorage.EnqueueCommandFuncInvoked = false
|
||||
|
||||
cmdUUID := uuid.New().String()
|
||||
err := cmdr.InstallProfile(ctx, hostUUIDs, mc, cmdUUID, "")
|
||||
require.NoError(t, err)
|
||||
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
|
||||
require.Empty(t, gotName)
|
||||
})
|
||||
|
||||
t.Run("RemoveProfile with a name", func(t *testing.T) {
|
||||
var gotName string
|
||||
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
|
||||
gotName = cmd.Name
|
||||
return nil, nil
|
||||
}
|
||||
mdmStorage.EnqueueCommandFuncInvoked = false
|
||||
|
||||
cmdUUID := uuid.New().String()
|
||||
err := cmdr.RemoveProfile(ctx, hostUUIDs, payloadIdentifier, cmdUUID, "My Profile")
|
||||
require.NoError(t, err)
|
||||
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
|
||||
require.Equal(t, "My Profile", gotName)
|
||||
})
|
||||
|
||||
t.Run("EnqueueCommand no name", func(t *testing.T) {
|
||||
var gotName string
|
||||
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
|
||||
gotName = cmd.Name
|
||||
return nil, nil
|
||||
}
|
||||
mdmStorage.EnqueueCommandFuncInvoked = false
|
||||
|
||||
cmdUUID := uuid.New().String()
|
||||
rawCmd := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CommandUUID</key>
|
||||
<string>%s</string>
|
||||
<key>Command</key>
|
||||
<dict>
|
||||
<key>RequestType</key>
|
||||
<string>ProfileList</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>`, cmdUUID)
|
||||
err := cmdr.EnqueueCommand(ctx, hostUUIDs, rawCmd)
|
||||
require.NoError(t, err)
|
||||
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
|
||||
require.Empty(t, gotName)
|
||||
})
|
||||
|
||||
t.Run("EnqueueCommandInstallProfileWithSecrets with a name", func(t *testing.T) {
|
||||
var gotName string
|
||||
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
|
||||
gotName = cmd.Name
|
||||
return nil, nil
|
||||
}
|
||||
mdmStorage.EnqueueCommandFuncInvoked = false
|
||||
|
||||
cmdUUID := uuid.New().String()
|
||||
err := cmdr.EnqueueCommandInstallProfileWithSecrets(ctx, hostUUIDs, mc, cmdUUID, "Secret Profile")
|
||||
require.NoError(t, err)
|
||||
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
|
||||
require.Equal(t, "Secret Profile", gotName)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,14 +97,15 @@ func ProcessAndEnqueueProfiles(ctx context.Context,
|
|||
switch op {
|
||||
case fleet.MDMOperationTypeInstall:
|
||||
if _, ok := profilesWithSecrets[profUUID]; ok {
|
||||
err = commander.EnqueueCommandInstallProfileWithSecrets(ctx, target.EnrollmentIDs, profileContents[profUUID], target.CmdUUID)
|
||||
err = commander.EnqueueCommandInstallProfileWithSecrets(ctx, target.EnrollmentIDs, profileContents[profUUID], target.CmdUUID, target.ProfileName)
|
||||
} else {
|
||||
err = commander.InstallProfile(ctx, target.EnrollmentIDs, profileContents[profUUID], target.CmdUUID)
|
||||
err = commander.InstallProfile(ctx, target.EnrollmentIDs, profileContents[profUUID], target.CmdUUID, target.ProfileName)
|
||||
}
|
||||
case fleet.MDMOperationTypeRemove:
|
||||
err = commander.RemoveProfile(ctx, target.EnrollmentIDs, target.ProfileIdentifier, target.CmdUUID)
|
||||
err = commander.RemoveProfile(ctx, target.EnrollmentIDs, target.ProfileIdentifier, target.CmdUUID, target.ProfileName)
|
||||
}
|
||||
|
||||
// Determine whether the command was enqueued (even if push notification failed).
|
||||
var e *APNSDeliveryError
|
||||
switch {
|
||||
case errors.As(err, &e):
|
||||
|
|
@ -117,6 +118,7 @@ func ProcessAndEnqueueProfiles(ctx context.Context,
|
|||
default:
|
||||
ch <- remoteResult{nil, target.CmdUUID}
|
||||
}
|
||||
|
||||
}
|
||||
for profUUID, target := range installTargets {
|
||||
wgProd.Add(1)
|
||||
|
|
@ -630,6 +632,7 @@ func preprocessProfileContents(
|
|||
addedTargets[tempProfUUID] = &fleet.CmdTarget{
|
||||
CmdUUID: tempCmdUUID,
|
||||
ProfileIdentifier: target.ProfileIdentifier,
|
||||
ProfileName: target.ProfileName,
|
||||
EnrollmentIDs: []string{enrollmentID},
|
||||
}
|
||||
profileContents[tempProfUUID] = mobileconfig.Mobileconfig(hostContents)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ type Command struct {
|
|||
type CommandWithSubtype struct {
|
||||
Command
|
||||
Subtype CommandSubtype
|
||||
Name string
|
||||
}
|
||||
|
||||
// DecodeCommand unmarshals rawCommand into command
|
||||
|
|
|
|||
|
|
@ -18,10 +18,18 @@ func enqueue(ctx context.Context, tx sqlx.ExtContext, ids []string, cmd *mdm.Com
|
|||
if len(ids) < 1 {
|
||||
return errors.New("no id(s) supplied to queue command to")
|
||||
}
|
||||
var nameArg sql.NullString
|
||||
if cmd.Name != "" {
|
||||
name := cmd.Name
|
||||
if runes := []rune(name); len(runes) > 255 {
|
||||
name = string(runes[:255])
|
||||
}
|
||||
nameArg = sql.NullString{String: name, Valid: true}
|
||||
}
|
||||
_, err := tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO nano_commands (command_uuid, request_type, command, subtype) VALUES (?, ?, ?, ?)`,
|
||||
cmd.CommandUUID, cmd.Command.Command.RequestType, cmd.Raw, cmd.Subtype,
|
||||
`INSERT INTO nano_commands (command_uuid, request_type, command, subtype, name) VALUES (?, ?, ?, ?, ?)`,
|
||||
cmd.CommandUUID, cmd.Command.Command.RequestType, cmd.Raw, cmd.Subtype, nameArg,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ CREATE TABLE nano_commands (
|
|||
updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
|
||||
subtype ENUM('None','ProfileWithSecrets') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'None',
|
||||
name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
|
||||
PRIMARY KEY (command_uuid),
|
||||
|
||||
|
|
@ -231,6 +232,7 @@ SELECT
|
|||
c.command_uuid,
|
||||
c.request_type,
|
||||
c.command,
|
||||
c.name,
|
||||
r.updated_at AS result_updated_at,
|
||||
r.status,
|
||||
r.result
|
||||
|
|
|
|||
|
|
@ -2330,7 +2330,7 @@ func (svc *Service) enqueueMDMAppleCommandRemoveEnrollmentProfile(ctx context.Co
|
|||
}
|
||||
|
||||
cmdUUID := uuid.New().String()
|
||||
err = svc.mdmAppleCommander.RemoveProfile(ctx, []string{nanoEnroll.ID}, apple_mdm.FleetPayloadIdentifier, cmdUUID)
|
||||
err = svc.mdmAppleCommander.RemoveProfile(ctx, []string{nanoEnroll.ID}, apple_mdm.FleetPayloadIdentifier, cmdUUID, "")
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "enqueuing mdm apple remove profile command")
|
||||
}
|
||||
|
|
@ -5262,6 +5262,7 @@ func ReconcileAppleProfiles(
|
|||
target = &fleet.CmdTarget{
|
||||
CmdUUID: uuid.New().String(),
|
||||
ProfileIdentifier: p.ProfileIdentifier,
|
||||
ProfileName: p.ProfileName,
|
||||
}
|
||||
installTargets[p.ProfileUUID] = target
|
||||
}
|
||||
|
|
@ -5365,6 +5366,7 @@ func ReconcileAppleProfiles(
|
|||
target = &fleet.CmdTarget{
|
||||
CmdUUID: uuid.New().String(),
|
||||
ProfileIdentifier: p.ProfileIdentifier,
|
||||
ProfileName: p.ProfileName,
|
||||
}
|
||||
removeTargets[p.ProfileUUID] = target
|
||||
}
|
||||
|
|
@ -5697,7 +5699,7 @@ func RenewSCEPCertificates(
|
|||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts without enroll reference")
|
||||
}
|
||||
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, filteredAssocs, profile); err != nil {
|
||||
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, filteredAssocs, profile, appConfig.OrgInfo.OrgName+" enrollment"); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "sending profile to hosts without associations")
|
||||
}
|
||||
}
|
||||
|
|
@ -5738,7 +5740,7 @@ func RenewSCEPCertificates(
|
|||
}
|
||||
|
||||
// each host with association needs a different enrollment profile, and thus a different command.
|
||||
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, []fleet.SCEPIdentityAssociation{assoc}, profile); err != nil {
|
||||
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, []fleet.SCEPIdentityAssociation{assoc}, profile, appConfig.OrgInfo.OrgName+" account driven enrollment"); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "sending account driven enrollment profile renewal to hosts")
|
||||
}
|
||||
}
|
||||
|
|
@ -5767,7 +5769,7 @@ func RenewSCEPCertificates(
|
|||
}
|
||||
|
||||
// each host with association needs a different enrollment profile, and thus a different command.
|
||||
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, []fleet.SCEPIdentityAssociation{assoc}, profile); err != nil {
|
||||
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, []fleet.SCEPIdentityAssociation{assoc}, profile, appConfig.OrgInfo.OrgName+" enrollment"); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "sending profile to hosts without associations")
|
||||
}
|
||||
}
|
||||
|
|
@ -5805,7 +5807,7 @@ func RenewSCEPCertificates(
|
|||
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts requiring ACME renewal")
|
||||
}
|
||||
|
||||
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, []fleet.SCEPIdentityAssociation{assoc}, profile); err != nil {
|
||||
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, []fleet.SCEPIdentityAssociation{assoc}, profile, appConfig.OrgInfo.OrgName+" ACME enrollment"); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "sending ACME enrollment profile to hosts")
|
||||
}
|
||||
}
|
||||
|
|
@ -5823,7 +5825,7 @@ func RenewSCEPCertificates(
|
|||
}
|
||||
if migrationEnrollmentProfile != "" && hasAssocsFromMigration {
|
||||
profileBytes := []byte(migrationEnrollmentProfile)
|
||||
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, assocsFromMigration, profileBytes); err != nil {
|
||||
if err := renewMDMAppleEnrollmentProfile(ctx, ds, commander, logger, assocsFromMigration, profileBytes, appConfig.OrgInfo.OrgName+" migration enrollment"); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "sending profile to hosts from migration")
|
||||
}
|
||||
}
|
||||
|
|
@ -5838,6 +5840,7 @@ func renewMDMAppleEnrollmentProfile(
|
|||
logger *slog.Logger,
|
||||
assocs []fleet.SCEPIdentityAssociation,
|
||||
profile []byte,
|
||||
profileName string,
|
||||
) error {
|
||||
cmdUUID := uuid.NewString()
|
||||
var uuids []string
|
||||
|
|
@ -5857,7 +5860,7 @@ func renewMDMAppleEnrollmentProfile(
|
|||
uuids = append(uuids, assoc.HostUUID)
|
||||
}
|
||||
|
||||
if err := commander.InstallProfile(ctx, uuids, profile, cmdUUID); err != nil {
|
||||
if err := commander.InstallProfile(ctx, uuids, profile, cmdUUID, profileName); err != nil {
|
||||
return ctxerr.Wrapf(ctx, err, "sending InstallProfile command for hosts %s", uuids)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4638,6 +4638,7 @@ func TestRenewSCEPCertificatesBranches(t *testing.T) {
|
|||
error,
|
||||
) {
|
||||
require.Equal(t, "InstallProfile", cmd.Command.Command.RequestType)
|
||||
require.Equal(t, "fl33t enrollment", cmd.Name)
|
||||
wantCommandUUID = cmd.CommandUUID
|
||||
return map[string]error{}, nil
|
||||
}
|
||||
|
|
@ -4681,6 +4682,7 @@ func TestRenewSCEPCertificatesBranches(t *testing.T) {
|
|||
error,
|
||||
) {
|
||||
require.Equal(t, "InstallProfile", cmd.Command.Command.RequestType)
|
||||
require.Equal(t, "fl33t enrollment", cmd.Name)
|
||||
wantCommandUUID = cmd.CommandUUID
|
||||
return map[string]error{}, nil
|
||||
}
|
||||
|
|
@ -4740,6 +4742,7 @@ func TestRenewSCEPCertificatesBranches(t *testing.T) {
|
|||
error,
|
||||
) {
|
||||
require.Equal(t, "InstallProfile", cmd.Command.Command.RequestType)
|
||||
require.Equal(t, "fl33t account driven enrollment", cmd.Name)
|
||||
require.Equal(t, 1, len(id))
|
||||
_, idAlreadyExists := wantCommandUUIDs[id[0]]
|
||||
// Should only get one for each host
|
||||
|
|
@ -4794,6 +4797,7 @@ func TestRenewSCEPCertificatesBranches(t *testing.T) {
|
|||
error,
|
||||
) {
|
||||
require.Equal(t, "InstallProfile", cmd.Command.Command.RequestType)
|
||||
require.Equal(t, "fl33t account driven enrollment", cmd.Name)
|
||||
require.Equal(t, 1, len(id))
|
||||
_, idAlreadyExists := wantCommandUUIDs[id[0]]
|
||||
// Should only get one for each host
|
||||
|
|
@ -4985,6 +4989,7 @@ func TestRenewACMECertificatesBranches(t *testing.T) {
|
|||
}}, nil
|
||||
}
|
||||
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
|
||||
require.Equal(t, "fl33t enrollment", cmd.Name)
|
||||
return map[string]error{}, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
|
|
@ -5018,6 +5023,7 @@ func TestRenewACMECertificatesBranches(t *testing.T) {
|
|||
}
|
||||
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
|
||||
require.Equal(t, "InstallProfile", cmd.Command.Command.RequestType)
|
||||
require.Equal(t, "fl33t ACME enrollment", cmd.Name)
|
||||
wantCommandUUID = cmd.CommandUUID
|
||||
// Verify the profile is an ACME profile by checking it contains the device serial
|
||||
var fullCmd micromdm.CommandPayload
|
||||
|
|
@ -5064,6 +5070,7 @@ func TestRenewACMECertificatesBranches(t *testing.T) {
|
|||
}
|
||||
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
|
||||
require.Equal(t, "InstallProfile", cmd.Command.Command.RequestType)
|
||||
require.Equal(t, "fl33t ACME enrollment", cmd.Name)
|
||||
wantCommandUUID = cmd.CommandUUID
|
||||
var fullCmd micromdm.CommandPayload
|
||||
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
|
||||
|
|
@ -5142,6 +5149,14 @@ func TestRenewACMECertificatesBranches(t *testing.T) {
|
|||
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
|
||||
require.Equal(t, "InstallProfile", cmd.Command.Command.RequestType)
|
||||
for _, hostUUID := range id {
|
||||
switch hostUUID {
|
||||
case "hostUUID-acme":
|
||||
require.Equal(t, "fl33t ACME enrollment", cmd.Name)
|
||||
case "hostUUID-scep":
|
||||
require.Equal(t, "fl33t enrollment", cmd.Name)
|
||||
default:
|
||||
require.Failf(t, "Unexpected host UUID", "Unexpected host UUID: %s", hostUUID)
|
||||
}
|
||||
enqueuedHostUUIDs[hostUUID] = true
|
||||
}
|
||||
return map[string]error{}, nil
|
||||
|
|
|
|||
|
|
@ -725,6 +725,7 @@ func (a *AppleMDM) installProfilesForEnrollingHost(ctx context.Context, hostUUID
|
|||
target := &fleet.CmdTarget{
|
||||
CmdUUID: uuid.NewString(),
|
||||
ProfileIdentifier: profile.ProfileIdentifier,
|
||||
ProfileName: profile.ProfileName,
|
||||
EnrollmentIDs: []string{hostUUID},
|
||||
}
|
||||
installTargets[profile.ProfileUUID] = target
|
||||
|
|
|
|||
Loading…
Reference in a new issue