diff --git a/cmd/fleetctl/mdm_test.go b/cmd/fleetctl/mdm_test.go index 0a7062de30..e708c60dec 100644 --- a/cmd/fleetctl/mdm_test.go +++ b/cmd/fleetctl/mdm_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "slices" "testing" "time" @@ -487,6 +488,21 @@ func TestMDMLockCommand(t *testing.T) { return h.MDMInfo, nil } + ds.GetHostOrbitInfoFunc = func(ctx context.Context, hostID uint) (*fleet.HostOrbitInfo, error) { + hostIDMod := hostID % 3 + switch hostIDMod { + case 0: + return nil, ¬FoundError{} + case 1: + return &fleet.HostOrbitInfo{}, nil + case 2: + return &fleet.HostOrbitInfo{ScriptsEnabled: ptr.Bool(true)}, nil + default: + t.Errorf("unexpected hostIDMod %v", hostIDMod) + return nil, nil + } + } + appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM := setupAppConigs() successfulOutput := func(ident string) string { @@ -730,6 +746,21 @@ func TestMDMUnlockCommand(t *testing.T) { return h.MDMInfo, nil } + ds.GetHostOrbitInfoFunc = func(ctx context.Context, hostID uint) (*fleet.HostOrbitInfo, error) { + hostIDMod := hostID % 3 + switch hostIDMod { + case 0: + return nil, ¬FoundError{} + case 1: + return &fleet.HostOrbitInfo{}, nil + case 2: + return &fleet.HostOrbitInfo{ScriptsEnabled: ptr.Bool(true)}, nil + default: + t.Errorf("unexpected hostIDMod %v", hostIDMod) + return nil, nil + } + } + appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM := setupAppConigs() successfulOutput := func(ident string) string { @@ -793,11 +824,6 @@ func TestMDMWipeCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } - linuxEnrolled := &fleet.Host{ - ID: 3, - UUID: "linux-enrolled", - Platform: "linux", - } winNotEnrolled := &fleet.Host{ ID: 4, UUID: "win-not-enrolled", @@ -892,6 +918,23 @@ func TestMDMWipeCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual")}, } + linuxEnrolled := &fleet.Host{ + ID: 18, + UUID: "linux-enrolled", + Platform: "linux", + } + linuxEnrolled2 := &fleet.Host{ + ID: 19, + UUID: "linux-enrolled", + Platform: "linux", + } + linuxEnrolled3 := &fleet.Host{ + ID: 20, + UUID: "linux-enrolled", + Platform: "linux", + } + + linuxHostIDs := []uint{linuxEnrolled.ID, linuxEnrolled2.ID, linuxEnrolled3.ID} hostByUUID := make(map[string]*fleet.Host) hostsByID := make(map[uint]*fleet.Host) @@ -899,6 +942,8 @@ func TestMDMWipeCommand(t *testing.T) { winEnrolled, macEnrolled, linuxEnrolled, + linuxEnrolled2, + linuxEnrolled3, macNotEnrolled, winNotEnrolled, macPending, @@ -1039,6 +1084,26 @@ func TestMDMWipeCommand(t *testing.T) { return h.MDMInfo, nil } + // This function should only run on linux + ds.GetHostOrbitInfoFunc = func(ctx context.Context, hostID uint) (*fleet.HostOrbitInfo, error) { + if !slices.Contains(linuxHostIDs, hostID) { + t.Errorf("GetHostOrbitInfo should not be called for non-linux host %v", hostID) + return nil, nil + } + hostIDMod := hostID % 3 + switch hostIDMod { + case 0: + return nil, ¬FoundError{} + case 1: + return &fleet.HostOrbitInfo{}, nil + case 2: + return &fleet.HostOrbitInfo{ScriptsEnabled: ptr.Bool(true)}, nil + default: + t.Errorf("unexpected hostIDMod %v", hostIDMod) + return nil, nil + } + } + appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM := setupAppConigs() appCfgScriptsDisabled := &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ScriptsDisabled: true}} @@ -1055,6 +1120,8 @@ func TestMDMWipeCommand(t *testing.T) { {appCfgAllMDM, "valid windows", []string{"--host", winEnrolled.UUID}, ""}, {appCfgAllMDM, "valid macos", []string{"--host", macEnrolled.UUID}, ""}, {appCfgNoMDM, "valid linux", []string{"--host", linuxEnrolled.UUID}, ""}, + {appCfgNoMDM, "valid linux 2", []string{"--host", linuxEnrolled2.UUID}, ""}, + {appCfgNoMDM, "valid linux 3", []string{"--host", linuxEnrolled3.UUID}, ""}, {appCfgNoMDM, "valid windows but no mdm", []string{"--host", winEnrolled.UUID}, `Windows MDM isn't turned on.`}, {appCfgMacMDM, "valid macos but not enrolled", []string{"--host", macNotEnrolled.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, {appCfgWinMDM, "valid windows but not enrolled", []string{"--host", winNotEnrolled.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, diff --git a/ee/server/service/hosts.go b/ee/server/service/hosts.go index f6f9168516..4bd442516a 100644 --- a/ee/server/service/hosts.go +++ b/ee/server/service/hosts.go @@ -96,6 +96,20 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { if appCfg.ServerSettings.ScriptsDisabled { return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock host because running scripts is disabled in organization settings.")) } + hostOrbitInfo, err := svc.ds.GetHostOrbitInfo(ctx, host.ID) + switch { + case err != nil: + // If not found, then do nothing. We do not know if this host has scripts enabled or not + if !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "get host orbit info") + } + case hostOrbitInfo.ScriptsEnabled != nil && !*hostOrbitInfo.ScriptsEnabled: + return ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError( + "host_id", "Couldn't lock host. To lock, deploy the fleetd agent with --enable-scripts and refetch host vitals.", + ), + ) + } default: return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) @@ -166,6 +180,20 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) if appCfg.ServerSettings.ScriptsDisabled { return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't unlock host because running scripts is disabled in organization settings.")) } + hostOrbitInfo, err := svc.ds.GetHostOrbitInfo(ctx, host.ID) + switch { + case err != nil: + // If not found, then do nothing. We do not know if this host has scripts enabled or not + if !fleet.IsNotFound(err) { + return "", ctxerr.Wrap(ctx, err, "get host orbit info") + } + case hostOrbitInfo.ScriptsEnabled != nil && !*hostOrbitInfo.ScriptsEnabled: + return "", ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError( + "host_id", "Couldn't unlock host. To unlock, deploy the fleetd agent with --enable-scripts and refetch host vitals.", + ), + ) + } default: return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) @@ -248,6 +276,20 @@ func (svc *Service) WipeHost(ctx context.Context, hostID uint) error { if appCfg.ServerSettings.ScriptsDisabled { return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't wipe host because running scripts is disabled in organization settings.")) } + hostOrbitInfo, err := svc.ds.GetHostOrbitInfo(ctx, host.ID) + switch { + case err != nil: + // If not found, then do nothing. We do not know if this host has scripts enabled or not + if !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "get host orbit info") + } + case hostOrbitInfo.ScriptsEnabled != nil && !*hostOrbitInfo.ScriptsEnabled: + return ctxerr.Wrap( + ctx, fleet.NewInvalidArgumentError( + "host_id", "Couldn't wipe host. To wipe, deploy the fleetd agent with --enable-scripts and refetch host vitals.", + ), + ) + } default: return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 2a675fa0ef..6daf64222b 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -3708,6 +3708,25 @@ func (ds *Datastore) SetOrUpdateHostOrbitInfo( ) } +func (ds *Datastore) GetHostOrbitInfo(ctx context.Context, hostID uint) (*fleet.HostOrbitInfo, error) { + var orbit fleet.HostOrbitInfo + err := sqlx.GetContext( + ctx, ds.reader(ctx), &orbit, ` + SELECT + scripts_enabled + FROM + host_orbit_info + WHERE host_id = ?`, hostID, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("HostOrbitInfo").WithID(hostID)) + } + return nil, ctxerr.Wrapf(ctx, err, "select host_orbit_info for host_id %d", hostID) + } + return &orbit, nil +} + func (ds *Datastore) getOrInsertMDMSolution(ctx context.Context, serverURL string, mdmName string) (mdmID uint, err error) { readStmt := ¶meterizedStmt{ Statement: `SELECT id FROM mobile_device_management_solutions WHERE name = ? AND server_url = ?`, diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index e2841849e2..16c3d08bf5 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -163,6 +163,7 @@ func TestHosts(t *testing.T) { {"ListHostsWithPagination", testListHostsWithPagination}, {"LastRestarted", testLastRestarted}, {"HostHealth", testHostHealth}, + {"GetHostOrbitInfo", testGetHostOrbitInfo}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -8756,3 +8757,41 @@ func testHostHealth(t *testing.T, ds *Datastore) { require.Empty(t, hh.VulnerableSoftware) require.Equal(t, h.TeamID, hh.TeamID) } + +func testGetHostOrbitInfo(t *testing.T, ds *Datastore) { + host, err := ds.NewHost( + context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("1"), + UUID: "1", + Hostname: "foo.local", + PrimaryIP: "192.168.1.1", + PrimaryMac: "30-65-EC-6F-C4-58", + }, + ) + require.NoError(t, err) + require.NotNil(t, host) + + _, err = ds.GetHostOrbitInfo(context.Background(), host.ID) + require.True(t, fleet.IsNotFound(err)) + + orbitVersion := "1.1.0" + err = ds.SetOrUpdateHostOrbitInfo( + context.Background(), host.ID, orbitVersion, sql.NullString{Valid: false}, sql.NullBool{Valid: false}, + ) + require.NoError(t, err) + hostOrbitInfo, err := ds.GetHostOrbitInfo(context.Background(), host.ID) + require.NoError(t, err) + assert.Nil(t, hostOrbitInfo.ScriptsEnabled) + + err = ds.SetOrUpdateHostOrbitInfo( + context.Background(), host.ID, orbitVersion, sql.NullString{Valid: false}, sql.NullBool{Bool: true, Valid: true}, + ) + require.NoError(t, err) + hostOrbitInfo, err = ds.GetHostOrbitInfo(context.Background(), host.ID) + require.NoError(t, err) + assert.True(t, *hostOrbitInfo.ScriptsEnabled) +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index d549464d2a..3292e8740d 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -841,6 +841,8 @@ type Datastore interface { ctx context.Context, hostID uint, version string, desktopVersion sql.NullString, scriptsEnabled sql.NullBool, ) error + GetHostOrbitInfo(ctx context.Context, hostID uint) (*HostOrbitInfo, error) + ReplaceHostDeviceMapping(ctx context.Context, id uint, mappings []*HostDeviceMapping, source string) error // ReplaceHostBatteries creates or updates the battery mappings of a host. diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 441e9e7b2e..809ab12d54 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -359,6 +359,11 @@ type Host struct { Policies *[]*HostPolicy `json:"policies,omitempty" csv:"-"` } +// HostOrbitInfo maps to the host_orbit_info table in the database, which maps to the orbit_info agent table. +type HostOrbitInfo struct { + ScriptsEnabled *bool `json:"scripts_enabled" db:"scripts_enabled"` +} + // HostHealth contains a subset of Host data that indicates how healthy a Host is. For fields with // the same name, see the comments/docs for the Host field above. type HostHealth struct { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index c94e88e953..1ed9fe53eb 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -589,6 +589,8 @@ type GetHostMDMProfileRetryCountByCommandUUIDFunc func(ctx context.Context, host type SetOrUpdateHostOrbitInfoFunc func(ctx context.Context, hostID uint, version string, desktopVersion sql.NullString, scriptsEnabled sql.NullBool) error +type GetHostOrbitInfoFunc func(ctx context.Context, hostID uint) (*fleet.HostOrbitInfo, error) + type ReplaceHostDeviceMappingFunc func(ctx context.Context, id uint, mappings []*fleet.HostDeviceMapping, source string) error type ReplaceHostBatteriesFunc func(ctx context.Context, id uint, mappings []*fleet.HostBattery) error @@ -1755,6 +1757,9 @@ type DataStore struct { SetOrUpdateHostOrbitInfoFunc SetOrUpdateHostOrbitInfoFunc SetOrUpdateHostOrbitInfoFuncInvoked bool + GetHostOrbitInfoFunc GetHostOrbitInfoFunc + GetHostOrbitInfoFuncInvoked bool + ReplaceHostDeviceMappingFunc ReplaceHostDeviceMappingFunc ReplaceHostDeviceMappingFuncInvoked bool @@ -4218,6 +4223,13 @@ func (s *DataStore) SetOrUpdateHostOrbitInfo(ctx context.Context, hostID uint, v return s.SetOrUpdateHostOrbitInfoFunc(ctx, hostID, version, desktopVersion, scriptsEnabled) } +func (s *DataStore) GetHostOrbitInfo(ctx context.Context, hostID uint) (*fleet.HostOrbitInfo, error) { + s.mu.Lock() + s.GetHostOrbitInfoFuncInvoked = true + s.mu.Unlock() + return s.GetHostOrbitInfoFunc(ctx, hostID) +} + func (s *DataStore) ReplaceHostDeviceMapping(ctx context.Context, id uint, mappings []*fleet.HostDeviceMapping, source string) error { s.mu.Lock() s.ReplaceHostDeviceMappingFuncInvoked = true diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 957f41623f..2b53cff98b 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -7244,6 +7244,28 @@ func (s *integrationEnterpriseTestSuite) TestLockUnlockWipeWindowsLinux() { errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Windows MDM isn't turned on.") + // Disable scripts on Linux host + err := s.ds.SetOrUpdateHostOrbitInfo( + context.Background(), linuxHost.ID, "1.22.0", sql.NullString{}, sql.NullBool{Bool: false, Valid: true}, + ) + require.NoError(t, err) + // try to lock/unlock/wipe the Linux host. Fails because scripts are not enabled. + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", linuxHost.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't lock host. To lock, deploy the fleetd agent with --enable-scripts") + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", linuxHost.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't unlock host. To unlock, deploy the fleetd agent with --enable-scripts") + 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, "Couldn't wipe host. To wipe, deploy the fleetd agent with --enable-scripts") + + // Enable scripts on Linux host + err = s.ds.SetOrUpdateHostOrbitInfo( + context.Background(), linuxHost.ID, "1.22.0", sql.NullString{}, sql.NullBool{Bool: true, Valid: true}, + ) + require.NoError(t, err) + // 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)