Remote wipe: add API endpoint and activity (#17060)

This commit is contained in:
Martin Angers 2024-02-26 11:31:00 -05:00 committed by GitHub
parent 5d20ee85fc
commit a01241ec2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1216 additions and 226 deletions

View File

@ -0,0 +1 @@
* Added the `POST /api/v1/fleet/hosts/:id/wipe` Fleet Premium API endpoint to support remote wiping a host.

View File

@ -360,7 +360,7 @@ func TestGetHosts(t *testing.T) {
}, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}

View File

@ -196,7 +196,7 @@ func TestMDMRunCommand(t *testing.T) {
ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) {
return nil, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) {
@ -440,11 +440,13 @@ func TestMDMLockCommand(t *testing.T) {
ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) {
return nil, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
fleetPlatform := host.FleetPlatform()
var status fleet.HostLockWipeStatus
status.HostFleetPlatform = fleetPlatform
if _, ok := unlockPending[hostID]; ok {
if _, ok := unlockPending[host.ID]; ok {
if fleetPlatform == "darwin" {
status.UnlockPIN = "1234"
status.UnlockRequestedAt = time.Now()
@ -454,7 +456,7 @@ func TestMDMLockCommand(t *testing.T) {
status.UnlockScript = &fleet.HostScriptResult{}
}
if _, ok := lockPending[hostID]; ok {
if _, ok := lockPending[host.ID]; ok {
if fleetPlatform == "darwin" {
status.LockMDMCommand = &fleet.MDMCommand{}
return &status, nil
@ -710,10 +712,12 @@ func TestMDMUnlockCommand(t *testing.T) {
ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) {
return nil, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
fleetPlatform := host.FleetPlatform()
var status fleet.HostLockWipeStatus
status.HostFleetPlatform = fleetPlatform
if _, ok := locked[hostID]; ok {
if _, ok := locked[host.ID]; ok {
if fleetPlatform == "darwin" {
status.LockMDMCommand = &fleet.MDMCommand{}
status.LockMDMCommandResult = &fleet.MDMCommandResult{Status: fleet.MDMAppleStatusAcknowledged}
@ -723,7 +727,7 @@ func TestMDMUnlockCommand(t *testing.T) {
status.LockScript = &fleet.HostScriptResult{ExitCode: ptr.Int64(0)}
}
if _, ok := unlockPending[hostID]; ok {
if _, ok := unlockPending[host.ID]; ok {
if fleetPlatform == "darwin" {
status.UnlockPIN = "1234"
status.UnlockRequestedAt = time.Now()
@ -733,7 +737,7 @@ func TestMDMUnlockCommand(t *testing.T) {
status.UnlockScript = &fleet.HostScriptResult{}
}
if _, ok := lockPending[hostID]; ok {
if _, ok := lockPending[host.ID]; ok {
if fleetPlatform == "darwin" {
status.LockMDMCommand = &fleet.MDMCommand{}
return &status, nil

View File

@ -240,7 +240,7 @@ Fleet records the last 10,000 characters to prevent downtime.
}
return &fleet.HostScriptResult{}, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, req *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {

View File

@ -1032,6 +1032,23 @@ This activity contains the following fields:
}
```
## wiped_host
Generated when a user sends a request to wipe a host.
This activity contains the following fields:
- "host_id": ID of the host.
- "host_display_name": Display name of the host.
#### Example
```json
{
"host_id": 1,
"host_display_name": "Anna's MacBook Pro"
}
```
<meta name="title" value="Audit logs">
<meta name="pageOrderInSection" value="1400">

View File

@ -0,0 +1,46 @@
#!/bin/sh
# Function to log out all users and lock their passwords except root
logout_users() {
for user in $(who | awk '{print $1}' | sort | uniq)
do
if [ "$user" != "root" ]; then
echo "Logging out $user"
pkill -KILL -u "$user"
passwd -l "$user"
fi
done
}
# Function to wipe non-essential data
wipe_non_essential_data() {
# Define non-essential paths
non_essential_paths="/home/* /tmp /var/tmp /var/log /home/*/.cache /var/cache /home/*/.local/share/Trash"
for path in $non_essential_paths
do
if [ -e "$path" ]; then
echo "Wiping $path"
rm -rf "$path"
fi
done
}
# Function to wipe system files - Warning: This will render the system inoperable
wipe_system_files() {
# Define essential system paths
essential_system_paths="/bin /sbin /usr /lib"
for path in $essential_system_paths
do
echo "Wiping $path"
rm -rf "$path"
done
}
# Start the wiping process
logout_users
wipe_non_essential_data
wipe_system_files
echo "Wiping process completed."

View File

@ -3,6 +3,7 @@ package service
import (
"context"
_ "embed"
"errors"
"fmt"
"net/http"
"time"
@ -55,21 +56,22 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error {
return err
}
// TODO(mna): error messages are subtly different in the figma for CLI and
// UI, they should be the same as they come from the same place (the API).
// I used the CLI messages for the implementation.
// locking validations are based on the platform of the host
switch host.FleetPlatform() {
case "darwin":
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
err := fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
if errors.Is(err, fleet.ErrMDMNotConfigured) {
err = fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
}
return ctxerr.Wrap(ctx, err, "check macOS MDM enabled")
}
// on macOS, the lock command requires the host to be MDM-enrolled in Fleet
hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID)
if err != nil {
if fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock the host because it doesn't have MDM turned on."))
}
return ctxerr.Wrap(ctx, err, "get host MDM information")
}
if !hostMDM.IsFleetEnrolled() {
@ -79,7 +81,9 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error {
case "windows", "linux":
if host.FleetPlatform() == "windows" {
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
err := fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
if errors.Is(err, fleet.ErrMDMNotConfigured) {
err = fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
}
return ctxerr.Wrap(ctx, err, "check windows MDM enabled")
}
}
@ -94,13 +98,12 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error {
}
default:
// TODO(mna): should we allow/treat ChromeOS as Linux for this purpose?
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform)))
}
// if there's a lock, unlock or wipe action pending, do not accept the lock
// request.
lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform())
lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host)
if err != nil {
return ctxerr.Wrap(ctx, err, "get host lock/wipe status")
}
@ -148,7 +151,9 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error)
// be enabled
if host.FleetPlatform() == "windows" {
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
err := fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
if errors.Is(err, fleet.ErrMDMNotConfigured) {
err = fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
}
return "", ctxerr.Wrap(ctx, err, "check windows MDM enabled")
}
}
@ -161,11 +166,10 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error)
}
default:
// TODO(mna): should we allow/treat ChromeOS as Linux for this purpose?
return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform)))
}
lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform())
lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "get host lock/wipe status")
}
@ -190,6 +194,95 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error)
return svc.enqueueUnlockHostRequest(ctx, host, lockWipe)
}
func (svc *Service) WipeHost(ctx context.Context, hostID uint) error {
// First ensure the user has access to list hosts, then check the specific
// host once team_id is loaded.
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return err
}
host, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get host lite")
}
// Authorize again with team loaded now that we have the host's team_id.
// Authorize as "execute mdm_command", which is the correct access
// requirement and is what happens for macOS platforms.
if err := svc.authz.Authorize(ctx, fleet.MDMCommandAuthz{TeamID: host.TeamID}, fleet.ActionWrite); err != nil {
return err
}
// wipe validations are based on the platform of the host, Windows and macOS
// require MDM to be enabled and the host to be MDM-enrolled in Fleet. Linux
// uses scripts, not MDM.
var requireMDM bool
switch host.FleetPlatform() {
case "darwin":
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
if errors.Is(err, fleet.ErrMDMNotConfigured) {
err = fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
}
return ctxerr.Wrap(ctx, err, "check macOS MDM enabled")
}
requireMDM = true
case "windows":
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
if errors.Is(err, fleet.ErrMDMNotConfigured) {
err = fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
}
return ctxerr.Wrap(ctx, err, "check windows MDM enabled")
}
requireMDM = true
case "linux":
// on linux, a script is used to wipe the host so scripts must be enabled
appCfg, err := svc.ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "get app config")
}
if appCfg.ServerSettings.ScriptsDisabled {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't wipe host because running scripts is disabled in organization settings."))
}
default:
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform)))
}
if requireMDM {
// the wipe command requires the host to be MDM-enrolled in Fleet
hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID)
if err != nil {
if fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't wipe the host because it doesn't have MDM turned on."))
}
return ctxerr.Wrap(ctx, err, "get host MDM information")
}
if !hostMDM.IsFleetEnrolled() {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't wipe the host because it doesn't have MDM turned on."))
}
}
// validations based on host's actions status (pending lock, unlock, wipe)
lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host)
if err != nil {
return ctxerr.Wrap(ctx, err, "get host lock/wipe status")
}
switch {
case lockWipe.IsPendingLock():
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending lock request. Host cannot be wiped until lock is complete."))
case lockWipe.IsPendingUnlock():
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending unlock request. Host cannot be wiped until unlock is complete."))
case lockWipe.IsPendingWipe():
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending wipe request. The host will be wiped when it comes online."))
case lockWipe.IsWiped():
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is already wiped.").WithStatus(http.StatusConflict))
}
// all good, go ahead with queuing the wipe request.
return svc.enqueueWipeHostRequest(ctx, host, lockWipe)
}
func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host, lockStatus *fleet.HostLockWipeStatus) error {
vc, ok := viewer.FromContext(ctx)
if !ok {
@ -301,6 +394,59 @@ func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Ho
return unlockPIN, nil
}
func (svc *Service) enqueueWipeHostRequest(ctx context.Context, host *fleet.Host, wipeStatus *fleet.HostLockWipeStatus) error {
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
switch wipeStatus.HostFleetPlatform {
case "darwin":
wipeCommandUUID := uuid.NewString()
if err := svc.mdmAppleCommander.EraseDevice(ctx, host, wipeCommandUUID); err != nil {
return ctxerr.Wrap(ctx, err, "enqueuing wipe request for darwin")
}
case "windows":
wipeCmdUUID := uuid.NewString()
wipeCmd := &fleet.MDMWindowsCommand{
CommandUUID: wipeCmdUUID,
RawCommand: []byte(fmt.Sprintf(windowsWipeCommand, wipeCmdUUID)),
TargetLocURI: "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected",
}
if err := svc.ds.WipeHostViaWindowsMDM(ctx, host, wipeCmd); err != nil {
return ctxerr.Wrap(ctx, err, "enqueuing wipe request for windows")
}
case "linux":
// TODO(mna): svc.RunHostScript should be refactored so that we can reuse the
// part starting with the validation of the script (just in case), the checks
// that we don't enqueue over the limit, etc. for any other important
// validation we may add over there and that we bypass here by enqueueing the
// script directly in the datastore layer.
if err := svc.ds.WipeHostViaScript(ctx, &fleet.HostScriptRequestPayload{
HostID: host.ID,
ScriptContents: string(linuxWipeScript),
UserID: &vc.User.ID,
SyncRequest: false,
}); err != nil {
return err
}
}
if err := svc.ds.NewActivity(
ctx,
vc.User,
fleet.ActivityTypeWipedHost{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for wipe host request")
}
return nil
}
// TODO(mna): ideally we'd embed the scripts from the scripts/mdm/windows/..
// and scripts/mdm/linux/.. directories where they currently exist, but this is
// not possible (not a Go package) and I don't know if those script locations
@ -316,4 +462,21 @@ var (
linuxLockScript []byte
//go:embed embedded_scripts/linux_unlock.sh
linuxUnlockScript []byte
//go:embed embedded_scripts/linux_wipe.sh
linuxWipeScript []byte
windowsWipeCommand = `
<Exec>
<CmdID>%s</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/RemoteWipe/doWipeProtected</LocURI>
</Target>
<Meta>
<Format xmlns="syncml:metinf">chr</Format>
<Type>text/plain</Type>
</Meta>
<Data></Data>
</Item>
</Exec>`
)

View File

@ -139,14 +139,7 @@ func (svc *Service) MDMAppleEraseDevice(ctx context.Context, hostID uint) error
return err
}
// TODO: save the pin (first return value) in the database
// TODO(mna): same here for when we implement the Wipe story, assuming this
// implementation (which is for the deprecated /mdm/hosts/:id/wipe endpoint)
// should work as the new endpoint, then this should call
// svc.enqueueWipeHostRequest so that it behaves like the new endpoint. And
// yes, we do need to save the generated PIN so the EraseDevice method
// signature must change to return it.
err = svc.mdmAppleCommander.EraseDevice(ctx, []string{host.UUID}, uuid.New().String())
err = svc.mdmAppleCommander.EraseDevice(ctx, host, uuid.New().String())
if err != nil {
return err
}

View File

@ -67,7 +67,7 @@ func TestMDMApple(t *testing.T) {
{"TestMDMAppleConfigProfileHash", testMDMAppleConfigProfileHash},
{"TestResetMDMAppleEnrollment", testResetMDMAppleEnrollment},
{"TestMDMAppleDeleteHostDEPAssignments", testMDMAppleDeleteHostDEPAssignments},
{"CleanMacOSMDMLock", testCleanMacOSMDMLock},
{"LockUnlockWipeMacOS", testLockUnlockWipeMacOS},
}
for _, c := range cases {
@ -4402,18 +4402,9 @@ func testMDMAppleDeleteHostDEPAssignments(t *testing.T, ds *Datastore) {
}
}
func testCleanMacOSMDMLock(t *testing.T, ds *Datastore) {
func testLockUnlockWipeMacOS(t *testing.T, ds *Datastore) {
ctx := context.Background()
checkState := func(t *testing.T, status *fleet.HostLockWipeStatus, unlocked, locked, wiped, pendingUnlock, pendingLock, pendingWipe bool) {
require.Equal(t, unlocked, status.IsUnlocked())
require.Equal(t, locked, status.IsLocked())
require.Equal(t, wiped, status.IsWiped())
require.Equal(t, pendingLock, status.IsPendingLock())
require.Equal(t, pendingUnlock, status.IsPendingUnlock())
require.Equal(t, pendingWipe, status.IsPendingWipe())
}
host, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "test-host1-name",
OsqueryHostID: ptr.String("1337"),
@ -4425,11 +4416,11 @@ func testCleanMacOSMDMLock(t *testing.T, ds *Datastore) {
require.NoError(t, err)
nanoEnroll(t, ds, host, false)
status, err := ds.GetHostLockWipeStatus(ctx, host.ID, "macos")
status, err := ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
// default state
checkState(t, status, true, false, false, false, false, false)
checkLockWipeState(t, status, true, false, false, false, false, false)
appleStore, err := ds.NewMDMAppleMDMStorage(nil, nil)
require.NoError(t, err)
@ -4443,18 +4434,117 @@ func testCleanMacOSMDMLock(t *testing.T, ds *Datastore) {
err = appleStore.EnqueueDeviceLockCommand(ctx, host, cmd, "123456")
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform())
// it is now pending lock
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkState(t, status, true, false, false, false, true, false)
checkLockWipeState(t, status, true, false, false, false, true, false)
// record a command result to simulate locked state
err = appleStore.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: host.UUID},
Context: ctx,
}, &mdm.CommandResults{
CommandUUID: cmd.CommandUUID,
Status: "Acknowledged",
RequestType: "DeviceLock",
Raw: cmd.Raw,
})
require.NoError(t, err)
err = ds.UpdateHostLockWipeStatusFromAppleMDMResult(ctx, host.UUID, cmd.CommandUUID, "DeviceLock", true)
require.NoError(t, err)
// it is now locked
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkLockWipeState(t, status, false, true, false, false, false, false)
// request an unlock, to make it pending unlock
err = ds.UnlockHostManually(ctx, host.ID, time.Now().UTC())
require.NoError(t, err)
// it is now locked pending unlock
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkLockWipeState(t, status, false, true, false, true, false, false)
// execute CleanMacOSMDMLock to simulate successful unlock
err = ds.CleanMacOSMDMLock(ctx, host.UUID)
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, host.ID, "macos")
// it is back to unlocked state
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkState(t, status, true, false, false, false, false, false)
checkLockWipeState(t, status, true, false, false, false, false, false)
require.Empty(t, status.UnlockPIN)
// record a request to wipe the host
cmd = &mdm.Command{
CommandUUID: uuid.NewString(),
Raw: []byte("<?xml"),
}
cmd.Command.RequestType = "EraseDevice"
err = appleStore.EnqueueDeviceWipeCommand(ctx, host, cmd)
require.NoError(t, err)
// it is now pending wipe
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, true)
// record a command result failure to simulate failed wipe (back to unlocked)
err = appleStore.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: host.UUID},
Context: ctx,
}, &mdm.CommandResults{
CommandUUID: cmd.CommandUUID,
Status: "Error",
RequestType: cmd.Command.RequestType,
Raw: cmd.Raw,
})
require.NoError(t, err)
err = ds.UpdateHostLockWipeStatusFromAppleMDMResult(ctx, host.UUID, cmd.CommandUUID, cmd.Command.RequestType, false)
require.NoError(t, err)
// it is back to unlocked
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, false)
// record a new request to wipe the host
cmd = &mdm.Command{
CommandUUID: uuid.NewString(),
Raw: []byte("<?xml"),
}
cmd.Command.RequestType = "EraseDevice"
err = appleStore.EnqueueDeviceWipeCommand(ctx, host, cmd)
require.NoError(t, err)
// it is back to pending wipe
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, true)
// record a command result success to simulate wipe
err = appleStore.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: host.UUID},
Context: ctx,
}, &mdm.CommandResults{
CommandUUID: cmd.CommandUUID,
Status: "Acknowledged",
RequestType: cmd.Command.RequestType,
Raw: cmd.Raw,
})
require.NoError(t, err)
err = ds.UpdateHostLockWipeStatusFromAppleMDMResult(ctx, host.UUID, cmd.CommandUUID, cmd.Command.RequestType, true)
require.NoError(t, err)
// it is wiped
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkLockWipeState(t, status, false, false, true, false, false, false)
}
func TestMDMAppleProfileVerification(t *testing.T) {

View File

@ -779,8 +779,6 @@ const hostMDMSelect = `,
) mdm_host_data
`
// TODO(mna): add integration tests with Get host with locked/wiped/unlocked (+pending) to ensure proper marshaling.
// hostMDMJoin is the SQL fragment used to join MDM-related tables to the hosts table. It is a
// dependency of the hostMDMSelect fragment.
const hostMDMJoin = `

View File

@ -142,28 +142,32 @@ func (ds *Datastore) MDMWindowsInsertCommandForHosts(ctx context.Context, hostUU
}
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// first, create the command entry
stmt := `
INSERT INTO windows_mdm_commands (command_uuid, raw_command, target_loc_uri)
VALUES (?, ?, ?)
`
if _, err := tx.ExecContext(ctx, stmt, cmd.CommandUUID, cmd.RawCommand, cmd.TargetLocURI); err != nil {
if isDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsCommand", cmd.CommandUUID))
}
return ctxerr.Wrap(ctx, err, "inserting MDMWindowsCommand")
}
// create the command execution queue entries, one per host
for _, hostUUIDOrDeviceID := range hostUUIDsOrDeviceIDs {
if err := ds.mdmWindowsInsertHostCommandDB(ctx, tx, hostUUIDOrDeviceID, cmd.CommandUUID); err != nil {
return err
}
}
return nil
return ds.mdmWindowsInsertCommandForHostsDB(ctx, tx, hostUUIDsOrDeviceIDs, cmd)
})
}
func (ds *Datastore) mdmWindowsInsertCommandForHostsDB(ctx context.Context, tx sqlx.ExecerContext, hostUUIDsOrDeviceIDs []string, cmd *fleet.MDMWindowsCommand) error {
// first, create the command entry
stmt := `
INSERT INTO windows_mdm_commands (command_uuid, raw_command, target_loc_uri)
VALUES (?, ?, ?)
`
if _, err := tx.ExecContext(ctx, stmt, cmd.CommandUUID, cmd.RawCommand, cmd.TargetLocURI); err != nil {
if isDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsCommand", cmd.CommandUUID))
}
return ctxerr.Wrap(ctx, err, "inserting MDMWindowsCommand")
}
// create the command execution queue entries, one per host
for _, hostUUIDOrDeviceID := range hostUUIDsOrDeviceIDs {
if err := ds.mdmWindowsInsertHostCommandDB(ctx, tx, hostUUIDOrDeviceID, cmd.CommandUUID); err != nil {
return err
}
}
return nil
}
func (ds *Datastore) mdmWindowsInsertHostCommandDB(ctx context.Context, tx sqlx.ExecerContext, hostUUIDOrDeviceID, commandUUID string) error {
stmt := `
INSERT INTO windows_mdm_command_queue (enrollment_id, command_uuid)
@ -228,20 +232,20 @@ func (ds *Datastore) MDMWindowsSaveResponse(ctx context.Context, deviceID string
return ctxerr.New(ctx, "empty raw response")
}
const findCommandsStmt = `SELECT command_uuid, raw_command FROM windows_mdm_commands WHERE command_uuid IN (?)`
const (
findCommandsStmt = `SELECT command_uuid, raw_command, target_loc_uri FROM windows_mdm_commands WHERE command_uuid IN (?)`
saveFullRespStmt = `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)`
dequeueCommandsStmt = `DELETE FROM windows_mdm_command_queue WHERE command_uuid IN (?)`
const saveFullRespStmt = `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)`
const dequeueCommandsStmt = `DELETE FROM windows_mdm_command_queue WHERE command_uuid IN (?)`
const insertResultsStmt = `
insertResultsStmt = `
INSERT INTO windows_mdm_command_results
(enrollment_id, command_uuid, raw_result, response_id, status_code)
VALUES %s
ON DUPLICATE KEY UPDATE
raw_result = COALESCE(VALUES(raw_result), raw_result),
status_code = COALESCE(VALUES(status_code), status_code)
`
`
)
enrollment, err := ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID)
if err != nil {
@ -301,9 +305,15 @@ ON DUPLICATE KEY UPDATE
// for all the matching UUIDs, try to find any <Status> or
// <Result> entries to track them as responses.
var args []any
var sb strings.Builder
var potentialProfilePayloads []*fleet.MDMWindowsProfilePayload
var (
args []any
sb strings.Builder
potentialProfilePayloads []*fleet.MDMWindowsProfilePayload
wipeCmdUUID string
wipeCmdStatus string
)
for _, cmd := range matchingCmds {
statusCode := ""
if status, ok := uuidsToStatus[cmd.CommandUUID]; ok && status.Data != nil {
@ -327,6 +337,13 @@ ON DUPLICATE KEY UPDATE
}
args = append(args, enrollment.ID, cmd.CommandUUID, rawResult, responseID, statusCode)
sb.WriteString("(?, ?, ?, ?, ?),")
// if the command is a Wipe, keep track of it so we can update
// host_mdm_actions accordingly.
if strings.Contains(cmd.TargetLocURI, "/Device/Vendor/MSFT/RemoteWipe/") {
wipeCmdUUID = cmd.CommandUUID
wipeCmdStatus = statusCode
}
}
if err := updateMDMWindowsHostProfileStatusFromResponseDB(ctx, tx, potentialProfilePayloads); err != nil {
@ -339,6 +356,14 @@ ON DUPLICATE KEY UPDATE
return ctxerr.Wrap(ctx, err, "inserting command results")
}
// if we received a Wipe command result, update the host's status
if wipeCmdUUID != "" {
if err := updateHostLockWipeStatusFromResultAndHostUUID(ctx, tx, enrollment.HostUUID,
"wipe_ref", wipeCmdUUID, strings.HasPrefix(wipeCmdStatus, "2")); err != nil {
return ctxerr.Wrap(ctx, err, "updating wipe command result in host_mdm_actions")
}
}
// dequeue the commands
var matchingUUIDs []string
for _, cmd := range matchingCmds {
@ -1874,3 +1899,26 @@ host_uuid = ? AND profile_name NOT IN(?) AND NOT (operation_type = '%s' AND COAL
}
return profiles, nil
}
func (ds *Datastore) WipeHostViaWindowsMDM(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error {
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
if err := ds.mdmWindowsInsertCommandForHostsDB(ctx, tx, []string{host.UUID}, cmd); err != nil {
return err
}
stmt := `
INSERT INTO host_mdm_actions (
host_id,
wipe_ref
)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE
wipe_ref = VALUES(wipe_ref)`
if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID); err != nil {
return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for wipe_ref")
}
return nil
})
}

View File

@ -1266,7 +1266,8 @@ func testMDMWindowsCommandResults(t *testing.T, ds *Datastore) {
require.Empty(t, results)
}
func windowsEnroll(t *testing.T, ds fleet.Datastore, h *fleet.Host) {
// enrolls the host in Windows MDM and returns the device's enrollment ID.
func windowsEnroll(t *testing.T, ds fleet.Datastore, h *fleet.Host) string {
ctx := context.Background()
d1 := &fleet.MDMWindowsEnrolledDevice{
MDMDeviceID: uuid.New().String(),
@ -1285,6 +1286,7 @@ func windowsEnroll(t *testing.T, ds fleet.Datastore, h *fleet.Host) {
require.NoError(t, err)
err = ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, d1.HostUUID, d1.MDMDeviceID)
require.NoError(t, err)
return d1.MDMDeviceID
}
func testMDMWindowsProfileManagement(t *testing.T, ds *Datastore) {

View File

@ -1188,8 +1188,6 @@ func insertOnDuplicateDidUpdate(res sql.Result) bool {
// time of the Exec call, and the result simply returns the integers it
// already holds:
// https://github.com/go-sql-driver/mysql/blob/bcc459a906419e2890a50fc2c99ea6dd927a88f2/result.go
//
// TODO(mna): would that work on mariadb too?
lastID, _ := res.LastInsertId()
aff, _ := res.RowsAffected()

View File

@ -92,20 +92,18 @@ func (s *NanoMDMStorage) EnqueueDeviceLockCommand(
return err
}
// TODO(roberto): call @mna's transactionable method to update
// these tables when it's ready.
stmt := `
INSERT INTO host_mdm_actions (
host_id,
lock_ref,
unlock_pin
)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
wipe_ref = NULL,
unlock_ref = NULL,
INSERT INTO host_mdm_actions (
host_id,
lock_ref,
unlock_pin
)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
wipe_ref = NULL,
unlock_ref = NULL,
unlock_pin = VALUES(unlock_pin),
lock_ref = VALUES(lock_ref)`
lock_ref = VALUES(lock_ref)`
if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, pin); err != nil {
return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for DeviceLock")
@ -115,6 +113,30 @@ func (s *NanoMDMStorage) EnqueueDeviceLockCommand(
}, s.logger)
}
// EnqueueDeviceWipeCommand enqueues a EraseDevice command for the given host.
func (s *NanoMDMStorage) EnqueueDeviceWipeCommand(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error {
return withRetryTxx(ctx, s.db, func(tx sqlx.ExtContext) error {
if err := enqueueCommandDB(ctx, tx, []string{host.UUID}, cmd); err != nil {
return err
}
stmt := `
INSERT INTO host_mdm_actions (
host_id,
wipe_ref
)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE
wipe_ref = VALUES(wipe_ref)`
if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID); err != nil {
return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for DeviceWipe")
}
return nil
}, s.logger)
}
// NewMDMAppleDEPStorage returns a MySQL nanodep storage that uses the Datastore
// underlying MySQL writer *sql.DB.
func (ds *Datastore) NewMDMAppleDEPStorage(tok nanodep_client.OAuth1Tokens) (*NanoDEPStorage, error) {

View File

@ -78,7 +78,7 @@ func testEnqueueDeviceLockCommand(t *testing.T, ds *Datastore) {
},
}, res)
status, err := ds.GetHostLockWipeStatus(ctx, host.ID, "darwin")
status, err := ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
require.Equal(t, "cmd-uuid", status.LockMDMCommand.CommandUUID)
require.Equal(t, "123456", status.UnlockPIN)

View File

@ -138,7 +138,7 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f
return ctxerr.Wrap(ctx, err, "lookup host script corresponding mdm action")
}
if refCol != "" {
err = ds.updateHostLockWipeStatusFromResult(ctx, tx, result.HostID, refCol, result.ExitCode == 0)
err = updateHostLockWipeStatusFromResult(ctx, tx, result.HostID, refCol, result.ExitCode == 0)
if err != nil {
return ctxerr.Wrap(ctx, err, "update host mdm action based on script result")
}
@ -557,7 +557,7 @@ ON DUPLICATE KEY UPDATE
})
}
func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
const stmt = `
SELECT
lock_ref,
@ -576,11 +576,12 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fle
UnlockRef *string `db:"unlock_ref"`
UnlockPIN *string `db:"unlock_pin"`
}
fleetPlatform := host.FleetPlatform()
status := &fleet.HostLockWipeStatus{
HostFleetPlatform: fleetPlatform,
}
if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmActions, stmt, hostID); err != nil {
if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmActions, stmt, host.ID); err != nil {
if err == sql.ErrNoRows {
// do not return a Not Found error, return the zero-value status, which
// will report the correct states.
@ -608,34 +609,22 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fle
if mdmActions.LockRef != nil {
// the lock reference is an MDM command
cmd, err := ds.getMDMCommand(ctx, ds.reader(ctx), *mdmActions.LockRef)
cmd, cmdRes, err := ds.getHostMDMAppleCommand(ctx, *mdmActions.LockRef, host.UUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get lock reference MDM command")
return nil, ctxerr.Wrap(ctx, err, "get lock reference")
}
status.LockMDMCommand = cmd
status.LockMDMCommandResult = cmdRes
}
// get the MDM command result, which may be not found (indicating the
// command is pending)
cmdRes, err := ds.GetMDMAppleCommandResults(ctx, *mdmActions.LockRef)
if mdmActions.WipeRef != nil {
// the wipe reference is an MDM command
cmd, cmdRes, err := ds.getHostMDMAppleCommand(ctx, *mdmActions.WipeRef, host.UUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get lock reference MDM command result")
}
// TODO: each item in the slice returned by
// GetMDMAppleCommandResults is a result for a
// different host. This only works because we're
// enqueuing the command with the given UUID for a
// single host, but it's the equivalent of doing
// cmdRes[0].
//
// Ideally, and to be super safe, we should try to find
// a command with a matching r.HostUUID, but we don't
// have the host UUID available.
for _, r := range cmdRes {
if r.Status == fleet.MDMAppleStatusAcknowledged || r.Status == fleet.MDMAppleStatusError || r.Status == fleet.MDMAppleStatusCommandFormatError {
status.LockMDMCommandResult = r
break
}
return nil, ctxerr.Wrap(ctx, err, "get wipe reference")
}
status.WipeMDMCommand = cmd
status.WipeMDMCommandResult = cmdRes
}
case "windows", "linux":
@ -655,10 +644,89 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fle
}
status.UnlockScript = hsr
}
// wipe is an MDM command on Windows, a script on Linux
if mdmActions.WipeRef != nil {
if fleetPlatform == "windows" {
cmd, cmdRes, err := ds.getHostMDMWindowsCommand(ctx, *mdmActions.WipeRef, host.UUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get wipe reference")
}
status.WipeMDMCommand = cmd
status.WipeMDMCommandResult = cmdRes
} else {
hsr, err := ds.getHostScriptExecutionResultDB(ctx, ds.reader(ctx), *mdmActions.WipeRef)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get wipe reference script result")
}
status.WipeScript = hsr
}
}
}
return status, nil
}
func (ds *Datastore) getHostMDMWindowsCommand(ctx context.Context, cmdUUID, hostUUID string) (*fleet.MDMCommand, *fleet.MDMCommandResult, error) {
cmd, err := ds.getMDMCommand(ctx, ds.reader(ctx), cmdUUID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get Windows MDM command")
}
// get the MDM command result, which may be not found (indicating the command
// is pending). Note that it doesn't return ErrNoRows if not found, it
// returns success and an empty cmdRes slice.
cmdResults, err := ds.GetMDMWindowsCommandResults(ctx, cmdUUID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get Windows MDM command result")
}
// each item in the slice returned by GetMDMWindowsCommandResults is
// potentially a result for a different host, we need to find the one for
// that specific host.
var cmdRes *fleet.MDMCommandResult
for _, r := range cmdResults {
if r.HostUUID != hostUUID {
continue
}
// all statuses for Windows indicate end of processing of the command
// (there is no equivalent of "NotNow" or "Idle" as for Apple).
cmdRes = r
break
}
return cmd, cmdRes, nil
}
func (ds *Datastore) getHostMDMAppleCommand(ctx context.Context, cmdUUID, hostUUID string) (*fleet.MDMCommand, *fleet.MDMCommandResult, error) {
cmd, err := ds.getMDMCommand(ctx, ds.reader(ctx), cmdUUID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get Apple MDM command")
}
// get the MDM command result, which may be not found (indicating the command
// is pending). Note that it doesn't return ErrNoRows if not found, it
// returns success and an empty cmdRes slice.
cmdResults, err := ds.GetMDMAppleCommandResults(ctx, cmdUUID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get Apple MDM command result")
}
// each item in the slice returned by GetMDMAppleCommandResults is
// potentially a result for a different host, we need to find the one for
// that specific host.
var cmdRes *fleet.MDMCommandResult
for _, r := range cmdResults {
if r.HostUUID != hostUUID {
continue
}
if r.Status == fleet.MDMAppleStatusAcknowledged || r.Status == fleet.MDMAppleStatusError || r.Status == fleet.MDMAppleStatusCommandFormatError {
cmdRes = r
break
}
}
return cmd, cmdRes, nil
}
// LockHostViaScript will create the script execution request and update
// host_mdm_actions in a single transaction.
func (ds *Datastore) LockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error {
@ -740,6 +808,44 @@ func (ds *Datastore) UnlockHostViaScript(ctx context.Context, request *fleet.Hos
})
}
// WipeHostViaScript creates the script execution request and updates the
// host_mdm_actions table in a single transaction.
func (ds *Datastore) WipeHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error {
var res *fleet.HostScriptResult
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
var err error
res, err = newHostScriptExecutionRequest(ctx, request, tx)
if err != nil {
return ctxerr.Wrap(ctx, err, "wipe host via script create execution")
}
// on duplicate we don't clear any other existing state because at this
// point in time, this is just a request to wipe the host that is recorded,
// it is pending execution, so if it was locked, it is still locked (so the
// lock_ref info must still be there).
const stmt = `
INSERT INTO host_mdm_actions
(
host_id,
wipe_ref
)
VALUES (?,?)
ON DUPLICATE KEY UPDATE
wipe_ref = VALUES(wipe_ref)
`
_, err = tx.ExecContext(ctx, stmt,
request.HostID,
res.ExecutionID,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "wipe host via script update mdm actions")
}
return err
})
}
func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, ts time.Time) error {
const stmt = `
INSERT INTO host_mdm_actions
@ -758,16 +864,22 @@ func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, ts tim
// entering a PIN on the device). The /unlock endpoint can be called multiple
// times, so we record the timestamp of the first time it was requested and
// from then on, the host is marked as "pending unlock" until the device is
// actually unlocked with the PIN.
// TODO(mna): to be determined how we then get notified that it has been
// unlocked, so that it can transition to unlocked (not pending).
// actually unlocked with the PIN. The actual unlocking happens when the
// device sends an Idle MDM request.
unlockRef := ts.Format(time.DateTime)
_, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, unlockRef)
return ctxerr.Wrap(ctx, err, "record manual unlock host request")
}
func (ds *Datastore) updateHostLockWipeStatusFromResult(ctx context.Context, tx sqlx.ExtContext, hostID uint, refCol string, succeeded bool) error {
stmt := `UPDATE host_mdm_actions SET %s WHERE host_id = ?`
func buildHostLockWipeStatusUpdateStmt(refCol string, succeeded bool, joinPart string) string {
var alias string
stmt := `UPDATE host_mdm_actions `
if joinPart != "" {
stmt += `hma ` + joinPart
alias = "hma."
}
stmt += ` SET `
if succeeded {
switch refCol {
@ -775,23 +887,49 @@ func (ds *Datastore) updateHostLockWipeStatusFromResult(ctx context.Context, tx
// Note that this must not clear the unlock_pin, because recording the
// lock request does generate the PIN and store it there to be used by an
// eventual unlock.
stmt = fmt.Sprintf(stmt, "unlock_ref = NULL")
stmt += fmt.Sprintf("%sunlock_ref = NULL, %[1]swipe_ref = NULL", alias)
case "unlock_ref":
// a successful unlock clears itself as well as the lock ref, because
// unlock is the default state so we don't need to keep its unlock_ref
// around once it's confirmed.
stmt = fmt.Sprintf(stmt, "lock_ref = NULL, unlock_ref = NULL, unlock_pin = NULL")
stmt += fmt.Sprintf("%slock_ref = NULL, %[1]sunlock_ref = NULL, %[1]sunlock_pin = NULL, %[1]swipe_ref = NULL", alias)
case "wipe_ref":
// TODO(mna): implement when implementing the wipe story
default:
return ctxerr.Errorf(ctx, "unknown reference column %q", refCol)
stmt += fmt.Sprintf("%slock_ref = NULL, %[1]sunlock_ref = NULL, %[1]sunlock_pin = NULL", alias)
}
} else {
// if the action failed, then we clear the reference to that action itself so
// the host stays in the previous state (it doesn't transition to the new
// state).
stmt = fmt.Sprintf(stmt, refCol+" = NULL")
stmt += fmt.Sprintf("%s"+refCol+" = NULL", alias)
}
return stmt
}
func (ds *Datastore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Context, hostUUID, cmdUUID, requestType string, succeeded bool) error {
// a bit of MDM protocol leaking in the mysql layer, but it's either that or
// the other way around (MDM protocol would translate to database column)
var refCol string
switch requestType {
case "EraseDevice":
refCol = "wipe_ref"
case "DeviceLock":
refCol = "lock_ref"
default:
return nil
}
return updateHostLockWipeStatusFromResultAndHostUUID(ctx, ds.writer(ctx), hostUUID, refCol, cmdUUID, succeeded)
}
func updateHostLockWipeStatusFromResultAndHostUUID(ctx context.Context, tx sqlx.ExtContext, hostUUID, refCol, cmdUUID string, succeeded bool) error {
stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, `JOIN hosts h ON hma.host_id = h.id`)
stmt += ` WHERE h.uuid = ? AND hma.` + refCol + ` = ?`
_, err := tx.ExecContext(ctx, stmt, hostUUID, cmdUUID)
return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result via host uuid")
}
func updateHostLockWipeStatusFromResult(ctx context.Context, tx sqlx.ExtContext, hostID uint, refCol string, succeeded bool) error {
stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, "")
stmt += ` WHERE host_id = ?`
_, err := tx.ExecContext(ctx, stmt, hostID)
return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result")
}

View File

@ -11,6 +11,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
@ -29,7 +30,7 @@ func TestScripts(t *testing.T) {
{"BatchSetScripts", testBatchSetScripts},
{"TestLockHostViaScript", testLockHostViaScript},
{"TestUnlockHostViaScript", testUnlockHostViaScript},
{"TestLockUnlockViaScripts", testLockUnlockViaScripts},
{"TestLockUnlockWipeViaScripts", testLockUnlockWipeViaScripts},
{"TestLockUnlockManually", testLockUnlockManually},
}
for _, c := range cases {
@ -735,7 +736,7 @@ func testLockHostViaScript(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// verify that we have created entries in host_mdm_actions and host_script_results
status, err := ds.GetHostLockWipeStatus(ctx, windowsHostID, "windows")
status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: windowsHostID, Platform: "windows", UUID: "uuid"})
require.NoError(t, err)
require.Equal(t, "windows", status.HostFleetPlatform)
require.NotNil(t, status.LockScript)
@ -756,7 +757,7 @@ func testLockHostViaScript(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, windowsHostID, "windows")
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: windowsHostID, Platform: "windows", UUID: "uuid"})
require.NoError(t, err)
require.True(t, status.IsLocked())
require.False(t, status.IsPendingLock())
@ -786,7 +787,7 @@ func testUnlockHostViaScript(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// verify that we have created entries in host_mdm_actions and host_script_results
status, err := ds.GetHostLockWipeStatus(ctx, hostID, "windows")
status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: "windows", UUID: "uuid"})
require.NoError(t, err)
require.Equal(t, "windows", status.HostFleetPlatform)
require.NotNil(t, status.UnlockScript)
@ -807,14 +808,14 @@ func testUnlockHostViaScript(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, hostID, "windows")
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: "windows", UUID: "uuid"})
require.NoError(t, err)
require.True(t, status.IsUnlocked())
require.False(t, status.IsPendingUnlock())
require.False(t, status.IsLocked())
}
func testLockUnlockViaScripts(t *testing.T, ds *Datastore) {
func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) {
ctx := context.Background()
user := test.NewUser(t, ds, "Bob", "bob@example.com", true)
@ -822,7 +823,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) {
hostID := uint(i + 1)
t.Run(platform, func(t *testing.T) {
status, err := ds.GetHostLockWipeStatus(ctx, hostID, platform)
status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
// default state
@ -837,7 +838,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, true, false)
@ -849,7 +850,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, false, true, false, false, false, false)
@ -862,7 +863,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, false, true, false, true, false, false)
@ -875,7 +876,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// still locked
status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, false, true, false, false, false, false)
@ -888,7 +889,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, false, true, false, true, false, false)
@ -901,7 +902,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// host is now unlocked
status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, false)
@ -914,7 +915,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, true, false)
@ -926,9 +927,93 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, false)
switch platform {
case "windows":
// need a real MDM-enrolled host for MDM commands
h, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "test-host-windows",
OsqueryHostID: ptr.String("osquery-windows"),
NodeKey: ptr.String("nodekey-windows"),
UUID: "test-uuid-windows",
Platform: "windows",
})
require.NoError(t, err)
windowsEnroll(t, ds, h)
// record a request to wipe the host
wipeCmdUUID := uuid.NewString()
wipeCmd := &fleet.MDMWindowsCommand{
CommandUUID: wipeCmdUUID,
RawCommand: []byte(`<Exec></Exec>`),
TargetLocURI: "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected",
}
err = ds.WipeHostViaWindowsMDM(ctx, h, wipeCmd)
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, h)
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, true)
// TODO: we don't seem to have an easy way to simulate a Windows MDM
// protocol response, and there are lots of validations happening so we
// can't just send a simple XML. Will test the rest via integration
// tests.
case "linux":
// record a request to wipe the host
err = ds.WipeHostViaScript(ctx, &fleet.HostScriptRequestPayload{
HostID: hostID,
ScriptContents: "wipe",
UserID: &user.ID,
SyncRequest: false,
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, true)
// simulate a failed result for the wipe script execution
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: hostID,
ExecutionID: status.WipeScript.ExecutionID,
ExitCode: 1,
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, false)
// record another request to wipe the host
err = ds.WipeHostViaScript(ctx, &fleet.HostScriptRequestPayload{
HostID: hostID,
ScriptContents: "wipe2",
UserID: &user.ID,
SyncRequest: false,
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, true)
// simulate a successful result for the wipe script execution
_, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{
HostID: hostID,
ExecutionID: status.WipeScript.ExecutionID,
ExitCode: 0,
})
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"})
require.NoError(t, err)
checkLockWipeState(t, status, false, false, true, false, false, false)
}
})
}
}
@ -941,7 +1026,7 @@ func testLockUnlockManually(t *testing.T, ds *Datastore) {
err := ds.UnlockHostManually(ctx, 1, twoDaysAgo)
require.NoError(t, err)
status, err := ds.GetHostLockWipeStatus(ctx, 1, "darwin")
status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: 1, Platform: "darwin", UUID: "uuid"})
require.NoError(t, err)
require.False(t, status.UnlockRequestedAt.IsZero())
require.WithinDuration(t, twoDaysAgo, status.UnlockRequestedAt, 1*time.Second)
@ -950,7 +1035,7 @@ func testLockUnlockManually(t *testing.T, ds *Datastore) {
// requests
err = ds.UnlockHostManually(ctx, 1, today)
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, 1, "darwin")
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: 1, Platform: "darwin", UUID: "uuid"})
require.NoError(t, err)
require.False(t, status.UnlockRequestedAt.IsZero())
require.WithinDuration(t, twoDaysAgo, status.UnlockRequestedAt, 1*time.Second)
@ -963,7 +1048,7 @@ func testLockUnlockManually(t *testing.T, ds *Datastore) {
})
err = ds.UnlockHostManually(ctx, 2, today)
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, 2, "darwin")
status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: 2, Platform: "darwin", UUID: "uuid"})
require.NoError(t, err)
require.False(t, status.UnlockRequestedAt.IsZero())
require.WithinDuration(t, today, status.UnlockRequestedAt, 1*time.Second)

View File

@ -83,6 +83,7 @@ var ActivityDetailsList = []ActivityDetails{
ActivityTypeLockedHost{},
ActivityTypeUnlockedHost{},
ActivityTypeWipedHost{},
}
type ActivityDetails interface {
@ -1234,6 +1235,20 @@ type ActivityTypeEditedWindowsProfile struct {
TeamName *string `json:"team_name"`
}
func (a ActivityTypeEditedWindowsProfile) ActivityName() string {
return "edited_windows_profile"
}
func (a ActivityTypeEditedWindowsProfile) Documentation() (activity, details, detailsExample string) {
return `Generated when a user edits the Windows profiles of a team (or no team) via the fleetctl CLI.`,
`This activity contains the following fields:
- "team_id": The ID of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team.
- "team_name": The name of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team.`, `{
"team_id": 123,
"team_name": "Workstations"
}`
}
type ActivityTypeLockedHost struct {
HostID uint `json:"host_id"`
HostDisplayName string `json:"host_display_name"`
@ -1283,17 +1298,22 @@ func (a ActivityTypeUnlockedHost) Documentation() (activity, details, detailsExa
}`
}
func (a ActivityTypeEditedWindowsProfile) ActivityName() string {
return "edited_windows_profile"
type ActivityTypeWipedHost struct {
HostID uint `json:"host_id"`
HostDisplayName string `json:"host_display_name"`
}
func (a ActivityTypeEditedWindowsProfile) Documentation() (activity, details, detailsExample string) {
return `Generated when a user edits the Windows profiles of a team (or no team) via the fleetctl CLI.`,
func (a ActivityTypeWipedHost) ActivityName() string {
return "wiped_host"
}
func (a ActivityTypeWipedHost) Documentation() (activity, details, detailsExample string) {
return `Generated when a user sends a request to wipe a host.`,
`This activity contains the following fields:
- "team_id": The ID of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team.
- "team_name": The name of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team.`, `{
"team_id": 123,
"team_name": "Workstations"
- "host_id": ID of the host.
- "host_display_name": Display name of the host.`, `{
"host_id": 1,
"host_display_name": "Anna's MacBook Pro"
}`
}

View File

@ -19,7 +19,7 @@ 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
DeviceLock(ctx context.Context, host *Host, uuid string) error
EraseDevice(ctx context.Context, hostUUIDs []string, uuid string) error
EraseDevice(ctx context.Context, host *Host, uuid string) error
InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error
}

View File

@ -1292,7 +1292,7 @@ type Datastore interface {
BatchSetScripts(ctx context.Context, tmID *uint, scripts []*Script) error
// GetHostLockWipeStatus gets the lock/unlock and wipe status for the host.
GetHostLockWipeStatus(ctx context.Context, hostID uint, fleetPlatform string) (*HostLockWipeStatus, error)
GetHostLockWipeStatus(ctx context.Context, host *Host) (*HostLockWipeStatus, error)
// LockHostViaScript sends a script to lock a host and updates the
// states in host_mdm_actions
@ -1310,6 +1310,20 @@ type Datastore interface {
// CleanMacOSMDMLock cleans the lock status and pin for a macOS device
// after it has been unlocked.
CleanMacOSMDMLock(ctx context.Context, hostUUID string) error
// WipeHostViaScript sends a script to wipe a host and updates the
// states in host_mdm_actions.
WipeHostViaScript(ctx context.Context, request *HostScriptRequestPayload) error
// WipeHostViaWindowsMDM sends a Windows MDM command to wipe a host and
// updates the states in host_mdm_actions.
WipeHostViaWindowsMDM(ctx context.Context, host *Host, cmd *MDMWindowsCommand) error
// UpdateHostLockWipeStatusFromAppleMDMResult updates the host_mdm_actions
// table to reflect the result of the corresponding lock/wipe MDM command for
// Apple hosts. It is optimized to update using only the information
// available in the Apple MDM protocol.
UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Context, hostUUID, cmdUUID, requestType string, succeeded bool) error
}
// MDMAppleStore wraps nanomdm's storage and adds methods to deal with
@ -1317,6 +1331,7 @@ type Datastore interface {
type MDMAppleStore interface {
storage.AllStorage
EnqueueDeviceLockCommand(ctx context.Context, host *Host, cmd *mdm.Command, pin string) error
EnqueueDeviceWipeCommand(ctx context.Context, host *Host, cmd *mdm.Command) error
}
// Cloner represents any type that can clone itself. Used for the cached_mysql

View File

@ -192,7 +192,8 @@ type MDMCommandResult struct {
HostUUID string `json:"host_uuid" db:"host_uuid"`
// CommandUUID is the unique identifier of the command.
CommandUUID string `json:"command_uuid" db:"command_uuid"`
// Status is the command status. One of Acknowledged, Error, or NotNow.
// Status is the command status. One of Acknowledged, Error, or NotNow for
// Apple, or 200, 400, etc for Windows.
Status string `json:"status" db:"status"`
// UpdatedAt is the last update timestamp of the command result.
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`

View File

@ -312,7 +312,12 @@ type HostLockWipeStatus struct {
// windows and linux hosts use a script to unlock
UnlockScript *HostScriptResult
// TODO: add wipe status when implementing the Wipe story.
// macOS and Windows use MDM commands for Wipe
WipeMDMCommand *MDMCommand
WipeMDMCommandResult *MDMCommandResult
// Linux uses a script for Wipe
WipeScript *HostScriptResult
}
func (s *HostLockWipeStatus) IsPendingLock() bool {
@ -334,8 +339,12 @@ func (s HostLockWipeStatus) IsPendingUnlock() bool {
}
func (s HostLockWipeStatus) IsPendingWipe() bool {
// TODO(mna): implement when addressing Wipe story, for now wipe is never pending
return false
if s.HostFleetPlatform == "linux" {
// pending wipe if script execution request is queued but no result yet
return s.WipeScript != nil && s.WipeScript.ExitCode == nil
}
// pending wipe if an MDM command is queued but no result received yet
return s.WipeMDMCommand != nil && s.WipeMDMCommandResult == nil
}
func (s HostLockWipeStatus) IsLocked() bool {
@ -359,6 +368,20 @@ func (s HostLockWipeStatus) IsUnlocked() bool {
}
func (s HostLockWipeStatus) IsWiped() bool {
// TODO(mna): implement when addressing Wipe story, for now never wiped
return false
switch s.HostFleetPlatform {
case "linux":
// wiped if script was sent and succeeded
return s.WipeScript != nil && s.WipeScript.ExitCode != nil &&
*s.WipeScript.ExitCode == 0
case "windows":
// wiped if an MDM command was sent and succeeded
return s.WipeMDMCommand != nil && s.WipeMDMCommandResult != nil &&
strings.HasPrefix(s.WipeMDMCommandResult.Status, "2")
case "darwin":
// wiped if an MDM command was sent and succeeded
return s.WipeMDMCommand != nil && s.WipeMDMCommandResult != nil &&
s.WipeMDMCommandResult.Status == MDMAppleStatusAcknowledged
default:
return false
}
}

View File

@ -956,4 +956,5 @@ type Service interface {
// Script-based methods (at least for some platforms, MDM-based for others)
LockHost(ctx context.Context, hostID uint) error
UnlockHost(ctx context.Context, hostID uint) (unlockPIN string, err error)
WipeHost(ctx context.Context, hostID uint) error
}

View File

@ -118,7 +118,7 @@ func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, host *fleet.Host,
return nil
}
func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, hostUUIDs []string, uuid string) error {
func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, host *fleet.Host, uuid string) error {
pin := GenerateRandomPin(6)
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">
@ -132,10 +132,26 @@ func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, hostUUIDs []strin
<string>EraseDevice</string>
<key>PIN</key>
<string>%s</string>
<key>ObliterationBehavior</key>
<string>Default</string>
</dict>
</dict>
</plist>`, uuid, pin)
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
cmd, err := mdm.DecodeCommand([]byte(raw))
if err != nil {
return ctxerr.Wrap(ctx, err, "decoding command")
}
if err := svc.storage.EnqueueDeviceWipeCommand(ctx, host, cmd); err != nil {
return ctxerr.Wrap(ctx, err, "enqueuing for DeviceWipe")
}
if err := svc.sendNotifications(ctx, []string{host.UUID}); err != nil {
return ctxerr.Wrap(ctx, err, "sending notifications for DeviceWipe")
}
return nil
}
func (svc *MDMAppleCommander) InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error {

View File

@ -104,7 +104,7 @@ func TestMDMAppleCommander(t *testing.T) {
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
mdmStorage.RetrievePushInfoFuncInvoked = false
host := &fleet.Host{ID: 1, UUID: "A"}
host := &fleet.Host{ID: 1, UUID: "A", Platform: "darwin"}
cmdUUID = uuid.New().String()
mdmStorage.EnqueueDeviceLockCommandFunc = func(ctx context.Context, gotHost *fleet.Host, cmd *mdm.Command, pin string) error {
require.NotNil(t, gotHost)
@ -112,6 +112,7 @@ func TestMDMAppleCommander(t *testing.T) {
require.Equal(t, host.UUID, gotHost.UUID)
require.Equal(t, "DeviceLock", cmd.Command.RequestType)
require.Contains(t, string(cmd.Raw), cmdUUID)
require.Len(t, pin, 6)
return nil
}
err = cmdr.DeviceLock(ctx, host, cmdUUID)
@ -120,6 +121,22 @@ func TestMDMAppleCommander(t *testing.T) {
mdmStorage.EnqueueDeviceLockCommandFuncInvoked = false
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
mdmStorage.RetrievePushInfoFuncInvoked = false
cmdUUID = uuid.New().String()
mdmStorage.EnqueueDeviceWipeCommandFunc = func(ctx context.Context, gotHost *fleet.Host, cmd *mdm.Command) error {
require.NotNil(t, gotHost)
require.Equal(t, host.ID, gotHost.ID)
require.Equal(t, host.UUID, gotHost.UUID)
require.Equal(t, "EraseDevice", cmd.Command.RequestType)
require.Contains(t, string(cmd.Raw), cmdUUID)
return nil
}
err = cmdr.EraseDevice(ctx, host, cmdUUID)
require.NoError(t, err)
require.True(t, mdmStorage.EnqueueDeviceWipeCommandFuncInvoked)
mdmStorage.EnqueueDeviceWipeCommandFuncInvoked = false
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
mdmStorage.RetrievePushInfoFuncInvoked = false
}
func newMockAPNSPushProviderFactory() (*svcmock.APNSPushProviderFactory, *svcmock.APNSPushProvider) {

View File

@ -56,6 +56,8 @@ type RetrieveTokenUpdateTallyFunc func(ctx context.Context, id string) (int, err
type EnqueueDeviceLockCommandFunc func(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error
type EnqueueDeviceWipeCommandFunc func(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error
type MDMAppleStore struct {
StoreAuthenticateFunc StoreAuthenticateFunc
StoreAuthenticateFuncInvoked bool
@ -120,6 +122,9 @@ type MDMAppleStore struct {
EnqueueDeviceLockCommandFunc EnqueueDeviceLockCommandFunc
EnqueueDeviceLockCommandFuncInvoked bool
EnqueueDeviceWipeCommandFunc EnqueueDeviceWipeCommandFunc
EnqueueDeviceWipeCommandFuncInvoked bool
mu sync.Mutex
}
@ -269,3 +274,10 @@ func (fs *MDMAppleStore) EnqueueDeviceLockCommand(ctx context.Context, host *fle
fs.mu.Unlock()
return fs.EnqueueDeviceLockCommandFunc(ctx, host, cmd, pin)
}
func (fs *MDMAppleStore) EnqueueDeviceWipeCommand(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error {
fs.mu.Lock()
fs.EnqueueDeviceWipeCommandFuncInvoked = true
fs.mu.Unlock()
return fs.EnqueueDeviceWipeCommandFunc(ctx, host, cmd)
}

View File

@ -828,7 +828,7 @@ type GetHostScriptDetailsFunc func(ctx context.Context, hostID uint, teamID *uin
type BatchSetScriptsFunc func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error
type GetHostLockWipeStatusFunc func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error)
type GetHostLockWipeStatusFunc func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error)
type LockHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) error
@ -838,6 +838,12 @@ type UnlockHostManuallyFunc func(ctx context.Context, hostID uint, ts time.Time)
type CleanMacOSMDMLockFunc func(ctx context.Context, hostUUID string) error
type WipeHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) error
type WipeHostViaWindowsMDMFunc func(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error
type UpdateHostLockWipeStatusFromAppleMDMResultFunc func(ctx context.Context, hostUUID string, cmdUUID string, requestType string, succeeded bool) error
type DataStore struct {
HealthCheckFunc HealthCheckFunc
HealthCheckFuncInvoked bool
@ -2069,6 +2075,15 @@ type DataStore struct {
CleanMacOSMDMLockFunc CleanMacOSMDMLockFunc
CleanMacOSMDMLockFuncInvoked bool
WipeHostViaScriptFunc WipeHostViaScriptFunc
WipeHostViaScriptFuncInvoked bool
WipeHostViaWindowsMDMFunc WipeHostViaWindowsMDMFunc
WipeHostViaWindowsMDMFuncInvoked bool
UpdateHostLockWipeStatusFromAppleMDMResultFunc UpdateHostLockWipeStatusFromAppleMDMResultFunc
UpdateHostLockWipeStatusFromAppleMDMResultFuncInvoked bool
mu sync.Mutex
}
@ -4907,11 +4922,11 @@ func (s *DataStore) BatchSetScripts(ctx context.Context, tmID *uint, scripts []*
return s.BatchSetScriptsFunc(ctx, tmID, scripts)
}
func (s *DataStore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
func (s *DataStore) GetHostLockWipeStatus(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
s.mu.Lock()
s.GetHostLockWipeStatusFuncInvoked = true
s.mu.Unlock()
return s.GetHostLockWipeStatusFunc(ctx, hostID, fleetPlatform)
return s.GetHostLockWipeStatusFunc(ctx, host)
}
func (s *DataStore) LockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error {
@ -4941,3 +4956,24 @@ func (s *DataStore) CleanMacOSMDMLock(ctx context.Context, hostUUID string) erro
s.mu.Unlock()
return s.CleanMacOSMDMLockFunc(ctx, hostUUID)
}
func (s *DataStore) WipeHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error {
s.mu.Lock()
s.WipeHostViaScriptFuncInvoked = true
s.mu.Unlock()
return s.WipeHostViaScriptFunc(ctx, request)
}
func (s *DataStore) WipeHostViaWindowsMDM(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error {
s.mu.Lock()
s.WipeHostViaWindowsMDMFuncInvoked = true
s.mu.Unlock()
return s.WipeHostViaWindowsMDMFunc(ctx, host, cmd)
}
func (s *DataStore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Context, hostUUID string, cmdUUID string, requestType string, succeeded bool) error {
s.mu.Lock()
s.UpdateHostLockWipeStatusFromAppleMDMResultFuncInvoked = true
s.mu.Unlock()
return s.UpdateHostLockWipeStatusFromAppleMDMResultFunc(ctx, hostUUID, cmdUUID, requestType, succeeded)
}

View File

@ -2400,6 +2400,13 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
Detail: apple_mdm.FmtErrorChain(cmdResult.ErrorChain),
OperationType: fleet.MDMOperationTypeRemove,
})
case "DeviceLock", "EraseDevice":
// call into our datastore to update host_mdm_actions if the status is terminal
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged ||
cmdResult.Status == fleet.MDMAppleStatusError ||
cmdResult.Status == fleet.MDMAppleStatusCommandFormatError {
return nil, svc.ds.UpdateHostLockWipeStatusFromAppleMDMResult(r.Context, cmdResult.UDID, cmdResult.CommandUUID, requestType, cmdResult.Status == fleet.MDMAppleStatusAcknowledged)
}
}
return nil, nil
}

View File

@ -760,7 +760,7 @@ func TestHostDetailsMDMProfiles(t *testing.T) {
ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) {
return nil, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}

View File

@ -479,6 +479,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/activities", listHostPastActivitiesEndpoint, listHostPastActivitiesRequest{})
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/lock", lockHostEndpoint, lockHostRequest{})
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/unlock", unlockHostEndpoint, unlockHostRequest{})
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/wipe", wipeHostEndpoint, wipeHostRequest{})
// Only Fleet MDM specific endpoints should be within the root /mdm/ path.
// NOTE: remember to update

View File

@ -1093,7 +1093,7 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f
}
host.MDM.MacOSSetup = macOSSetup
mdmActions, err := svc.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform())
mdmActions, err := svc.ds.GetHostLockWipeStatus(ctx, host)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host mdm lock/wipe status")
}
@ -1104,10 +1104,10 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f
host.MDM.PendingAction = ptr.String("")
// device status
switch {
case mdmActions.IsLocked():
host.MDM.DeviceStatus = ptr.String("locked")
case mdmActions.IsWiped():
host.MDM.DeviceStatus = ptr.String("wiped")
case mdmActions.IsLocked():
host.MDM.DeviceStatus = ptr.String("locked")
}
// pending action, if any

View File

@ -67,7 +67,7 @@ func TestHostDetails(t *testing.T) {
ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) {
return dsBats, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
// Health should be replaced at the service layer with custom values determined by the cycle count. See https://github.com/fleetdm/fleet/issues/6763.
@ -108,7 +108,7 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) {
ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) {
return nil, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
@ -385,7 +385,7 @@ func TestHostDetailsOSSettings(t *testing.T) {
ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hid uint) (*fleet.HostMDMMacOSSetup, error) {
return nil, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
@ -497,7 +497,7 @@ func TestHostDetailsOSSettingsWindowsOnly(t *testing.T) {
ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, uuid string) ([]fleet.HostMDMWindowsProfile, error) {
return nil, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
@ -600,7 +600,7 @@ func TestHostAuth(t *testing.T) {
ds.ListHostUpcomingActivitiesFunc = func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) {
return nil, nil, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
@ -1383,7 +1383,7 @@ func TestHostMDMProfileDetail(t *testing.T) {
ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hid uint) (*fleet.HostMDMMacOSSetup, error) {
return nil, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
@ -1445,15 +1445,20 @@ func TestHostMDMProfileDetail(t *testing.T) {
}
}
func TestLockUnlockHostAuth(t *testing.T) {
func TestLockUnlockWipeHostAuth(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
const (
teamHostID = 1
globalHostID = 2
)
teamHost := &fleet.Host{TeamID: ptr.Uint(1), Platform: "darwin"}
globalHost := &fleet.Host{Platform: "darwin"}
ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) {
if identifier == "1" {
if identifier == fmt.Sprint(teamHostID) {
return teamHost, nil
}
@ -1483,14 +1488,14 @@ func TestLockUnlockHostAuth(t *testing.T) {
ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) {
return nil, nil
}
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
ds.LockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) error {
return nil
}
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
if hostID == 1 {
if hostID == teamHostID {
return teamHost, nil
}
@ -1596,25 +1601,30 @@ func TestLockUnlockHostAuth(t *testing.T) {
}
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
err := svc.LockHost(ctx, 2)
err := svc.LockHost(ctx, globalHostID)
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
err = svc.LockHost(ctx, 1)
err = svc.LockHost(ctx, teamHostID)
checkAuthErr(t, tt.shouldFailTeamWrite, err)
// Pretend we locked the host
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{HostFleetPlatform: fleetPlatform, LockMDMCommand: &fleet.MDMCommand{}, LockMDMCommandResult: &fleet.MDMCommandResult{Status: fleet.MDMAppleStatusAcknowledged}}, nil
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{HostFleetPlatform: host.FleetPlatform(), LockMDMCommand: &fleet.MDMCommand{}, LockMDMCommandResult: &fleet.MDMCommandResult{Status: fleet.MDMAppleStatusAcknowledged}}, nil
}
_, err = svc.UnlockHost(ctx, 2)
_, err = svc.UnlockHost(ctx, globalHostID)
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
_, err = svc.UnlockHost(ctx, 1)
_, err = svc.UnlockHost(ctx, teamHostID)
checkAuthErr(t, tt.shouldFailTeamWrite, err)
// Reset so we're now pretending host is unlocked
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) {
ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) {
return &fleet.HostLockWipeStatus{}, nil
}
err = svc.WipeHost(ctx, globalHostID)
checkAuthErr(t, tt.shouldFailGlobalWrite, err)
err = svc.WipeHost(ctx, teamHostID)
checkAuthErr(t, tt.shouldFailTeamWrite, err)
})
}
}

View File

@ -5350,9 +5350,10 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() {
"team_id", "1",
)
// lock/unlock a host
// lock/unlock/wipe a host
s.Do("POST", "/api/v1/fleet/hosts/123/lock", nil, http.StatusPaymentRequired)
s.Do("POST", "/api/v1/fleet/hosts/123/unlock", nil, http.StatusPaymentRequired)
s.Do("POST", "/api/v1/fleet/hosts/123/wipe", nil, http.StatusPaymentRequired)
}
func (s *integrationTestSuite) TestScriptsEndpointsWithoutLicense() {

View File

@ -6866,7 +6866,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() {
}
func (s *integrationEnterpriseTestSuite) TestLockUnlockWindowsLinux() {
func (s *integrationEnterpriseTestSuite) TestLockUnlockWipeWindowsLinux() {
ctx := context.Background()
t := s.T()
@ -6888,19 +6888,22 @@ func (s *integrationEnterpriseTestSuite) TestLockUnlockWindowsLinux() {
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
// try to lock/unlock the Windows host, fails because Windows MDM must be enabled
// try to lock/unlock/wipe the Windows host, fails because Windows MDM must be enabled
res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", winHost.ID), nil, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows MDM isn't turned on.")
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", winHost.ID), nil, http.StatusBadRequest)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows MDM isn't turned on.")
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", winHost.ID), nil, http.StatusBadRequest)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows MDM isn't turned on.")
// try to lock/unlock the Linux host succeeds, no MDM constraints
// try to lock/unlock/wipe the Linux host succeeds, no MDM constraints
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", linuxHost.ID), nil, http.StatusNoContent)
// simulate a successful script result for the lock command
status, err := s.ds.GetHostLockWipeStatus(ctx, linuxHost.ID, linuxHost.FleetPlatform())
status, err := s.ds.GetHostLockWipeStatus(ctx, linuxHost)
require.NoError(t, err)
var orbitScriptResp orbitPostScriptResultResponse
@ -6922,6 +6925,12 @@ func (s *integrationEnterpriseTestSuite) TestLockUnlockWindowsLinux() {
require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "unlock", *getHostResp.Host.MDM.PendingAction)
// attempting to Wipe the linux host fails due to pending unlock, not because
// of MDM not enabled
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", linuxHost.ID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Host cannot be wiped until unlock is complete.")
}
// checks that the specified team/no-team has the Windows OS Updates profile with

View File

@ -11009,12 +11009,15 @@ func (s *integrationMDMTestSuite) TestManualEnrollmentCommands() {
checkInstallFleetdCommandSent(mdmDevice, false)
}
func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() {
func (s *integrationMDMTestSuite) TestLockUnlockWipeWindowsLinux() {
t := s.T()
ctx := context.Background()
// create an MDM-enrolled Windows host
winHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
winHost, winMDMClient := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// set its MDM data so it shows as MDM-enrolled in the backend
err := s.ds.SetOrUpdateMDMData(ctx, winHost.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet, "")
require.NoError(t, err)
linuxHost := createOrbitEnrolledHost(t, "linux", "lock_unlock_linux", s.ds)
for _, host := range []*fleet.Host{winHost, linuxHost} {
@ -11047,7 +11050,7 @@ func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() {
require.Contains(t, errMsg, "Host has pending lock request.")
// simulate a successful script result for the lock command
status, err := s.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform())
status, err := s.ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
var orbitScriptResp orbitPostScriptResultResponse
@ -11081,7 +11084,7 @@ func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() {
require.Contains(t, errMsg, "Host has pending unlock request.")
// simulate a failed script result for the unlock command
status, err = s.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform())
status, err = s.ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
@ -11094,10 +11097,219 @@ func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() {
require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
// unlock the host, simulate success
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusNoContent)
status, err = s.ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, status.UnlockScript.ExecutionID)),
http.StatusOK, &orbitScriptResp)
// refresh the host's status, it is unlocked, no pending action
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
// wipe the host
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusNoContent)
wipeActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0)
// try to wipe the host again, already have it pending
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Host has pending wipe request.")
// no activity created
s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID)
// refresh the host's status, it is unlocked, pending wipe
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "wipe", *getHostResp.Host.MDM.PendingAction)
status, err = s.ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
if host.FleetPlatform() == "linux" {
// simulate a successful wipe for the Linux host's script response
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, status.WipeScript.ExecutionID)),
http.StatusOK, &orbitScriptResp)
} else {
// simulate a successful wipe from the Windows device's MDM response
cmds, err := winMDMClient.StartManagementSession()
require.NoError(t, err)
// two status + the wipe command we enqueued
require.Len(t, cmds, 3)
wipeCmd := cmds[status.WipeMDMCommand.CommandUUID]
require.NotNil(t, wipeCmd)
require.Equal(t, wipeCmd.Verb, fleet.CmdExec)
require.Len(t, wipeCmd.Cmd.Items, 1)
require.EqualValues(t, "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected", *wipeCmd.Cmd.Items[0].Target)
msgID, err := winMDMClient.GetCurrentMsgID()
require.NoError(t, err)
winMDMClient.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: mdm_types.CmdStatus},
MsgRef: &msgID,
CmdRef: &status.WipeMDMCommand.CommandUUID,
Cmd: ptr.String("Exec"),
Data: ptr.String("200"),
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
cmds, err = winMDMClient.SendResponse()
require.NoError(t, err)
// the ack of the message should be the only returned command
require.Len(t, cmds, 1)
}
// refresh the host's status, it is wiped
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "wiped", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
// try to wipe the host again, conflict (already wiped)
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusConflict)
// no activity created
s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID)
})
}
}
func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() {
t := s.T()
host, mdmClient := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// get the host's information
var getHostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
// try to unlock the host (which is already its status)
var unlockResp unlockHostResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusConflict, &unlockResp)
// lock the host
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusNoContent)
// refresh the host's status, it is now pending lock
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "lock", *getHostResp.Host.MDM.PendingAction)
// try locking the host while it is pending lock fails
res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Host has pending lock request.")
// simulate a successful MDM result for the lock command
cmd, err := mdmClient.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "DeviceLock", cmd.Command.RequestType)
cmd, err = mdmClient.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
// refresh the host's status, it is now locked
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
// try to lock the host again
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusConflict)
// unlock the host
unlockResp = unlockHostResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusOK, &unlockResp)
require.NotNil(t, unlockResp.HostID)
require.Equal(t, host.ID, *unlockResp.HostID)
require.Len(t, unlockResp.UnlockPIN, 6)
unlockPIN := unlockResp.UnlockPIN
unlockActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeUnlockedHost{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "host_platform": %q}`, host.ID, host.DisplayName(), host.FleetPlatform()), 0)
// refresh the host's status, it is locked pending unlock
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "unlock", *getHostResp.Host.MDM.PendingAction)
// try unlocking the host again simply returns the PIN again
unlockResp = unlockHostResponse{}
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusOK, &unlockResp)
require.Equal(t, unlockPIN, unlockResp.UnlockPIN)
// a new unlock host activity is created every time the unlock PIN is viewed
newUnlockActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeUnlockedHost{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "host_platform": %q}`, host.ID, host.DisplayName(), host.FleetPlatform()), 0)
require.NotEqual(t, unlockActID, newUnlockActID)
// as soon as the host sends an Idle MDM request, it is maked as unlocked
cmd, err = mdmClient.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
// refresh the host's status, it is unlocked
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
// wipe the host
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusNoContent)
wipeActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0)
// try to wipe the host again, already have it pending
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Host has pending wipe request.")
// no activity created
s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID)
// refresh the host's status, it is unlocked, pending wipe
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "wipe", *getHostResp.Host.MDM.PendingAction)
// simulate a successful MDM result for the wipe command
cmd, err = mdmClient.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "EraseDevice", cmd.Command.RequestType)
cmd, err = mdmClient.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
// refresh the host's status, it is wiped
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.DeviceStatus)
require.Equal(t, "wiped", *getHostResp.Host.MDM.DeviceStatus)
require.NotNil(t, getHostResp.Host.MDM.PendingAction)
require.Equal(t, "", *getHostResp.Host.MDM.PendingAction)
// try to wipe the host again, conflict (already wiped)
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusConflict)
// no activity created
s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID)
}
func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() {
t := s.T()

View File

@ -545,24 +545,6 @@ func (svc *Service) enqueueAppleMDMCommand(ctx context.Context, rawXMLCmd []byte
return nil, ctxerr.Wrap(ctx, err, "decode plist command")
}
// TODO(mna): as per the story's spec:
// Make macOS and Windows MDM, low-level lock command available for free
// users. Remove validation where we check for Premium for custom MDM
// commands that contain the lock command
//
// So we'd need to not only remove this validation to allow DeviceLock (and
// eventually EraseDevice for the Wipe story), but it needs to behave
// similarly to how the /lock endpoint would've:
//
// see https://fleetdm.slack.com/archives/C03C41L5YEL/p1707169116154199?thread_ts=1707162619.655219&cid=C03C41L5YEL
// Regarding Free use of “lock” command as custom command, remove the validation but does that behave the same as if /lock had been used?
// @Martin Angers
// thats right.
//
// So it looks like we'd need to parse the command's XML to get the unlock
// PIN, and TBD how to behave if there is no PIN or if it's larger than
// supported.
if appleMDMPremiumCommands[strings.TrimSpace(cmd.Command.RequestType)] {
lic, err := svc.License(ctx)
if err != nil {
@ -621,15 +603,6 @@ func (svc *Service) enqueueMicrosoftMDMCommand(ctx context.Context, rawXMLCmd []
return nil, ctxerr.Wrap(ctx, err, "decode SyncML command")
}
// TODO(mna): as per the story's spec:
// Make macOS and Windows MDM, low-level lock command available for Free
// users. Remove validation where we check for Premium for custom MDM
// commands that contain the lock command
//
// However for Windows, it looks like we only prevent the RemoteWipe command,
// nothing for lock, so looks like nothing to do here for now (will need a
// change for the wipe command).
if cmdMsg.IsPremium() {
lic, err := svc.License(ctx)
if err != nil {

View File

@ -924,3 +924,34 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error)
return "", fleet.ErrMissingLicense
}
////////////////////////////////////////////////////////////////////////////////
// Wipe host
////////////////////////////////////////////////////////////////////////////////
type wipeHostRequest struct {
HostID uint `url:"id"`
}
type wipeHostResponse struct {
Err error `json:"error,omitempty"`
}
func (r wipeHostResponse) Status() int { return http.StatusNoContent }
func (r wipeHostResponse) error() error { return r.Err }
func wipeHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*wipeHostRequest)
if err := svc.WipeHost(ctx, req.HostID); err != nil {
return wipeHostResponse{Err: err}, nil
}
return wipeHostResponse{}, nil
}
func (svc *Service) WipeHost(ctx context.Context, hostID uint) error {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return fleet.ErrMissingLicense
}