Set recovery lock password - mdm commands (#41217)
This commit is contained in:
@@ -1987,3 +1987,27 @@ func cronMigrateToPerHostPolicy(
|
||||
)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func newRecoveryLockPasswordSchedule(
|
||||
ctx context.Context,
|
||||
instanceID string,
|
||||
ds fleet.Datastore,
|
||||
commander *apple_mdm.MDMAppleCommander,
|
||||
logger *slog.Logger,
|
||||
) (*schedule.Schedule, error) {
|
||||
const (
|
||||
name = string(fleet.CronSendRecoveryLockCommands)
|
||||
defaultInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
logger = logger.With("cron", name)
|
||||
s := schedule.New(
|
||||
ctx, name, instanceID, defaultInterval, ds, ds,
|
||||
schedule.WithLogger(logger),
|
||||
schedule.WithJob("send_recovery_lock_commands", func(ctx context.Context) error {
|
||||
return apple_mdm.SendRecoveryLockCommands(ctx, ds, commander, logger)
|
||||
}),
|
||||
)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -1291,6 +1291,13 @@ the way that the Fleet server works.
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register refresh vpp app versions schedule")
|
||||
}
|
||||
|
||||
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
|
||||
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
|
||||
return newRecoveryLockPasswordSchedule(ctx, instanceID, ds, commander, logger)
|
||||
}); err != nil {
|
||||
initFatal(err, "failed to register recovery lock password schedule")
|
||||
}
|
||||
}
|
||||
|
||||
if license.IsPremium() && config.Activity.EnableAuditLog {
|
||||
@@ -1468,6 +1475,7 @@ the way that the Fleet server works.
|
||||
|
||||
mdmCheckinAndCommandService.RegisterResultsHandler("InstalledApplicationList", service.NewInstalledApplicationListResultsHandler(ds, commander, logger, config.Server.VPPVerifyTimeout, config.Server.VPPVerifyRequestDelay, svc.NewActivity))
|
||||
mdmCheckinAndCommandService.RegisterResultsHandler(fleet.DeviceLocationCmdName, service.NewDeviceLocationResultsHandler(ds, commander, logger))
|
||||
mdmCheckinAndCommandService.RegisterResultsHandler(fleet.SetRecoveryLockCmdName, service.NewSetRecoveryLockResultsHandler(ds, logger))
|
||||
|
||||
hasSCEPChallenge, err := checkMDMAssets([]fleet.MDMAssetName{fleet.MDMAssetSCEPChallenge})
|
||||
if err != nil {
|
||||
|
||||
@@ -7239,3 +7239,170 @@ func (ds *Datastore) DeleteHostLocationData(ctx context.Context, hostID uint) er
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID)
|
||||
return ctxerr.Wrap(ctx, err, "delete host location data")
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Apple MDM Recovery Lock Password
|
||||
|
||||
func (ds *Datastore) SetHostsRecoveryLockPasswords(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error {
|
||||
if len(passwords) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build values for bulk insert.
|
||||
// Status is set to 'pending' immediately to prevent the host from being picked up
|
||||
// again by the next cron run while the command is being enqueued. If enqueue fails,
|
||||
// ClearRecoveryLockPendingStatus should be called to reset the status to NULL.
|
||||
var args []any
|
||||
for _, p := range passwords {
|
||||
encrypted, err := encrypt([]byte(p.Password), ds.serverPrivateKey)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "encrypting recovery lock password")
|
||||
}
|
||||
args = append(args, p.HostUUID, encrypted, fleet.MDMDeliveryPending, fleet.MDMOperationTypeInstall)
|
||||
}
|
||||
|
||||
stmt := `
|
||||
INSERT INTO host_recovery_key_passwords (host_uuid, encrypted_password, status, operation_type)
|
||||
VALUES %s
|
||||
ON DUPLICATE KEY UPDATE
|
||||
encrypted_password = VALUES(encrypted_password),
|
||||
status = VALUES(status),
|
||||
operation_type = VALUES(operation_type),
|
||||
error_message = NULL,
|
||||
deleted = 0
|
||||
`
|
||||
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("(?, ?, ?, ?),", len(passwords)), ",")
|
||||
stmt = fmt.Sprintf(stmt, placeholders)
|
||||
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, stmt, args...); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "storing recovery lock passwords")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetHostRecoveryLockPassword(ctx context.Context, hostUUID string) (*fleet.HostRecoveryLockPassword, error) {
|
||||
const stmt = `SELECT encrypted_password, updated_at FROM host_recovery_key_passwords WHERE host_uuid = ? AND deleted = 0`
|
||||
|
||||
var row struct {
|
||||
EncryptedPassword []byte `db:"encrypted_password"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
if err := sqlx.GetContext(ctx, ds.reader(ctx), &row, stmt, hostUUID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ctxerr.Wrap(ctx, notFound("HostRecoveryLockPassword").
|
||||
WithMessage(fmt.Sprintf("for host %s", hostUUID)))
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting recovery lock password")
|
||||
}
|
||||
|
||||
decrypted, err := decrypt(row.EncryptedPassword, ds.serverPrivateKey)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "decrypting recovery lock password")
|
||||
}
|
||||
|
||||
return &fleet.HostRecoveryLockPassword{
|
||||
Password: string(decrypted),
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetHostsForRecoveryLockAction(ctx context.Context) ([]string, error) {
|
||||
// Query hosts that:
|
||||
// - Have enable_recovery_lock_password = true (from team config or appconfig for no-team hosts)
|
||||
// - Are Apple Silicon (ARM CPU)
|
||||
// - Are MDM enrolled (enabled = 1 and device enrollment type)
|
||||
// - Have no recovery lock password record OR have a password with NULL status (command not yet enqueued)
|
||||
// Note: hosts with status pending, verified, or failed are NOT included
|
||||
const stmt = `
|
||||
SELECT h.uuid
|
||||
FROM hosts h
|
||||
JOIN nano_enrollments ne ON ne.device_id = h.uuid
|
||||
JOIN host_mdm hm ON hm.host_id = h.id
|
||||
LEFT JOIN teams t ON t.id = h.team_id
|
||||
CROSS JOIN app_config_json ac
|
||||
LEFT JOIN host_recovery_key_passwords rkp ON rkp.host_uuid = h.uuid AND rkp.deleted = 0
|
||||
WHERE h.platform = 'darwin'
|
||||
AND h.cpu_type LIKE '%arm%'
|
||||
AND ne.enabled = 1
|
||||
AND ne.type IN ('Device', 'User Enrollment (Device)')
|
||||
AND hm.enrolled = 1
|
||||
AND (
|
||||
-- Team hosts: check team config
|
||||
(h.team_id IS NOT NULL AND JSON_EXTRACT(t.config, '$.mdm.enable_recovery_lock_password') = true)
|
||||
OR
|
||||
-- No-team hosts: check appconfig
|
||||
(h.team_id IS NULL AND JSON_EXTRACT(ac.json_value, '$.mdm.enable_recovery_lock_password') = true)
|
||||
)
|
||||
AND (rkp.host_uuid IS NULL OR rkp.status IS NULL)
|
||||
LIMIT 500
|
||||
`
|
||||
|
||||
var hostUUIDs []string
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostUUIDs, stmt); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get hosts for recovery lock action")
|
||||
}
|
||||
|
||||
return hostUUIDs, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) SetRecoveryLockVerified(ctx context.Context, hostUUID string) error {
|
||||
stmt := fmt.Sprintf(`
|
||||
UPDATE host_recovery_key_passwords
|
||||
SET status = '%s',
|
||||
error_message = NULL
|
||||
WHERE host_uuid = ?
|
||||
AND deleted = 0
|
||||
`, fleet.MDMDeliveryVerified)
|
||||
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostUUID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "set recovery lock verified")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
|
||||
stmt := fmt.Sprintf(`
|
||||
UPDATE host_recovery_key_passwords
|
||||
SET status = '%s',
|
||||
error_message = ?
|
||||
WHERE host_uuid = ?
|
||||
AND deleted = 0
|
||||
`, fleet.MDMDeliveryFailed)
|
||||
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, stmt, errorMsg, hostUUID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "set recovery lock failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) ClearRecoveryLockPendingStatus(ctx context.Context, hostUUIDs []string) error {
|
||||
if len(hostUUIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reset status to NULL for hosts that failed to have their commands enqueued.
|
||||
// This allows them to be picked up again on the next cron run.
|
||||
// Only clears status if it's currently 'pending' to avoid overwriting other statuses.
|
||||
stmt := fmt.Sprintf(`
|
||||
UPDATE host_recovery_key_passwords
|
||||
SET status = NULL
|
||||
WHERE host_uuid IN (?)
|
||||
AND status = '%s'
|
||||
AND deleted = 0
|
||||
`, fleet.MDMDeliveryPending)
|
||||
|
||||
query, args, err := sqlx.In(stmt, hostUUIDs)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "build query for clear recovery lock pending status")
|
||||
}
|
||||
|
||||
if _, err := ds.writer(ctx).ExecContext(ctx, query, args...); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "clear recovery lock pending status")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -112,6 +113,13 @@ func TestMDMApple(t *testing.T) {
|
||||
{"DeviceLocation", testDeviceLocation},
|
||||
{"TestGetDEPAssignProfileExpiredCooldowns", testGetDEPAssignProfileExpiredCooldowns},
|
||||
{"DeleteMDMAppleDeclarationByNameCancelsInstalls", testDeleteMDMAppleDeclarationByNameCancelsInstalls},
|
||||
{"RecoveryLockPasswordSetAndGet", testRecoveryLockPasswordSetAndGet},
|
||||
{"RecoveryLockPasswordBulkSet", testRecoveryLockPasswordBulkSet},
|
||||
{"RecoveryLockPasswordGetNotFound", testRecoveryLockPasswordGetNotFound},
|
||||
{"RecoveryLockPasswordSetOverwrite", testRecoveryLockPasswordSetOverwrite},
|
||||
{"RecoveryLockPasswordUpdatedAtChanges", testRecoveryLockPasswordUpdatedAtChanges},
|
||||
{"RecoveryLockStatusMethods", testRecoveryLockStatusMethods},
|
||||
{"GetHostsForRecoveryLockAction", testGetHostsForRecoveryLockAction},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -10024,3 +10032,367 @@ func testDeleteMDMAppleDeclarationByNameCancelsInstalls(t *testing.T, ds *Datast
|
||||
runTest(t, &team.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func testRecoveryLockPasswordSetAndGet(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
host := test.NewHost(t, ds, "test-host-1", "1.2.3.4", "h1key", "h1uuid", time.Now())
|
||||
|
||||
// Generate and set password
|
||||
password := apple_mdm.GenerateRecoveryLockPassword()
|
||||
|
||||
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: password}})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get password and verify it matches
|
||||
result, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, password, result.Password)
|
||||
assert.False(t, result.UpdatedAt.IsZero())
|
||||
}
|
||||
|
||||
func testRecoveryLockPasswordBulkSet(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Create multiple hosts
|
||||
host1 := test.NewHost(t, ds, "bulk-host-1", "1.2.3.10", "bulk1key", "bulk1uuid", time.Now())
|
||||
host2 := test.NewHost(t, ds, "bulk-host-2", "1.2.3.11", "bulk2key", "bulk2uuid", time.Now())
|
||||
host3 := test.NewHost(t, ds, "bulk-host-3", "1.2.3.12", "bulk3key", "bulk3uuid", time.Now())
|
||||
|
||||
// Generate passwords for all hosts
|
||||
pw1 := apple_mdm.GenerateRecoveryLockPassword()
|
||||
pw2 := apple_mdm.GenerateRecoveryLockPassword()
|
||||
pw3 := apple_mdm.GenerateRecoveryLockPassword()
|
||||
|
||||
// Bulk set passwords
|
||||
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{
|
||||
{HostUUID: host1.UUID, Password: pw1},
|
||||
{HostUUID: host2.UUID, Password: pw2},
|
||||
{HostUUID: host3.UUID, Password: pw3},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify all passwords are stored correctly
|
||||
result1, err := ds.GetHostRecoveryLockPassword(ctx, host1.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pw1, result1.Password)
|
||||
|
||||
result2, err := ds.GetHostRecoveryLockPassword(ctx, host2.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pw2, result2.Password)
|
||||
|
||||
result3, err := ds.GetHostRecoveryLockPassword(ctx, host3.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pw3, result3.Password)
|
||||
|
||||
// Verify all passwords are different
|
||||
assert.NotEqual(t, pw1, pw2)
|
||||
assert.NotEqual(t, pw2, pw3)
|
||||
assert.NotEqual(t, pw1, pw3)
|
||||
}
|
||||
|
||||
func testRecoveryLockPasswordGetNotFound(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Try to get password for non-existent host
|
||||
_, err := ds.GetHostRecoveryLockPassword(ctx, "non-existent-uuid")
|
||||
require.Error(t, err)
|
||||
assert.True(t, fleet.IsNotFound(err))
|
||||
}
|
||||
|
||||
func testRecoveryLockPasswordSetOverwrite(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
host := test.NewHost(t, ds, "test-host-2", "1.2.3.5", "h2key", "h2uuid", time.Now())
|
||||
|
||||
// Set password first time
|
||||
password1 := apple_mdm.GenerateRecoveryLockPassword()
|
||||
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: password1}})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set password second time (should overwrite)
|
||||
password2 := apple_mdm.GenerateRecoveryLockPassword()
|
||||
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: password2}})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Passwords should be different (randomly generated)
|
||||
assert.NotEqual(t, password1, password2)
|
||||
|
||||
// Verify only the new password is stored
|
||||
result, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, password2, result.Password)
|
||||
}
|
||||
|
||||
func testRecoveryLockPasswordUpdatedAtChanges(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
host := test.NewHost(t, ds, "test-host-3", "1.2.3.6", "h3key", "h3uuid", time.Now())
|
||||
|
||||
// Set password first time
|
||||
password1 := apple_mdm.GenerateRecoveryLockPassword()
|
||||
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: password1}})
|
||||
require.NoError(t, err)
|
||||
|
||||
result1, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait a bit to ensure timestamp changes
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Set password second time
|
||||
password2 := apple_mdm.GenerateRecoveryLockPassword()
|
||||
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: password2}})
|
||||
require.NoError(t, err)
|
||||
|
||||
result2, err := ds.GetHostRecoveryLockPassword(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// updated_at should have changed
|
||||
assert.True(t, result2.UpdatedAt.After(result1.UpdatedAt), "updated_at should increase after overwrite")
|
||||
}
|
||||
|
||||
func testRecoveryLockStatusMethods(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Helper to create a host with a recovery lock password (status is set to 'pending' atomically)
|
||||
setupHost := func(t *testing.T, name, ip, key, uuid string) *fleet.Host {
|
||||
t.Helper()
|
||||
host := test.NewHost(t, ds, name, ip, key, uuid, time.Now())
|
||||
pw := apple_mdm.GenerateRecoveryLockPassword()
|
||||
err := ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: host.UUID, Password: pw}})
|
||||
require.NoError(t, err)
|
||||
return host
|
||||
}
|
||||
|
||||
t.Run("SetHostsRecoveryLockPasswords sets pending status atomically", func(t *testing.T) {
|
||||
host := setupHost(t, "atomic-pending-host", "1.2.3.6", "atomickey", "atomicuuid")
|
||||
|
||||
// Verify status is pending immediately after storing password
|
||||
var status string
|
||||
err := ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(fleet.MDMDeliveryPending), status)
|
||||
})
|
||||
|
||||
t.Run("SetRecoveryLockVerified", func(t *testing.T) {
|
||||
host := setupHost(t, "verified-host", "1.2.3.9", "verifiedkey", "verifieduuid")
|
||||
|
||||
// Set verified status
|
||||
err := ds.SetRecoveryLockVerified(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify status
|
||||
var status string
|
||||
err = ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(fleet.MDMDeliveryVerified), status)
|
||||
})
|
||||
|
||||
t.Run("SetRecoveryLockFailed", func(t *testing.T) {
|
||||
host := setupHost(t, "failed-host", "1.2.3.10", "failedkey", "faileduuid")
|
||||
|
||||
// Set failed status
|
||||
err := ds.SetRecoveryLockFailed(ctx, host.UUID, "test error message")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify status and error message
|
||||
var status, errorMsg string
|
||||
err = ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(fleet.MDMDeliveryFailed), status)
|
||||
|
||||
err = ds.writer(ctx).GetContext(ctx, &errorMsg, "SELECT error_message FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test error message", errorMsg)
|
||||
})
|
||||
|
||||
t.Run("ClearRecoveryLockPendingStatus", func(t *testing.T) {
|
||||
host := setupHost(t, "clear-pending-host", "1.2.3.11", "clearkey", "clearuuid")
|
||||
|
||||
// Verify status is pending
|
||||
var status sql.NullString
|
||||
err := ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(fleet.MDMDeliveryPending), status.String)
|
||||
|
||||
// Clear pending status
|
||||
err = ds.ClearRecoveryLockPendingStatus(ctx, []string{host.UUID})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify status is now NULL
|
||||
err = ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, status.Valid, "status should be NULL after clearing")
|
||||
})
|
||||
|
||||
t.Run("ClearRecoveryLockPendingStatus only clears pending", func(t *testing.T) {
|
||||
host := setupHost(t, "no-clear-verified-host", "1.2.3.12", "ncvkey", "ncvuuid")
|
||||
|
||||
// Set to verified
|
||||
err := ds.SetRecoveryLockVerified(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to clear - should not affect verified status
|
||||
err = ds.ClearRecoveryLockPendingStatus(ctx, []string{host.UUID})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify status is still verified
|
||||
var status string
|
||||
err = ds.writer(ctx).GetContext(ctx, &status, "SELECT status FROM host_recovery_key_passwords WHERE host_uuid = ?", host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(fleet.MDMDeliveryVerified), status)
|
||||
})
|
||||
}
|
||||
|
||||
func testGetHostsForRecoveryLockAction(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Helper to create a team with recovery lock setting
|
||||
createTeamWithRecoveryLock := func(name string, enabled bool) *fleet.Team {
|
||||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: name})
|
||||
require.NoError(t, err)
|
||||
|
||||
team.Config.MDM.EnableRecoveryLockPassword = enabled
|
||||
team, err = ds.SaveTeam(ctx, team)
|
||||
require.NoError(t, err)
|
||||
return team
|
||||
}
|
||||
|
||||
// Helper to set app config recovery lock setting
|
||||
setAppConfigRecoveryLock := func(enabled bool) {
|
||||
ac, err := ds.AppConfig(ctx)
|
||||
require.NoError(t, err)
|
||||
ac.MDM.EnableRecoveryLockPassword = optjson.SetBool(enabled)
|
||||
err = ds.SaveAppConfig(ctx, ac)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Helper to set host CPU type
|
||||
setHostCPUType := func(hostID uint, cpuType string) {
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, `UPDATE hosts SET cpu_type = ? WHERE id = ?`, cpuType, hostID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Initially no eligible hosts
|
||||
hosts, err := ds.GetHostsForRecoveryLockAction(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, hosts)
|
||||
|
||||
// Create eligible Apple Silicon host in team with recovery lock enabled
|
||||
teamARM := createTeamWithRecoveryLock("team-arm", true)
|
||||
hostARM := test.NewHost(t, ds, "arm-host", "1.2.5.1", "armkey", "armuuid", time.Now(),
|
||||
test.WithPlatform("darwin"), test.WithTeamID(teamARM.ID))
|
||||
setHostCPUType(hostARM.ID, "arm64")
|
||||
nanoEnrollAndSetHostMDMData(t, ds, hostARM, false)
|
||||
|
||||
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, slices.Contains(hosts, hostARM.UUID), "Apple Silicon (ARM) host should be eligible")
|
||||
|
||||
// Create ineligible Intel host
|
||||
teamIntel := createTeamWithRecoveryLock("team-intel", true)
|
||||
hostIntel := test.NewHost(t, ds, "intel-host", "1.2.5.2", "intelkey", "inteluuid", time.Now(),
|
||||
test.WithPlatform("darwin"), test.WithTeamID(teamIntel.ID))
|
||||
setHostCPUType(hostIntel.ID, "x86_64")
|
||||
nanoEnrollAndSetHostMDMData(t, ds, hostIntel, false)
|
||||
|
||||
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, slices.Contains(hosts, hostIntel.UUID), "Intel host should NOT be eligible")
|
||||
|
||||
// Create host in team with recovery lock DISABLED
|
||||
teamDisabled := createTeamWithRecoveryLock("team-disabled", false)
|
||||
hostDisabled := test.NewHost(t, ds, "disabled-team-host", "1.2.5.4", "dtkey", "dtuuid", time.Now(),
|
||||
test.WithPlatform("darwin"), test.WithTeamID(teamDisabled.ID))
|
||||
setHostCPUType(hostDisabled.ID, "arm64e")
|
||||
nanoEnrollAndSetHostMDMData(t, ds, hostDisabled, false)
|
||||
|
||||
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, slices.Contains(hosts, hostDisabled.UUID), "host in disabled team should NOT be eligible")
|
||||
|
||||
// Create host without MDM enrollment
|
||||
teamNotEnrolled := createTeamWithRecoveryLock("team-not-enrolled", true)
|
||||
hostNotEnrolled := test.NewHost(t, ds, "not-enrolled-host", "1.2.5.5", "nekey", "neuuid", time.Now(),
|
||||
test.WithPlatform("darwin"), test.WithTeamID(teamNotEnrolled.ID))
|
||||
setHostCPUType(hostNotEnrolled.ID, "arm64e")
|
||||
// No nano enrollment
|
||||
|
||||
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, slices.Contains(hosts, hostNotEnrolled.UUID), "non-enrolled host should NOT be eligible")
|
||||
|
||||
// Create Windows host (not darwin)
|
||||
teamNotDarwin := createTeamWithRecoveryLock("team-not-darwin", true)
|
||||
hostWindows := test.NewHost(t, ds, "windows-host", "1.2.5.6", "wkey", "wuuid", time.Now(),
|
||||
test.WithPlatform("windows"), test.WithTeamID(teamNotDarwin.ID))
|
||||
nanoEnrollAndSetHostMDMData(t, ds, hostWindows, false)
|
||||
|
||||
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, slices.Contains(hosts, hostWindows.UUID), "Windows host should NOT be eligible")
|
||||
|
||||
// Create host with pending status (already has SetRecoveryLock in progress)
|
||||
// Note: SetHostsRecoveryLockPasswords now sets status to 'pending' atomically
|
||||
teamPending := createTeamWithRecoveryLock("team-pending", true)
|
||||
hostPending := test.NewHost(t, ds, "pending-host2", "1.2.5.7", "pkey2", "puuid2", time.Now(),
|
||||
test.WithPlatform("darwin"), test.WithTeamID(teamPending.ID))
|
||||
setHostCPUType(hostPending.ID, "arm64e")
|
||||
nanoEnrollAndSetHostMDMData(t, ds, hostPending, false)
|
||||
pendingPW := apple_mdm.GenerateRecoveryLockPassword()
|
||||
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: hostPending.UUID, Password: pendingPW}})
|
||||
require.NoError(t, err)
|
||||
// Status is already 'pending' from SetHostsRecoveryLockPasswords - no need to call SetRecoveryLockPending
|
||||
|
||||
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, slices.Contains(hosts, hostPending.UUID), "pending host should NOT be eligible")
|
||||
|
||||
// Create host with verified status (already has recovery lock set)
|
||||
teamVerified := createTeamWithRecoveryLock("team-verified", true)
|
||||
hostVerified := test.NewHost(t, ds, "verified-host2", "1.2.5.8", "vkey2", "vuuid2", time.Now(),
|
||||
test.WithPlatform("darwin"), test.WithTeamID(teamVerified.ID))
|
||||
setHostCPUType(hostVerified.ID, "arm64e")
|
||||
nanoEnrollAndSetHostMDMData(t, ds, hostVerified, false)
|
||||
verifiedPW := apple_mdm.GenerateRecoveryLockPassword()
|
||||
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{{HostUUID: hostVerified.UUID, Password: verifiedPW}})
|
||||
require.NoError(t, err)
|
||||
err = ds.SetRecoveryLockVerified(ctx, hostVerified.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, slices.Contains(hosts, hostVerified.UUID), "verified host should NOT be eligible")
|
||||
|
||||
// Test no-team host with app config recovery lock enabled
|
||||
setAppConfigRecoveryLock(true)
|
||||
hostNoTeam := test.NewHost(t, ds, "no-team-host", "1.2.5.9", "ntkey", "ntuuid", time.Now(),
|
||||
test.WithPlatform("darwin"))
|
||||
setHostCPUType(hostNoTeam.ID, "arm64e")
|
||||
nanoEnrollAndSetHostMDMData(t, ds, hostNoTeam, false)
|
||||
|
||||
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, slices.Contains(hosts, hostNoTeam.UUID), "no-team host should be eligible when app config enabled")
|
||||
|
||||
// Clean up - disable app config recovery lock
|
||||
setAppConfigRecoveryLock(false)
|
||||
|
||||
// Now the no-team host should not be eligible
|
||||
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, slices.Contains(hosts, hostNoTeam.UUID), "no-team host should NOT be eligible when app config disabled")
|
||||
|
||||
// Create host with nano enrollment but MDM turned off (host_mdm.enrolled = 0)
|
||||
// This tests that hosts are properly excluded after MDMTurnOff is called
|
||||
teamUnenrolled := createTeamWithRecoveryLock("team-unenrolled", true)
|
||||
hostUnenrolled := test.NewHost(t, ds, "unenrolled-host", "1.2.5.10", "uekey", "ueuuid", time.Now(),
|
||||
test.WithPlatform("darwin"), test.WithTeamID(teamUnenrolled.ID))
|
||||
setHostCPUType(hostUnenrolled.ID, "arm64e")
|
||||
nanoEnroll(t, ds, hostUnenrolled, false)
|
||||
// Set host_mdm with enrolled = false (simulates MDM turn off)
|
||||
err = ds.SetOrUpdateMDMData(ctx, hostUnenrolled.ID, false, false, "", false, fleet.WellKnownMDMFleet, "", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
hosts, err = ds.GetHostsForRecoveryLockAction(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, slices.Contains(hosts, hostUnenrolled.UUID), "host with MDM turned off should NOT be eligible")
|
||||
}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260311160000, Down_20260311160000)
|
||||
}
|
||||
|
||||
func Up_20260311160000(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(`
|
||||
CREATE TABLE host_recovery_key_passwords (
|
||||
host_uuid varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
encrypted_password BLOB NOT NULL,
|
||||
status VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
operation_type VARCHAR(20) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
error_message TEXT COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (host_uuid),
|
||||
KEY status (status),
|
||||
KEY operation_type (operation_type),
|
||||
KEY deleted (deleted),
|
||||
CONSTRAINT host_recovery_key_passwords_ibfk_1 FOREIGN KEY (status) REFERENCES mdm_delivery_status (status) ON UPDATE CASCADE,
|
||||
CONSTRAINT host_recovery_key_passwords_ibfk_2 FOREIGN KEY (operation_type) REFERENCES mdm_operation_types (operation_type) ON UPDATE CASCADE
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("creating host_recovery_key_passwords table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260311160000(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -292,6 +292,15 @@ func (s *NanoMDMStorage) ExpandEmbeddedSecrets(ctx context.Context, document str
|
||||
return s.ds.ExpandEmbeddedSecrets(ctx, document)
|
||||
}
|
||||
|
||||
// ExpandHostSecrets expands host-scoped secrets in the document using the enrollment ID.
|
||||
func (s *NanoMDMStorage) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
|
||||
return s.ds.ExpandHostSecrets(ctx, document, enrollmentID)
|
||||
}
|
||||
|
||||
func (s *NanoMDMStorage) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
|
||||
return s.ds.SetRecoveryLockFailed(ctx, hostUUID, errorMsg)
|
||||
}
|
||||
|
||||
// ClearQueue in NanoMDMStorage overrides the implementation in
|
||||
// nanomdm_mysql.MySQLStorage. It does call
|
||||
// nanomdm_mysql.MySQLStorage.ClearQueue, but expands on its behavior.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -436,3 +437,78 @@ func (ds *Datastore) ValidateEmbeddedSecrets(ctx context.Context, documents []st
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpandHostSecrets expands host-scoped secrets ($FLEET_HOST_SECRET_*) in the document.
|
||||
// The enrollmentID (typically UDID/host UUID) is used to look up host-specific secrets.
|
||||
func (ds *Datastore) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
|
||||
// Check for host secret placeholders
|
||||
hostSecrets := fleet.ContainsPrefixVars(document, fleet.HostSecretPrefix)
|
||||
if len(hostSecrets) == 0 {
|
||||
return document, nil
|
||||
}
|
||||
|
||||
// Build a map of secret type -> value
|
||||
// enrollmentID is the host UUID, which is the primary key in host_recovery_key_passwords
|
||||
secretValues := make(map[string]string)
|
||||
for _, secretType := range hostSecrets {
|
||||
switch secretType {
|
||||
case fleet.HostSecretRecoveryLockPassword:
|
||||
password, err := ds.getHostRecoveryLockPasswordDecrypted(ctx, enrollmentID)
|
||||
if err != nil {
|
||||
return "", ctxerr.Wrapf(ctx, err, "getting recovery lock password for host %s", enrollmentID)
|
||||
}
|
||||
secretValues[secretType] = password
|
||||
default:
|
||||
return "", ctxerr.Errorf(ctx, "unknown host secret type: %s", secretType)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if document is XML (same logic as expandEmbeddedSecrets)
|
||||
documentIsXML := strings.HasPrefix(strings.TrimSpace(document), "<")
|
||||
|
||||
// Expand the placeholders
|
||||
expanded := fleet.MaybeExpand(document, func(s string, startPos, endPos int) (string, bool) {
|
||||
if !strings.HasPrefix(s, fleet.HostSecretPrefix) {
|
||||
return "", false
|
||||
}
|
||||
secretType := strings.TrimPrefix(s, fleet.HostSecretPrefix)
|
||||
val, ok := secretValues[secretType]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if documentIsXML {
|
||||
// Escape XML special characters to prevent malformed output
|
||||
var b strings.Builder
|
||||
if err := xml.EscapeText(&b, []byte(val)); err != nil {
|
||||
return "", false
|
||||
}
|
||||
val = b.String()
|
||||
}
|
||||
|
||||
return val, ok
|
||||
})
|
||||
|
||||
return expanded, nil
|
||||
}
|
||||
|
||||
// getHostRecoveryLockPasswordDecrypted retrieves and decrypts the recovery lock password for a host.
|
||||
func (ds *Datastore) getHostRecoveryLockPasswordDecrypted(ctx context.Context, hostUUID string) (string, error) {
|
||||
var encryptedPassword []byte
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &encryptedPassword,
|
||||
`SELECT encrypted_password FROM host_recovery_key_passwords WHERE host_uuid = ? AND deleted = 0`, hostUUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ctxerr.Wrap(ctx, notFound("HostRecoveryLockPassword").
|
||||
WithMessage(fmt.Sprintf("for host %s", hostUUID)))
|
||||
}
|
||||
return "", ctxerr.Wrap(ctx, err, "getting encrypted recovery lock password")
|
||||
}
|
||||
|
||||
password, err := decrypt(encryptedPassword, ds.serverPrivateKey)
|
||||
if err != nil {
|
||||
return "", ctxerr.Wrap(ctx, err, "decrypting recovery lock password")
|
||||
}
|
||||
|
||||
return string(password), nil
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -20,6 +22,7 @@ func TestSecretVariables(t *testing.T) {
|
||||
{"UpsertSecretVariables", testUpsertSecretVariables},
|
||||
{"ValidateEmbeddedSecrets", testValidateEmbeddedSecrets},
|
||||
{"ExpandEmbeddedSecrets", testExpandEmbeddedSecrets},
|
||||
{"ExpandHostSecrets", testExpandHostSecrets},
|
||||
{"CreateSecretVariable", testCreateSecretVariable},
|
||||
{"ListSecretVariables", testListSecretVariables},
|
||||
{"DeleteSecretVariable", testDeleteSecretVariable},
|
||||
@@ -206,6 +209,121 @@ Hello doc${FLEET_SECRET_INVALID}. $FLEET_SECRET_ALSO_INVALID
|
||||
require.Equal(t, expectedXMLExpansion, expanded)
|
||||
}
|
||||
|
||||
func testExpandHostSecrets(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
|
||||
// Create a host
|
||||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
OsqueryHostID: ptr.String("host-secrets-test"),
|
||||
NodeKey: ptr.String("host-secrets-test-key"),
|
||||
UUID: "host-secrets-test-uuid",
|
||||
Hostname: "host-secrets-test-hostname",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set a recovery lock password for this host
|
||||
password := "TEST-PASS-1234"
|
||||
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{
|
||||
{HostUUID: host.UUID, Password: password},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("no host secrets in document", func(t *testing.T) {
|
||||
doc := "This document has no host secrets. $FLEET_SECRET_SOMETHING ${OTHER_VAR}"
|
||||
expanded, err := ds.ExpandHostSecrets(ctx, doc, host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, doc, expanded) // unchanged
|
||||
})
|
||||
|
||||
t.Run("expand recovery lock password", func(t *testing.T) {
|
||||
doc := `<dict><key>NewPassword</key><string>$FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD</string></dict>`
|
||||
expected := `<dict><key>NewPassword</key><string>TEST-PASS-1234</string></dict>`
|
||||
expanded, err := ds.ExpandHostSecrets(ctx, doc, host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, expanded)
|
||||
})
|
||||
|
||||
t.Run("expand with braces syntax", func(t *testing.T) {
|
||||
doc := `Password: ${FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD}`
|
||||
expected := `Password: TEST-PASS-1234`
|
||||
expanded, err := ds.ExpandHostSecrets(ctx, doc, host.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, expanded)
|
||||
})
|
||||
|
||||
t.Run("unknown host secret type", func(t *testing.T) {
|
||||
doc := `<key>Value</key><string>$FLEET_HOST_SECRET_UNKNOWN_TYPE</string>`
|
||||
_, err := ds.ExpandHostSecrets(ctx, doc, host.UUID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown host secret type")
|
||||
})
|
||||
|
||||
t.Run("non-existent host", func(t *testing.T) {
|
||||
doc := `<string>$FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD</string>`
|
||||
_, err := ds.ExpandHostSecrets(ctx, doc, "non-existent-uuid")
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("host without recovery lock password", func(t *testing.T) {
|
||||
// Create another host without a recovery lock password
|
||||
host2, err := ds.NewHost(ctx, &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
OsqueryHostID: ptr.String("host-no-password"),
|
||||
NodeKey: ptr.String("host-no-password-key"),
|
||||
UUID: "host-no-password-uuid",
|
||||
Hostname: "host-no-password-hostname",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
doc := `<string>$FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD</string>`
|
||||
_, err = ds.ExpandHostSecrets(ctx, doc, host2.UUID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "getting recovery lock password")
|
||||
})
|
||||
|
||||
t.Run("expand recovery lock password with XML special characters", func(t *testing.T) {
|
||||
// Create a host with a password containing XML special characters
|
||||
hostXML, err := ds.NewHost(ctx, &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
OsqueryHostID: ptr.String("host-xml-escape-test"),
|
||||
NodeKey: ptr.String("host-xml-escape-test-key"),
|
||||
UUID: "host-xml-escape-test-uuid",
|
||||
Hostname: "host-xml-escape-test-hostname",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set a password with XML special characters: & < > " '
|
||||
passwordWithSpecialChars := `Pass&word<with>special"chars'`
|
||||
err = ds.SetHostsRecoveryLockPasswords(ctx, []fleet.HostRecoveryLockPasswordPayload{
|
||||
{HostUUID: hostXML.UUID, Password: passwordWithSpecialChars},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// When expanded in an XML document, special characters should be escaped
|
||||
doc := `<dict><key>NewPassword</key><string>$FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD</string></dict>`
|
||||
expected := `<dict><key>NewPassword</key><string>Pass&word<with>special"chars'</string></dict>`
|
||||
expanded, err := ds.ExpandHostSecrets(ctx, doc, hostXML.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, expanded)
|
||||
|
||||
// Non-XML documents should not escape the characters
|
||||
docNonXML := `Password: $FLEET_HOST_SECRET_RECOVERY_LOCK_PASSWORD`
|
||||
expandedNonXML, err := ds.ExpandHostSecrets(ctx, docNonXML, hostXML.UUID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `Password: Pass&word<with>special"chars'`, expandedNonXML)
|
||||
})
|
||||
}
|
||||
|
||||
func testCreateSecretVariable(t *testing.T, ds *Datastore) {
|
||||
t.Run("successful creation", func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
@@ -27,6 +27,7 @@ type MDMAppleCommandIssuer interface {
|
||||
EraseDevice(ctx context.Context, host *Host, uuid string) error
|
||||
InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error
|
||||
DeviceConfigured(ctx context.Context, hostUUID, cmdUUID string) error
|
||||
SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error
|
||||
}
|
||||
|
||||
// MDMAppleEnrollmentType is the type for Apple MDM enrollments.
|
||||
@@ -1124,6 +1125,7 @@ const (
|
||||
DeviceLocationCmdName = "DeviceLocation"
|
||||
EnableLostModeCmdName = "EnableLostMode"
|
||||
DisableLostModeCmdName = "DisableLostMode"
|
||||
SetRecoveryLockCmdName = "SetRecoveryLock"
|
||||
)
|
||||
|
||||
type HostLocationData struct {
|
||||
@@ -1131,3 +1133,15 @@ type HostLocationData struct {
|
||||
Latitude float64 `db:"latitude"`
|
||||
Longitude float64 `db:"longitude"`
|
||||
}
|
||||
|
||||
// HostRecoveryLockPassword represents a recovery lock password for a host.
|
||||
type HostRecoveryLockPassword struct {
|
||||
Password string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// HostRecoveryLockPasswordPayload contains the data needed to store a recovery lock password.
|
||||
type HostRecoveryLockPasswordPayload struct {
|
||||
HostUUID string
|
||||
Password string
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ const (
|
||||
// CronQueryResultsCleanup deletes excess query result rows that exceed the maximum allowed per query.
|
||||
// Runs every 1 minute.
|
||||
CronQueryResultsCleanup CronScheduleName = "query_results_cleanup"
|
||||
// CronSendRecoveryLockCommands sends SetRecoveryLock MDM commands to macOS devices.
|
||||
// Runs every 5 minutes.
|
||||
CronSendRecoveryLockCommands CronScheduleName = "send_recovery_lock_commands"
|
||||
)
|
||||
|
||||
type CronSchedulesService interface {
|
||||
|
||||
@@ -1493,6 +1493,33 @@ type Datastore interface {
|
||||
// to any team).
|
||||
GetMDMAppleFileVaultSummary(ctx context.Context, teamID *uint) (*MDMAppleFileVaultSummary, error)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Apple MDM Recovery Lock Password
|
||||
|
||||
// SetHostsRecoveryLockPasswords encrypts and stores recovery lock passwords for the given hosts.
|
||||
SetHostsRecoveryLockPasswords(ctx context.Context, passwords []HostRecoveryLockPasswordPayload) error
|
||||
|
||||
// GetHostRecoveryLockPassword retrieves and decrypts the recovery lock password
|
||||
// for the given host UUID.
|
||||
GetHostRecoveryLockPassword(ctx context.Context, hostUUID string) (*HostRecoveryLockPassword, error)
|
||||
|
||||
// GetHostsForRecoveryLockAction returns host UUIDs that need recovery lock password action:
|
||||
// - Teams with enable_recovery_lock_password = true
|
||||
// - macOS Apple Silicon hosts that are MDM enrolled
|
||||
// - No password saved or status is NULL (ready for command)
|
||||
GetHostsForRecoveryLockAction(ctx context.Context) ([]string, error)
|
||||
|
||||
// SetRecoveryLockVerified marks the recovery lock as verified.
|
||||
SetRecoveryLockVerified(ctx context.Context, hostUUID string) error
|
||||
|
||||
// SetRecoveryLockFailed marks the recovery lock as failed with the given error message.
|
||||
SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error
|
||||
|
||||
// ClearRecoveryLockPendingStatus resets the recovery lock status to NULL for hosts
|
||||
// that failed to have their SetRecoveryLock commands enqueued. This allows them to
|
||||
// be picked up again on the next cron run.
|
||||
ClearRecoveryLockPendingStatus(ctx context.Context, hostUUIDs []string) error
|
||||
|
||||
// InsertMDMAppleBootstrapPackage insterts a new bootstrap package in the
|
||||
// database (or S3 if configured).
|
||||
InsertMDMAppleBootstrapPackage(ctx context.Context, bp *MDMAppleBootstrapPackage, pkgStore MDMBootstrapPackageStore) error
|
||||
@@ -2408,6 +2435,11 @@ type Datastore interface {
|
||||
// returns the latest updated_at time of the secrets used in the expansion.
|
||||
ExpandEmbeddedSecretsAndUpdatedAt(ctx context.Context, document string) (string, *time.Time, error)
|
||||
|
||||
// ExpandHostSecrets expands host-scoped secrets ($FLEET_HOST_SECRET_*) in the document.
|
||||
// The enrollmentID (typically UDID) is used to look up host-specific secrets
|
||||
// like recovery lock passwords.
|
||||
ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error)
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////////
|
||||
// Android
|
||||
|
||||
|
||||
@@ -7,6 +7,21 @@ import (
|
||||
|
||||
const ServerSecretPrefix = "FLEET_SECRET_"
|
||||
|
||||
// HostSecretPrefix is used for host-scoped secrets that are looked up by
|
||||
// enrollment ID rather than by name. These are expanded at command delivery time.
|
||||
//
|
||||
// NOTE: This prefix is for Fleet-internal use only (e.g., injecting per-host
|
||||
// recovery lock passwords into MDM commands). It is not user-configurable and
|
||||
// should not be documented as a user-facing feature.
|
||||
const HostSecretPrefix = "FLEET_HOST_SECRET_" //nolint:gosec // G101: this is a prefix constant, not a credential
|
||||
|
||||
// Host secret types
|
||||
const (
|
||||
// HostSecretRecoveryLockPassword is the host secret type for macOS recovery lock passwords.
|
||||
// The password is stored encrypted in host_recovery_key_passwords and injected at delivery time.
|
||||
HostSecretRecoveryLockPassword = "RECOVERY_LOCK_PASSWORD"
|
||||
)
|
||||
|
||||
type MissingSecretsError struct {
|
||||
MissingSecrets []string
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package apple_mdm
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -1604,3 +1605,139 @@ func IOSiPadOSRevive(ctx context.Context, ds fleet.Datastore, commander *MDMAppl
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecoveryLockCommander defines the interface for sending recovery lock commands.
|
||||
// This interface is implemented by MDMAppleCommander and allows for testing.
|
||||
type RecoveryLockCommander interface {
|
||||
SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error
|
||||
}
|
||||
|
||||
// SendRecoveryLockCommands is the cron job function that sends SetRecoveryLock MDM commands
|
||||
// to hosts that need a recovery lock password.
|
||||
//
|
||||
// Note: SetRecoveryLock command results are handled in the MDM results handler
|
||||
// (server/service/apple_mdm.go), which sends VerifyRecoveryLock immediately upon acknowledgment.
|
||||
func SendRecoveryLockCommands(
|
||||
ctx context.Context,
|
||||
ds fleet.Datastore,
|
||||
commander *MDMAppleCommander,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
return sendRecoveryLockCommandsWithCommander(ctx, ds, commander, logger)
|
||||
}
|
||||
|
||||
func sendRecoveryLockCommandsWithCommander(
|
||||
ctx context.Context,
|
||||
ds fleet.Datastore,
|
||||
commander RecoveryLockCommander,
|
||||
logger *slog.Logger,
|
||||
) error {
|
||||
hosts, err := ds.GetHostsForRecoveryLockAction(ctx)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "get hosts for recovery lock action")
|
||||
}
|
||||
|
||||
if len(hosts) == 0 {
|
||||
logger.DebugContext(ctx, "no hosts need SetRecoveryLock")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.InfoContext(ctx, "sending SetRecoveryLock commands", "count", len(hosts))
|
||||
|
||||
// Generate passwords for all hosts upfront.
|
||||
// Passwords must be stored BEFORE enqueuing commands because they are injected
|
||||
// at delivery time by ExpandHostSecrets (which looks up by host UUID).
|
||||
passwords := make([]fleet.HostRecoveryLockPasswordPayload, 0, len(hosts))
|
||||
for _, hostUUID := range hosts {
|
||||
passwords = append(passwords, fleet.HostRecoveryLockPasswordPayload{
|
||||
HostUUID: hostUUID,
|
||||
Password: GenerateRecoveryLockPassword(),
|
||||
})
|
||||
}
|
||||
|
||||
// Store passwords with status='pending' atomically. This prevents the host from
|
||||
// being picked up again by the next cron run while we're enqueuing the command.
|
||||
// If enqueue fails, we reset the status to NULL so the host can be retried.
|
||||
if err := ds.SetHostsRecoveryLockPasswords(ctx, passwords); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "bulk set recovery lock passwords")
|
||||
}
|
||||
|
||||
// Collect host UUIDs for enqueue.
|
||||
// The password is not in the command - a placeholder is used that will be
|
||||
// expanded at delivery time by ExpandHostSecrets.
|
||||
hostUUIDs := make([]string, 0, len(passwords))
|
||||
for _, p := range passwords {
|
||||
hostUUIDs = append(hostUUIDs, p.HostUUID)
|
||||
}
|
||||
|
||||
// Enqueue a single command for all hosts. Each host gets their own queue entry
|
||||
// pointing to the same command, and ExpandHostSecrets injects the per-host
|
||||
// password at delivery time.
|
||||
cmdUUID := uuid.NewString()
|
||||
if err := commander.SetRecoveryLock(ctx, hostUUIDs, cmdUUID); err != nil {
|
||||
// Check if this is an APNs delivery error (command was persisted but push failed).
|
||||
// In this case, the command is already queued and will be delivered when the device
|
||||
// checks in, so we should NOT clear the pending status (which would cause duplicates).
|
||||
var apnsErr *APNSDeliveryError
|
||||
if errors.As(err, &apnsErr) {
|
||||
// Command was persisted but push notification failed - log warning but don't fail.
|
||||
// The command will be delivered when the device next checks in.
|
||||
logger.WarnContext(ctx, "SetRecoveryLock commands enqueued but APNs push failed",
|
||||
"host_count", len(hostUUIDs),
|
||||
"command_uuid", cmdUUID,
|
||||
"error", err,
|
||||
)
|
||||
// Don't clear pending status - command is queued and will be processed
|
||||
return nil
|
||||
}
|
||||
|
||||
// Persistence failed - reset status to NULL so hosts will be picked up again on next cron run.
|
||||
// The password is already stored, but a new one will be generated on retry (overwrites old).
|
||||
logger.ErrorContext(ctx, "failed to enqueue SetRecoveryLock commands",
|
||||
"host_count", len(hostUUIDs),
|
||||
"error", err,
|
||||
)
|
||||
if clearErr := ds.ClearRecoveryLockPendingStatus(ctx, hostUUIDs); clearErr != nil {
|
||||
logger.ErrorContext(ctx, "failed to clear recovery lock pending status after enqueue failure",
|
||||
"host_count", len(hostUUIDs),
|
||||
"error", clearErr,
|
||||
)
|
||||
}
|
||||
return ctxerr.Wrap(ctx, err, "enqueue SetRecoveryLock commands")
|
||||
}
|
||||
|
||||
logger.InfoContext(ctx, "sent SetRecoveryLock commands",
|
||||
"host_count", len(hostUUIDs),
|
||||
"command_uuid", cmdUUID,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecoveryLockPasswordCharset excludes confusing characters (0/O, 1/I/l)
|
||||
const RecoveryLockPasswordCharset = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
|
||||
// GenerateRecoveryLockPassword generates a password in format: 5ADZ-HTZ8-LJJ4-B2F8-JWH3-YPBT
|
||||
// (6 groups of 4 alphanumeric characters separated by dashes)
|
||||
func GenerateRecoveryLockPassword() string {
|
||||
const (
|
||||
groupCount = 6
|
||||
groupLen = 4
|
||||
)
|
||||
|
||||
groups := make([]string, groupCount)
|
||||
charsetLen := len(RecoveryLockPasswordCharset)
|
||||
|
||||
for i := range groupCount {
|
||||
randBytes := make([]byte, groupLen)
|
||||
_, _ = rand.Read(randBytes) // rand.Read never returns an error; it panics on failure
|
||||
|
||||
group := make([]byte, groupLen)
|
||||
for j := range groupLen {
|
||||
group[j] = RecoveryLockPasswordCharset[int(randBytes[j])%charsetLen]
|
||||
}
|
||||
groups[i] = string(group)
|
||||
}
|
||||
|
||||
return strings.Join(groups, "-")
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package apple_mdm
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -273,3 +275,194 @@ type notFoundError struct{}
|
||||
func (e notFoundError) IsNotFound() bool { return true }
|
||||
|
||||
func (e notFoundError) Error() string { return "not found" }
|
||||
|
||||
func TestGenerateRecoveryLockPassword(t *testing.T) {
|
||||
// Pattern: 6 groups of 4 characters from the allowed charset, separated by dashes
|
||||
pattern := regexp.MustCompile(`^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}(-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}){5}$`)
|
||||
|
||||
t.Run("format", func(t *testing.T) {
|
||||
password := GenerateRecoveryLockPassword()
|
||||
assert.True(t, pattern.MatchString(password), "password %q does not match expected format", password)
|
||||
assert.Len(t, password, 29) // 24 chars + 5 dashes
|
||||
})
|
||||
|
||||
t.Run("excludes confusing characters", func(t *testing.T) {
|
||||
// Generate multiple passwords and check none contain confusing chars
|
||||
confusingChars := regexp.MustCompile(`[01OIl]`)
|
||||
for range 100 {
|
||||
password := GenerateRecoveryLockPassword()
|
||||
assert.False(t, confusingChars.MatchString(password), "password %q contains confusing characters", password)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uniqueness", func(t *testing.T) {
|
||||
// Generate multiple passwords and verify they're unique
|
||||
seen := make(map[string]bool)
|
||||
for range 100 {
|
||||
password := GenerateRecoveryLockPassword()
|
||||
assert.False(t, seen[password], "duplicate password generated: %s", password)
|
||||
seen[password] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSendRecoveryLockCommands tests the cron job that sends SetRecoveryLock commands
|
||||
// to hosts that need recovery lock passwords.
|
||||
//
|
||||
// Note: SetRecoveryLock command results are handled synchronously in the MDM results handler
|
||||
// (server/service/apple_mdm.go), which is tested separately in apple_mdm_cmd_results_test.go.
|
||||
func TestSendRecoveryLockCommands(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
t.Run("no hosts needing recovery lock does not send commands", func(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
ds.GetHostsForRecoveryLockActionFunc = func(ctx context.Context) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var commandSent bool
|
||||
mockCommander := &mockRecoveryLockCommander{
|
||||
setRecoveryLockFn: func(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
||||
commandSent = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, commandSent, "SetRecoveryLock should not be called when no hosts need it")
|
||||
})
|
||||
|
||||
t.Run("host needing recovery lock gets SetRecoveryLock and password stored with pending status", func(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
|
||||
hostUUID := "host-uuid-1"
|
||||
ds.GetHostsForRecoveryLockActionFunc = func(ctx context.Context) ([]string, error) {
|
||||
return []string{hostUUID}, nil
|
||||
}
|
||||
|
||||
// Track call order to verify correct sequencing
|
||||
var callOrder []string
|
||||
var storedPasswords []fleet.HostRecoveryLockPasswordPayload
|
||||
ds.SetHostsRecoveryLockPasswordsFunc = func(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error {
|
||||
callOrder = append(callOrder, "SetHostsRecoveryLockPasswords")
|
||||
storedPasswords = passwords
|
||||
return nil
|
||||
}
|
||||
|
||||
var sentCmdUUID string
|
||||
mockCommander := &mockRecoveryLockCommander{
|
||||
setRecoveryLockFn: func(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
||||
callOrder = append(callOrder, "SetRecoveryLock")
|
||||
assert.Equal(t, []string{hostUUID}, hostUUIDs)
|
||||
sentCmdUUID = cmdUUID
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify call order: password must be stored BEFORE command is sent
|
||||
require.Equal(t, []string{"SetHostsRecoveryLockPasswords", "SetRecoveryLock"}, callOrder,
|
||||
"SetHostsRecoveryLockPasswords must be called before SetRecoveryLock")
|
||||
|
||||
// Password should be stored with pending status atomically
|
||||
require.Len(t, storedPasswords, 1, "password should be stored for host")
|
||||
assert.Equal(t, hostUUID, storedPasswords[0].HostUUID)
|
||||
assert.NotEmpty(t, storedPasswords[0].Password)
|
||||
assert.NotEmpty(t, sentCmdUUID, "command UUID should have been sent")
|
||||
})
|
||||
|
||||
t.Run("SetRecoveryLock failure clears pending status to allow retry", func(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
|
||||
hostUUID := "host-uuid-1"
|
||||
ds.GetHostsForRecoveryLockActionFunc = func(ctx context.Context) ([]string, error) {
|
||||
return []string{hostUUID}, nil
|
||||
}
|
||||
|
||||
// Track call order to verify correct sequencing
|
||||
var callOrder []string
|
||||
ds.SetHostsRecoveryLockPasswordsFunc = func(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error {
|
||||
callOrder = append(callOrder, "SetHostsRecoveryLockPasswords")
|
||||
return nil
|
||||
}
|
||||
|
||||
var clearedHostUUIDs []string
|
||||
ds.ClearRecoveryLockPendingStatusFunc = func(ctx context.Context, hostUUIDs []string) error {
|
||||
callOrder = append(callOrder, "ClearRecoveryLockPendingStatus")
|
||||
clearedHostUUIDs = hostUUIDs
|
||||
return nil
|
||||
}
|
||||
|
||||
mockCommander := &mockRecoveryLockCommander{
|
||||
setRecoveryLockFn: func(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
||||
callOrder = append(callOrder, "SetRecoveryLock")
|
||||
return errors.New("APNs push failed")
|
||||
},
|
||||
}
|
||||
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "APNs push failed")
|
||||
|
||||
// Verify call order: password stored -> command attempt -> clear pending on failure
|
||||
require.Equal(t, []string{"SetHostsRecoveryLockPasswords", "SetRecoveryLock", "ClearRecoveryLockPendingStatus"}, callOrder,
|
||||
"Operations must occur in order: store password, attempt command, clear pending on failure")
|
||||
|
||||
// Status should be cleared to allow retry on next cron run
|
||||
assert.Equal(t, []string{hostUUID}, clearedHostUUIDs, "pending status should be cleared on enqueue failure")
|
||||
})
|
||||
|
||||
t.Run("APNs delivery failure does not clear pending status", func(t *testing.T) {
|
||||
ds := new(mock.Store)
|
||||
|
||||
hostUUID := "host-uuid-1"
|
||||
ds.GetHostsForRecoveryLockActionFunc = func(ctx context.Context) ([]string, error) {
|
||||
return []string{hostUUID}, nil
|
||||
}
|
||||
|
||||
// Track call order to verify ClearRecoveryLockPendingStatus is NOT called
|
||||
var callOrder []string
|
||||
ds.SetHostsRecoveryLockPasswordsFunc = func(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error {
|
||||
callOrder = append(callOrder, "SetHostsRecoveryLockPasswords")
|
||||
return nil
|
||||
}
|
||||
|
||||
ds.ClearRecoveryLockPendingStatusFunc = func(ctx context.Context, hostUUIDs []string) error {
|
||||
callOrder = append(callOrder, "ClearRecoveryLockPendingStatus")
|
||||
return nil
|
||||
}
|
||||
|
||||
mockCommander := &mockRecoveryLockCommander{
|
||||
setRecoveryLockFn: func(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
||||
callOrder = append(callOrder, "SetRecoveryLock")
|
||||
// Return APNs delivery error - command was persisted but push failed
|
||||
return &APNSDeliveryError{errorsByUUID: map[string]error{hostUUID: errors.New("push failed")}}
|
||||
},
|
||||
}
|
||||
|
||||
err := sendRecoveryLockCommandsWithCommander(ctx, ds, mockCommander, logger)
|
||||
// Should NOT return error - command was persisted, just push failed
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify ClearRecoveryLockPendingStatus was NOT called (status should stay pending)
|
||||
// Command is queued and will be delivered when device checks in
|
||||
require.Equal(t, []string{"SetHostsRecoveryLockPasswords", "SetRecoveryLock"}, callOrder,
|
||||
"ClearRecoveryLockPendingStatus should NOT be called when APNs push fails (command is already queued)")
|
||||
})
|
||||
}
|
||||
|
||||
// mockRecoveryLockCommander implements RecoveryLockCommander for testing.
|
||||
type mockRecoveryLockCommander struct {
|
||||
setRecoveryLockFn func(ctx context.Context, hostUUIDs []string, cmdUUID string) error
|
||||
}
|
||||
|
||||
func (m *mockRecoveryLockCommander) SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
||||
if m.setRecoveryLockFn != nil {
|
||||
return m.setRecoveryLockFn(ctx, hostUUIDs, cmdUUID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -562,6 +562,33 @@ func (svc *MDMAppleCommander) BulkDeleteHostUserCommandsWithoutResults(ctx conte
|
||||
return svc.storage.BulkDeleteHostUserCommandsWithoutResults(ctx, commandToIDs)
|
||||
}
|
||||
|
||||
// SetRecoveryLock sends the SetRecoveryLock MDM command to set the recovery lock password.
|
||||
// The password is not included in the command - instead, a placeholder is used that will be
|
||||
// expanded at delivery time by looking up the password from host_recovery_key_passwords.
|
||||
// The password must be stored (via SetHostsRecoveryLockPasswords) BEFORE calling this method.
|
||||
// See https://developer.apple.com/documentation/devicemanagement/set_recovery_lock
|
||||
func (svc *MDMAppleCommander) SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
|
||||
// Use the host secret placeholder - the actual password will be injected at delivery time
|
||||
// by ExpandHostSecrets, which looks up the password from host_recovery_key_passwords.
|
||||
cmdPayload := commandPayload{
|
||||
CommandUUID: cmdUUID,
|
||||
Command: map[string]any{
|
||||
"RequestType": "SetRecoveryLock",
|
||||
"NewPassword": "$" + fleet.HostSecretPrefix + fleet.HostSecretRecoveryLockPassword,
|
||||
},
|
||||
}
|
||||
rawBytes, err := plist.MarshalIndent(cmdPayload, " ")
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "marshalling SetRecoveryLock payload")
|
||||
}
|
||||
|
||||
if err := svc.EnqueueCommand(ctx, hostUUIDs, string(rawBytes)); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "enqueuing SetRecoveryLock command")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// APNSDeliveryError records an error and the associated host UUIDs in which it
|
||||
// occurred.
|
||||
type APNSDeliveryError struct {
|
||||
|
||||
@@ -569,3 +569,57 @@ UUID: uuid3, Error: timeout error`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDMAppleCommanderSetRecoveryLock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mdmStorage := &mdmmock.MDMAppleStore{}
|
||||
pushFactory, _ := newMockAPNSPushProviderFactory()
|
||||
pusher := nanomdm_pushsvc.New(
|
||||
mdmStorage,
|
||||
mdmStorage,
|
||||
pushFactory,
|
||||
stdlogfmt.New(),
|
||||
)
|
||||
cmdr := NewMDMAppleCommander(mdmStorage, pusher)
|
||||
|
||||
hostUUIDs := []string{"host-uuid-1"}
|
||||
cmdUUID := uuid.New().String()
|
||||
|
||||
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
|
||||
require.NotNil(t, cmd)
|
||||
require.Equal(t, "SetRecoveryLock", cmd.Command.Command.RequestType)
|
||||
require.Contains(t, string(cmd.Raw), cmdUUID)
|
||||
require.Contains(t, string(cmd.Raw), "SetRecoveryLock")
|
||||
// Should contain the placeholder, not the actual password
|
||||
require.Contains(t, string(cmd.Raw), "$"+fleet.HostSecretPrefix+fleet.HostSecretRecoveryLockPassword)
|
||||
require.Contains(t, string(cmd.Raw), "<key>NewPassword</key>")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mdmStorage.RetrievePushInfoFunc = func(ctx context.Context, targetUUIDs []string) (map[string]*mdm.Push, error) {
|
||||
require.ElementsMatch(t, hostUUIDs, targetUUIDs)
|
||||
pushes := make(map[string]*mdm.Push, len(targetUUIDs))
|
||||
for _, uuid := range targetUUIDs {
|
||||
pushes[uuid] = &mdm.Push{
|
||||
PushMagic: "magic" + uuid,
|
||||
Token: []byte("token" + uuid),
|
||||
Topic: "topic" + uuid,
|
||||
}
|
||||
}
|
||||
return pushes, nil
|
||||
}
|
||||
|
||||
mdmStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) {
|
||||
cert, err := tls.LoadX509KeyPair("../../service/testdata/server.pem", "../../service/testdata/server.key")
|
||||
return &cert, "", err
|
||||
}
|
||||
|
||||
mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
err := cmdr.SetRecoveryLock(ctx, hostUUIDs, cmdUUID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
|
||||
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
|
||||
@@ -284,6 +285,38 @@ func (s *Service) CommandAndReportResults(r *mdm.Request, results *mdm.CommandRe
|
||||
} else {
|
||||
cmd.Raw = []byte(expanded)
|
||||
}
|
||||
|
||||
// Expand host-scoped secrets for SetRecoveryLock commands.
|
||||
// SetRecoveryLock is device-only, so UDID is always present and matches host UUID.
|
||||
if cmd.Command.Command.RequestType == fleet.SetRecoveryLockCmdName {
|
||||
hostUUID := results.UDID
|
||||
hostExpanded, err := s.store.ExpandHostSecrets(r.Context, string(cmd.Raw), hostUUID)
|
||||
if err != nil {
|
||||
errorMsg := fmt.Sprintf("failed to expand host secrets: %v", err)
|
||||
logger.Info("level", "error", "msg", "expanding host secrets", "err", err)
|
||||
// Mark the command as failed so it won't be retried forever
|
||||
failedResult := &mdm.CommandResults{
|
||||
Enrollment: results.Enrollment,
|
||||
CommandUUID: cmd.CommandUUID,
|
||||
Status: "Error",
|
||||
ErrorChain: []mdm.ErrorChain{{
|
||||
ErrorCode: -1,
|
||||
ErrorDomain: "Fleet",
|
||||
LocalizedDescription: errorMsg,
|
||||
}},
|
||||
}
|
||||
if storeErr := s.store.StoreCommandReport(r, failedResult); storeErr != nil {
|
||||
logger.Info("level", "error", "msg", "storing failed command result", "err", storeErr)
|
||||
}
|
||||
// Mark the host's recovery lock status as failed so it's not stuck in pending.
|
||||
if storeErr := s.store.SetRecoveryLockFailed(r.Context, hostUUID, errorMsg); storeErr != nil {
|
||||
logger.Info("level", "error", "msg", "setting recovery lock failed", "err", storeErr)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
cmd.Raw = []byte(hostExpanded)
|
||||
}
|
||||
|
||||
switch cmd.Subtype {
|
||||
case mdm.CommandSubtypeProfileWithSecrets:
|
||||
// Secrets were expanded above. Now we need to base64 encode and sign the configuration profile before returning it to the caller.
|
||||
|
||||
@@ -105,6 +105,16 @@ func (ms *MultiAllStorage) ExpandEmbeddedSecrets(ctx context.Context, document s
|
||||
return doc.(string), err
|
||||
}
|
||||
|
||||
func (ms *MultiAllStorage) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
|
||||
// NOT IMPLEMENTED
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (ms *MultiAllStorage) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
|
||||
// NOT IMPLEMENTED
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ms *MultiAllStorage) BulkDeleteHostUserCommandsWithoutResults(ctx context.Context, commandToIDs map[string][]string) error {
|
||||
_, err := ms.execStores(ctx, func(s storage.AllStorage) (interface{}, error) {
|
||||
return nil, s.BulkDeleteHostUserCommandsWithoutResults(ctx, commandToIDs)
|
||||
|
||||
@@ -246,6 +246,16 @@ func (s *FileStorage) ExpandEmbeddedSecrets(_ context.Context, document string)
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (s *FileStorage) ExpandHostSecrets(_ context.Context, document string, _ string) (string, error) {
|
||||
// NOT IMPLEMENTED
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (s *FileStorage) SetRecoveryLockFailed(_ context.Context, _ string, _ string) error {
|
||||
// NOT IMPLEMENTED
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FileStorage) BulkDeleteHostUserCommandsWithoutResults(_ context.Context, _ map[string][]string) error {
|
||||
// NOT IMPLEMENTED
|
||||
return nil
|
||||
|
||||
@@ -348,3 +348,13 @@ func (s *MySQLStorage) ExpandEmbeddedSecrets(ctx context.Context, document strin
|
||||
s.logger.ErrorContext(ctx, "MySQLStorage.ExpandEmbeddedSecrets not implemented")
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (s *MySQLStorage) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
|
||||
s.logger.ErrorContext(ctx, "MySQLStorage.ExpandHostSecrets not implemented")
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (s *MySQLStorage) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
|
||||
s.logger.ErrorContext(ctx, "MySQLStorage.SetRecoveryLockFailed not implemented")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,7 +40,15 @@ type BootstrapTokenStore interface {
|
||||
}
|
||||
|
||||
type SecretStore interface {
|
||||
// ExpandEmbeddedSecrets expands named secrets ($FLEET_SECRET_*) in the document.
|
||||
ExpandEmbeddedSecrets(ctx context.Context, document string) (string, error)
|
||||
// ExpandHostSecrets expands host-scoped secrets ($FLEET_HOST_SECRET_*) in the document.
|
||||
// The enrollmentID (host UUID) is used to look up host-specific secrets like recovery lock passwords.
|
||||
ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error)
|
||||
// SetRecoveryLockFailed marks a host's recovery lock as failed.
|
||||
// The hostUUID is the same as the enrollment ID (UDID).
|
||||
// Used when secret expansion fails and we need to update the host status.
|
||||
SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error
|
||||
}
|
||||
|
||||
// ServiceStore stores & retrieves both command and check-in data.
|
||||
|
||||
@@ -1023,6 +1023,18 @@ type GetMDMIdPAccountsByHostUUIDsFunc func(ctx context.Context, hostUUIDs []stri
|
||||
|
||||
type GetMDMAppleFileVaultSummaryFunc func(ctx context.Context, teamID *uint) (*fleet.MDMAppleFileVaultSummary, error)
|
||||
|
||||
type SetHostsRecoveryLockPasswordsFunc func(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error
|
||||
|
||||
type GetHostRecoveryLockPasswordFunc func(ctx context.Context, hostUUID string) (*fleet.HostRecoveryLockPassword, error)
|
||||
|
||||
type GetHostsForRecoveryLockActionFunc func(ctx context.Context) ([]string, error)
|
||||
|
||||
type SetRecoveryLockVerifiedFunc func(ctx context.Context, hostUUID string) error
|
||||
|
||||
type SetRecoveryLockFailedFunc func(ctx context.Context, hostUUID string, errorMsg string) error
|
||||
|
||||
type ClearRecoveryLockPendingStatusFunc func(ctx context.Context, hostUUIDs []string) error
|
||||
|
||||
type InsertMDMAppleBootstrapPackageFunc func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error
|
||||
|
||||
type CopyDefaultMDMAppleBootstrapPackageFunc func(ctx context.Context, ac *fleet.AppConfig, toTeamID uint) error
|
||||
@@ -1537,6 +1549,8 @@ type ExpandEmbeddedSecretsFunc func(ctx context.Context, document string) (strin
|
||||
|
||||
type ExpandEmbeddedSecretsAndUpdatedAtFunc func(ctx context.Context, document string) (string, *time.Time, error)
|
||||
|
||||
type ExpandHostSecretsFunc func(ctx context.Context, document string, enrollmentID string) (string, error)
|
||||
|
||||
type CreateEnterpriseFunc func(ctx context.Context, userID uint) (uint, error)
|
||||
|
||||
type GetEnterpriseByIDFunc func(ctx context.Context, id uint) (*android.EnterpriseDetails, error)
|
||||
@@ -3290,6 +3304,24 @@ type DataStore struct {
|
||||
GetMDMAppleFileVaultSummaryFunc GetMDMAppleFileVaultSummaryFunc
|
||||
GetMDMAppleFileVaultSummaryFuncInvoked bool
|
||||
|
||||
SetHostsRecoveryLockPasswordsFunc SetHostsRecoveryLockPasswordsFunc
|
||||
SetHostsRecoveryLockPasswordsFuncInvoked bool
|
||||
|
||||
GetHostRecoveryLockPasswordFunc GetHostRecoveryLockPasswordFunc
|
||||
GetHostRecoveryLockPasswordFuncInvoked bool
|
||||
|
||||
GetHostsForRecoveryLockActionFunc GetHostsForRecoveryLockActionFunc
|
||||
GetHostsForRecoveryLockActionFuncInvoked bool
|
||||
|
||||
SetRecoveryLockVerifiedFunc SetRecoveryLockVerifiedFunc
|
||||
SetRecoveryLockVerifiedFuncInvoked bool
|
||||
|
||||
SetRecoveryLockFailedFunc SetRecoveryLockFailedFunc
|
||||
SetRecoveryLockFailedFuncInvoked bool
|
||||
|
||||
ClearRecoveryLockPendingStatusFunc ClearRecoveryLockPendingStatusFunc
|
||||
ClearRecoveryLockPendingStatusFuncInvoked bool
|
||||
|
||||
InsertMDMAppleBootstrapPackageFunc InsertMDMAppleBootstrapPackageFunc
|
||||
InsertMDMAppleBootstrapPackageFuncInvoked bool
|
||||
|
||||
@@ -4061,6 +4093,9 @@ type DataStore struct {
|
||||
ExpandEmbeddedSecretsAndUpdatedAtFunc ExpandEmbeddedSecretsAndUpdatedAtFunc
|
||||
ExpandEmbeddedSecretsAndUpdatedAtFuncInvoked bool
|
||||
|
||||
ExpandHostSecretsFunc ExpandHostSecretsFunc
|
||||
ExpandHostSecretsFuncInvoked bool
|
||||
|
||||
CreateEnterpriseFunc CreateEnterpriseFunc
|
||||
CreateEnterpriseFuncInvoked bool
|
||||
|
||||
@@ -7942,6 +7977,48 @@ func (s *DataStore) GetMDMAppleFileVaultSummary(ctx context.Context, teamID *uin
|
||||
return s.GetMDMAppleFileVaultSummaryFunc(ctx, teamID)
|
||||
}
|
||||
|
||||
func (s *DataStore) SetHostsRecoveryLockPasswords(ctx context.Context, passwords []fleet.HostRecoveryLockPasswordPayload) error {
|
||||
s.mu.Lock()
|
||||
s.SetHostsRecoveryLockPasswordsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.SetHostsRecoveryLockPasswordsFunc(ctx, passwords)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetHostRecoveryLockPassword(ctx context.Context, hostUUID string) (*fleet.HostRecoveryLockPassword, error) {
|
||||
s.mu.Lock()
|
||||
s.GetHostRecoveryLockPasswordFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetHostRecoveryLockPasswordFunc(ctx, hostUUID)
|
||||
}
|
||||
|
||||
func (s *DataStore) GetHostsForRecoveryLockAction(ctx context.Context) ([]string, error) {
|
||||
s.mu.Lock()
|
||||
s.GetHostsForRecoveryLockActionFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.GetHostsForRecoveryLockActionFunc(ctx)
|
||||
}
|
||||
|
||||
func (s *DataStore) SetRecoveryLockVerified(ctx context.Context, hostUUID string) error {
|
||||
s.mu.Lock()
|
||||
s.SetRecoveryLockVerifiedFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.SetRecoveryLockVerifiedFunc(ctx, hostUUID)
|
||||
}
|
||||
|
||||
func (s *DataStore) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
|
||||
s.mu.Lock()
|
||||
s.SetRecoveryLockFailedFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.SetRecoveryLockFailedFunc(ctx, hostUUID, errorMsg)
|
||||
}
|
||||
|
||||
func (s *DataStore) ClearRecoveryLockPendingStatus(ctx context.Context, hostUUIDs []string) error {
|
||||
s.mu.Lock()
|
||||
s.ClearRecoveryLockPendingStatusFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ClearRecoveryLockPendingStatusFunc(ctx, hostUUIDs)
|
||||
}
|
||||
|
||||
func (s *DataStore) InsertMDMAppleBootstrapPackage(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage, pkgStore fleet.MDMBootstrapPackageStore) error {
|
||||
s.mu.Lock()
|
||||
s.InsertMDMAppleBootstrapPackageFuncInvoked = true
|
||||
@@ -9741,6 +9818,13 @@ func (s *DataStore) ExpandEmbeddedSecretsAndUpdatedAt(ctx context.Context, docum
|
||||
return s.ExpandEmbeddedSecretsAndUpdatedAtFunc(ctx, document)
|
||||
}
|
||||
|
||||
func (s *DataStore) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
|
||||
s.mu.Lock()
|
||||
s.ExpandHostSecretsFuncInvoked = true
|
||||
s.mu.Unlock()
|
||||
return s.ExpandHostSecretsFunc(ctx, document, enrollmentID)
|
||||
}
|
||||
|
||||
func (s *DataStore) CreateEnterprise(ctx context.Context, userID uint) (uint, error) {
|
||||
s.mu.Lock()
|
||||
s.CreateEnterpriseFuncInvoked = true
|
||||
|
||||
@@ -37,6 +37,10 @@ type RetrieveBootstrapTokenFunc func(r *mdm.Request, msg *mdm.GetBootstrapToken)
|
||||
|
||||
type ExpandEmbeddedSecretsFunc func(ctx context.Context, document string) (string, error)
|
||||
|
||||
type ExpandHostSecretsFunc func(ctx context.Context, document string, enrollmentID string) (string, error)
|
||||
|
||||
type SetRecoveryLockFailedFunc func(ctx context.Context, hostUUID string, errorMsg string) error
|
||||
|
||||
type RetrievePushInfoFunc func(ctx context.Context, ids []string) (map[string]*mdm.Push, error)
|
||||
|
||||
type IsPushCertStaleFunc func(ctx context.Context, topic string, staleToken string) (bool, error)
|
||||
@@ -107,6 +111,12 @@ type MDMAppleStore struct {
|
||||
ExpandEmbeddedSecretsFunc ExpandEmbeddedSecretsFunc
|
||||
ExpandEmbeddedSecretsFuncInvoked bool
|
||||
|
||||
ExpandHostSecretsFunc ExpandHostSecretsFunc
|
||||
ExpandHostSecretsFuncInvoked bool
|
||||
|
||||
SetRecoveryLockFailedFunc SetRecoveryLockFailedFunc
|
||||
SetRecoveryLockFailedFuncInvoked bool
|
||||
|
||||
RetrievePushInfoFunc RetrievePushInfoFunc
|
||||
RetrievePushInfoFuncInvoked bool
|
||||
|
||||
@@ -241,6 +251,20 @@ func (fs *MDMAppleStore) ExpandEmbeddedSecrets(ctx context.Context, document str
|
||||
return fs.ExpandEmbeddedSecretsFunc(ctx, document)
|
||||
}
|
||||
|
||||
func (fs *MDMAppleStore) ExpandHostSecrets(ctx context.Context, document string, enrollmentID string) (string, error) {
|
||||
fs.mu.Lock()
|
||||
fs.ExpandHostSecretsFuncInvoked = true
|
||||
fs.mu.Unlock()
|
||||
return fs.ExpandHostSecretsFunc(ctx, document, enrollmentID)
|
||||
}
|
||||
|
||||
func (fs *MDMAppleStore) SetRecoveryLockFailed(ctx context.Context, hostUUID string, errorMsg string) error {
|
||||
fs.mu.Lock()
|
||||
fs.SetRecoveryLockFailedFuncInvoked = true
|
||||
fs.mu.Unlock()
|
||||
return fs.SetRecoveryLockFailedFunc(ctx, hostUUID, errorMsg)
|
||||
}
|
||||
|
||||
func (fs *MDMAppleStore) RetrievePushInfo(ctx context.Context, ids []string) (map[string]*mdm.Push, error) {
|
||||
fs.mu.Lock()
|
||||
fs.RetrievePushInfoFuncInvoked = true
|
||||
|
||||
@@ -3891,6 +3891,12 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
|
||||
return nil, ctxerr.Wrap(r.Context, err, "DeviceLocation: calling handlers")
|
||||
}
|
||||
}
|
||||
|
||||
case fleet.SetRecoveryLockCmdName:
|
||||
res := NewRecoveryLockResult(cmdResult)
|
||||
if err := svc.runCommandHandlers(r.Context, fleet.SetRecoveryLockCmdName, res); err != nil {
|
||||
return nil, ctxerr.Wrap(r.Context, err, "SetRecoveryLock: calling handlers")
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
@@ -7313,3 +7319,72 @@ func EnsureMDMAppleServiceDiscovery(ctx context.Context, ds fleet.Datastore, dep
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Apple MDM Recovery Lock Password
|
||||
|
||||
// recoveryLockResult wraps mdm.CommandResults to implement fleet.MDMCommandResults
|
||||
type recoveryLockResult struct {
|
||||
cmdResult *mdm.CommandResults
|
||||
}
|
||||
|
||||
func (r *recoveryLockResult) Raw() []byte { return r.cmdResult.Raw }
|
||||
func (r *recoveryLockResult) UUID() string { return r.cmdResult.CommandUUID }
|
||||
func (r *recoveryLockResult) HostUUID() string { return r.cmdResult.UDID } // SetRecoveryLock is device-only, UDID is always present
|
||||
|
||||
// NewRecoveryLockResult wraps an mdm.CommandResults to implement fleet.MDMCommandResults
|
||||
func NewRecoveryLockResult(cmdResult *mdm.CommandResults) fleet.MDMCommandResults {
|
||||
return &recoveryLockResult{cmdResult: cmdResult}
|
||||
}
|
||||
|
||||
// NewSetRecoveryLockResultsHandler processes SetRecoveryLock command results.
|
||||
// When a SetRecoveryLock command is acknowledged, it marks the recovery lock as verified.
|
||||
// On error, it marks the recovery lock as failed.
|
||||
func NewSetRecoveryLockResultsHandler(
|
||||
ds fleet.Datastore,
|
||||
logger *slog.Logger,
|
||||
) fleet.MDMCommandResultsHandler {
|
||||
return func(ctx context.Context, results fleet.MDMCommandResults) error {
|
||||
// Get the underlying result to access status and error chain
|
||||
rlResult, ok := results.(*recoveryLockResult)
|
||||
if !ok {
|
||||
return ctxerr.New(ctx, "SetRecoveryLock handler: unexpected results type")
|
||||
}
|
||||
|
||||
hostUUID := results.HostUUID()
|
||||
status := rlResult.cmdResult.Status
|
||||
|
||||
logger.DebugContext(ctx, "SetRecoveryLock command result received",
|
||||
"host_uuid", hostUUID,
|
||||
"command_uuid", results.UUID(),
|
||||
"status", status,
|
||||
)
|
||||
|
||||
switch status {
|
||||
case fleet.MDMAppleStatusAcknowledged:
|
||||
// ACK means the password was successfully applied - mark as verified
|
||||
if err := ds.SetRecoveryLockVerified(ctx, hostUUID); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: set recovery lock verified")
|
||||
}
|
||||
|
||||
logger.InfoContext(ctx, "SetRecoveryLock acknowledged, marked verified",
|
||||
"host_uuid", hostUUID,
|
||||
)
|
||||
|
||||
case fleet.MDMAppleStatusError, fleet.MDMAppleStatusCommandFormatError:
|
||||
errorMsg := apple_mdm.FmtErrorChain(rlResult.cmdResult.ErrorChain)
|
||||
if errorMsg == "" {
|
||||
errorMsg = "SetRecoveryLock command failed"
|
||||
}
|
||||
if err := ds.SetRecoveryLockFailed(ctx, hostUUID, errorMsg); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "SetRecoveryLock handler: set recovery lock failed")
|
||||
}
|
||||
logger.WarnContext(ctx, "SetRecoveryLock command failed",
|
||||
"host_uuid", hostUUID,
|
||||
"error", errorMsg,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,3 +284,90 @@ func TestInstalledApplicationListHandler(t *testing.T) {
|
||||
assert.True(t, ds.NewJobFuncInvoked, "should queue a polling job when expected app not in list")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetRecoveryLockResultsHandler(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := slog.Default()
|
||||
|
||||
hostUUID := "test-host-uuid"
|
||||
cmdUUID := "set-recovery-lock-cmd-uuid"
|
||||
|
||||
t.Run("acknowledged sets verified", func(t *testing.T) {
|
||||
ds := new(mock.DataStore)
|
||||
|
||||
var verifiedCalled bool
|
||||
ds.SetRecoveryLockVerifiedFunc = func(_ context.Context, hUUID string) error {
|
||||
verifiedCalled = true
|
||||
assert.Equal(t, hostUUID, hUUID)
|
||||
return nil
|
||||
}
|
||||
|
||||
handler := NewSetRecoveryLockResultsHandler(ds, logger)
|
||||
|
||||
result := NewRecoveryLockResult(&mdm.CommandResults{
|
||||
Enrollment: mdm.Enrollment{UDID: hostUUID},
|
||||
CommandUUID: cmdUUID,
|
||||
Status: fleet.MDMAppleStatusAcknowledged,
|
||||
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict></dict></plist>`),
|
||||
})
|
||||
|
||||
err := handler(ctx, result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify status was set to verified
|
||||
assert.True(t, verifiedCalled)
|
||||
})
|
||||
|
||||
t.Run("error status sets failed", func(t *testing.T) {
|
||||
ds := new(mock.DataStore)
|
||||
|
||||
var failedCalled bool
|
||||
var capturedError string
|
||||
ds.SetRecoveryLockFailedFunc = func(_ context.Context, hUUID string, errorMsg string) error {
|
||||
failedCalled = true
|
||||
assert.Equal(t, hostUUID, hUUID)
|
||||
capturedError = errorMsg
|
||||
return nil
|
||||
}
|
||||
|
||||
handler := NewSetRecoveryLockResultsHandler(ds, logger)
|
||||
|
||||
result := NewRecoveryLockResult(&mdm.CommandResults{
|
||||
Enrollment: mdm.Enrollment{UDID: hostUUID},
|
||||
CommandUUID: cmdUUID,
|
||||
Status: fleet.MDMAppleStatusError,
|
||||
ErrorChain: []mdm.ErrorChain{{ErrorCode: 12345, ErrorDomain: "test", LocalizedDescription: "Test error"}},
|
||||
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict></dict></plist>`),
|
||||
})
|
||||
|
||||
err := handler(ctx, result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, failedCalled)
|
||||
assert.Contains(t, capturedError, "Test error")
|
||||
})
|
||||
|
||||
t.Run("command format error sets failed with default message", func(t *testing.T) {
|
||||
ds := new(mock.DataStore)
|
||||
|
||||
var capturedError string
|
||||
ds.SetRecoveryLockFailedFunc = func(_ context.Context, hUUID string, errorMsg string) error {
|
||||
capturedError = errorMsg
|
||||
return nil
|
||||
}
|
||||
|
||||
handler := NewSetRecoveryLockResultsHandler(ds, logger)
|
||||
|
||||
result := NewRecoveryLockResult(&mdm.CommandResults{
|
||||
Enrollment: mdm.Enrollment{UDID: hostUUID},
|
||||
CommandUUID: cmdUUID,
|
||||
Status: fleet.MDMAppleStatusCommandFormatError,
|
||||
Raw: []byte(`<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict></dict></plist>`),
|
||||
})
|
||||
|
||||
err := handler(ctx, result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "SetRecoveryLock command failed", capturedError)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -524,6 +524,7 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl
|
||||
checkInAndCommand := NewMDMAppleCheckinAndCommandService(ds, commander, vppInstaller, opts[0].License.IsPremium(), logger, redis_key_value.New(redisPool), svc.NewActivity)
|
||||
checkInAndCommand.RegisterResultsHandler("InstalledApplicationList", NewInstalledApplicationListResultsHandler(ds, commander, logger, cfg.Server.VPPVerifyTimeout, cfg.Server.VPPVerifyRequestDelay, svc.NewActivity))
|
||||
checkInAndCommand.RegisterResultsHandler(fleet.DeviceLocationCmdName, NewDeviceLocationResultsHandler(ds, commander, logger))
|
||||
checkInAndCommand.RegisterResultsHandler(fleet.SetRecoveryLockCmdName, NewSetRecoveryLockResultsHandler(ds, logger))
|
||||
err := RegisterAppleMDMProtocolServices(
|
||||
rootMux,
|
||||
cfg.MDM,
|
||||
|
||||
Reference in New Issue
Block a user