diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 551c423522..2ef91aeacc 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -10,6 +10,61 @@ import ( "github.com/jmoiron/sqlx" ) +func (ds *Datastore) ListPendingSoftwareInstalls(ctx context.Context, hostID uint) ([]string, error) { + const stmt = ` + SELECT + execution_id + FROM + host_software_installs + WHERE + host_id = ? + AND + install_script_exit_code IS NULL + AND + pre_install_query_output IS NULL + ORDER BY + created_at ASC +` + var results []string + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, stmt, hostID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "list pending software installs") + } + return results, nil +} + +func (ds *Datastore) GetSoftwareInstallDetails(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error) { + const stmt = ` + SELECT + hsi.host_id AS host_id, + hsi.execution_id AS execution_id, + hsi.software_installer_id AS installer_id, + si.pre_install_query AS pre_install_condition, + inst.contents AS install_script, + pisnt.contents AS post_install_script + FROM + host_software_installs hsi + INNER JOIN + software_installers si + ON hsi.software_installer_id = si.id + LEFT OUTER JOIN + script_contents inst + ON inst.id = si.install_script_content_id + LEFT OUTER JOIN + script_contents pisnt + ON pisnt.id = si.post_install_script_content_id + WHERE + hsi.execution_id = ?` + + result := &fleet.SoftwareInstallDetails{} + if err := sqlx.GetContext(ctx, ds.reader(ctx), result, stmt, executionId); err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("SoftwareInstallerDetails").WithName(executionId), "get software installer details") + } + return nil, ctxerr.Wrap(ctx, err, "list pending software installs") + } + return result, nil +} + func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { titleID, err := ds.getOrGenerateSoftwareInstallerTitleID(ctx, payload.Title, payload.Source) if err != nil { @@ -38,13 +93,13 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload stmt := ` INSERT INTO software_installers ( team_id, - global_or_team_id, - title_id, + global_or_team_id, + title_id, storage_id, - filename, + filename, version, - install_script_content_id, - pre_install_query, + install_script_content_id, + pre_install_query, post_install_script_content_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` @@ -105,9 +160,9 @@ SELECT pre_install_query, post_install_script_content_id, uploaded_at -FROM +FROM software_installers -WHERE +WHERE id = ?` var dest fleet.SoftwareInstaller diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 1ecad3de90..a0e9b91cec 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -2,11 +2,16 @@ package mysql import ( "context" + "database/sql" "testing" + "time" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -17,6 +22,7 @@ func TestSoftwareInstallers(t *testing.T) { name string fn func(t *testing.T, ds *Datastore) }{ + {"SoftwareInstallerDetails", testListSoftwareInstallerDetails}, {"InsertSoftwareInstallRequest", testInsertSoftwareInstallRequest}, {"GetSoftwareInstallResults", testGetSoftwareInstallResult}, } @@ -29,6 +35,142 @@ func TestSoftwareInstallers(t *testing.T) { } } +func testListSoftwareInstallerDetails(t *testing.T, ds *Datastore) { + ctx := context.Background() + + host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now()) + host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now()) + + script1, err := insertScriptContents(ctx, "hello", ds.writer(ctx)) + require.NoError(t, err) + script1Id, err := script1.LastInsertId() + require.NoError(t, err) + + script2, err := insertScriptContents(ctx, "world", ds.writer(ctx)) + require.NoError(t, err) + script2Id, err := script2.LastInsertId() + require.NoError(t, err) + + installer1, err := insertSoftwareInstaller(ctx, ds.writer(ctx), "file1", "1.0", "SELECT 1", "storage1", script1Id, script2Id) + require.NoError(t, err) + installer1Id, err := installer1.LastInsertId() + require.NoError(t, err) + + installer2, err := insertSoftwareInstaller(ctx, ds.writer(ctx), "file2", "2.0", "SELECT 2", "storage2", script2Id, script1Id) + require.NoError(t, err) + installer2Id, err := installer2.LastInsertId() + require.NoError(t, err) + + hostInstall1, err := insertHostSoftwareInstalls(ctx, ds.writer(ctx), host1.ID, "exec1", uint(installer1Id)) + require.NoError(t, err) + _ = hostInstall1 + + hostInstall2, err := insertHostSoftwareInstalls(ctx, ds.writer(ctx), host1.ID, "exec2", uint(installer2Id)) + require.NoError(t, err) + _ = hostInstall2 + + hostInstall3, err := insertHostSoftwareInstalls(ctx, ds.writer(ctx), host2.ID, "exec3", uint(installer1Id)) + require.NoError(t, err) + _ = hostInstall3 + + hostInstall4, err := insertHostSoftwareInstalls(ctx, ds.writer(ctx), host2.ID, "exec4", uint(installer2Id)) + require.NoError(t, err) + hostInstall4Id, err := hostInstall4.LastInsertId() + require.NoError(t, err) + + _ = ds.writer(ctx).MustExec("UPDATE host_software_installs SET install_script_exit_code = 0 WHERE id = ?", hostInstall4Id) + + hostInstall5, err := insertHostSoftwareInstalls(ctx, ds.writer(ctx), host2.ID, "exec5", uint(installer2Id)) + require.NoError(t, err) + hostInstall5Id, err := hostInstall5.LastInsertId() + require.NoError(t, err) + + _ = ds.writer(ctx).MustExec("UPDATE host_software_installs SET pre_install_query_output = 'output' WHERE id = ?", hostInstall5Id) + + installDetailsList1, err := ds.ListPendingSoftwareInstalls(ctx, host1.ID) + require.NoError(t, err) + require.Equal(t, 2, len(installDetailsList1)) + + installDetailsList2, err := ds.ListPendingSoftwareInstalls(ctx, host2.ID) + require.NoError(t, err) + require.Equal(t, 1, len(installDetailsList2)) + + require.Contains(t, installDetailsList1, "exec1") + require.Contains(t, installDetailsList1, "exec2") + + require.Contains(t, installDetailsList2, "exec3") + + exec1, err := ds.GetSoftwareInstallDetails(ctx, "exec1") + require.NoError(t, err) + + require.Equal(t, host1.ID, exec1.HostID) + require.Equal(t, "exec1", exec1.ExecutionID) + require.Equal(t, "hello", exec1.InstallScript) + require.Equal(t, "world", exec1.PostInstallScript) + require.Equal(t, uint(installer1Id), exec1.InstallerID) + require.Equal(t, "SELECT 1", exec1.PreInstallCondition) +} + +func insertHostSoftwareInstalls( + ctx context.Context, + tx sqlx.ExtContext, + hostId uint, + executionId string, + softwareInstallerId uint, +) (sql.Result, error) { + stmt := ` + INSERT INTO host_software_installs ( + host_id, + execution_id, + software_installer_id + ) VALUES (?, ?, ?) +` + res, err := tx.ExecContext(ctx, stmt, hostId, executionId, softwareInstallerId) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "inserting host software install") + } + + return res, nil +} + +func insertSoftwareInstaller( + ctx context.Context, + tx sqlx.ExtContext, + filename, + version, + preinstallQuery, + storageId string, + installScriptId, + postInstallScriptId int64, +) (sql.Result, error) { + stmt := ` + INSERT INTO software_installers ( + filename, + version, + pre_install_query, + install_script_content_id, + post_install_script_content_id, + storage_id + ) + VALUES (?, ?, ?, ?, ?, ?) +` + res, err := tx.ExecContext(ctx, + stmt, + filename, + version, + preinstallQuery, + installScriptId, + postInstallScriptId, + storageId, + ) + + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "inserting software installer") + } + + return res, nil +} + func testInsertSoftwareInstallRequest(t *testing.T, ds *Datastore) { ctx := context.Background() diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 9883dd1182..47689e340d 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1467,6 +1467,13 @@ type Datastore interface { // Software installers // + // GetSoftwareInstallDetails returns details required to fetch and + // run software installers + GetSoftwareInstallDetails(ctx context.Context, executionId string) (*SoftwareInstallDetails, error) + // ListPendingSoftwareInstalls returns a list of software + // installer execution IDs that have not yet been run for a given host + ListPendingSoftwareInstalls(ctx context.Context, hostID uint) ([]string, error) + // MatchOrCreateSoftwareInstaller matches or creates a new software installer. MatchOrCreateSoftwareInstaller(ctx context.Context, payload *UploadSoftwareInstallerPayload) (uint, error) diff --git a/server/fleet/service.go b/server/fleet/service.go index bacdcf55f5..f4ef1f5fca 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -670,6 +670,11 @@ type Service interface { GetInstaller(ctx context.Context, installer Installer) (io.ReadCloser, int64, error) CheckInstallerExistence(ctx context.Context, installer Installer) error + //////////////////////////////////////////////////////////////////////////////// + // Software Installers + + GetSoftwareInstallDetails(ctx context.Context, installUUID string) (*SoftwareInstallDetails, error) + // ///////////////////////////////////////////////////////////////////////////// // Apple MDM diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 68081a4ec0..de0f927989 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -35,6 +35,24 @@ func (FailingSoftwareInstallerStore) Exists(ctx context.Context, installerID str return false, errors.New("software installer store not properly configured") } +// SoftwareInstallDetailsResult contains all of the information +// required for a client to pull in and install software from the fleet server +type SoftwareInstallDetails struct { + // HostID is used for authentication on the backend and should not + // be passed to the client + HostID uint `json:"-" db:"host_id"` + // ExecutionID is a unique identifier for this installation + ExecutionID string `json:"install_id" db:"execution_id"` + // InstallerID is the unique identifier for the software package metadata in Fleet. + InstallerID uint `json:"installer_id" db:"installer_id"` + // PreInstallCondition is the query to run as a condition to installing the software package. + PreInstallCondition string `json:"pre_install_condition" db:"pre_install_condition"` + // InstallScript is the script to run to install the software package. + InstallScript string `json:"install_script" db:"install_script"` + // PostInstallScript is the script to run after installing the software package. + PostInstallScript string `json:"post_install_script" db:"post_install_script"` +} + // SoftwareInstaller represents a software installer package that can be used to install software on // hosts in Fleet. type SoftwareInstaller struct { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 4d34597c28..fe96f32527 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -925,6 +925,10 @@ type WipeHostViaWindowsMDMFunc func(ctx context.Context, host *fleet.Host, cmd * type UpdateHostLockWipeStatusFromAppleMDMResultFunc func(ctx context.Context, hostUUID string, cmdUUID string, requestType string, succeeded bool) error +type GetSoftwareInstallDetailsFunc func(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error) + +type ListPendingSoftwareInstallsFunc func(ctx context.Context, hostID uint) ([]string, error) + type MatchOrCreateSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) type GetSoftwareInstallerMetadataFunc func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) @@ -2293,6 +2297,12 @@ type DataStore struct { UpdateHostLockWipeStatusFromAppleMDMResultFunc UpdateHostLockWipeStatusFromAppleMDMResultFunc UpdateHostLockWipeStatusFromAppleMDMResultFuncInvoked bool + GetSoftwareInstallDetailsFunc GetSoftwareInstallDetailsFunc + GetSoftwareInstallDetailsFuncInvoked bool + + ListPendingSoftwareInstallsFunc ListPendingSoftwareInstallsFunc + ListPendingSoftwareInstallsFuncInvoked bool + MatchOrCreateSoftwareInstallerFunc MatchOrCreateSoftwareInstallerFunc MatchOrCreateSoftwareInstallerFuncInvoked bool @@ -5479,6 +5489,20 @@ func (s *DataStore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Conte return s.UpdateHostLockWipeStatusFromAppleMDMResultFunc(ctx, hostUUID, cmdUUID, requestType, succeeded) } +func (s *DataStore) GetSoftwareInstallDetails(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error) { + s.mu.Lock() + s.GetSoftwareInstallDetailsFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwareInstallDetailsFunc(ctx, executionId) +} + +func (s *DataStore) ListPendingSoftwareInstalls(ctx context.Context, hostID uint) ([]string, error) { + s.mu.Lock() + s.ListPendingSoftwareInstallsFuncInvoked = true + s.mu.Unlock() + return s.ListPendingSoftwareInstallsFunc(ctx, hostID) +} + func (s *DataStore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { s.mu.Lock() s.MatchOrCreateSoftwareInstallerFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index 6b0d30b7d6..e37828e1b8 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -813,6 +813,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC oe.POST("/api/fleet/orbit/software_install/package", orbitDownloadSoftwareInstallerEndpoint, orbitDownloadSoftwareInstallerRequest{}) + oe.POST("/api/fleet/orbit/software_install/details", getOrbitSoftwareInstallDetails, orbitGetSoftwareInstallRequest{}) + oeWindowsMDM := oe.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM()) oeWindowsMDM.POST("/api/fleet/orbit/disk_encryption_key", postOrbitDiskEncryptionKeyEndpoint, orbitPostDiskEncryptionKeyRequest{}) diff --git a/server/service/orbit.go b/server/service/orbit.go index 23522c0ea7..645cef2c03 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -748,6 +748,19 @@ func (svc *Service) SetOrUpdateDiskEncryptionKey(ctx context.Context, encryption } ///////////////////////////////////////////////////////////////////////////////// +// Get Orbit pending software installations +///////////////////////////////////////////////////////////////////////////////// + +type orbitGetSoftwareInstallRequest struct { + OrbitNodeKey string `json:"orbot_node_key"` + InstallUUID string `json:"install_uuid"` +} + +// interface implementation required by the OrbitClient +func (r *orbitGetSoftwareInstallRequest) setOrbitNodeKey(nodeKey string) { + r.OrbitNodeKey = nodeKey +} + // Download Orbit software installer request ///////////////////////////////////////////////////////////////////////////////// @@ -763,6 +776,48 @@ func (r *orbitDownloadSoftwareInstallerRequest) setOrbitNodeKey(nodeKey string) } // interface implementation required by orbit authentication +func (r *orbitGetSoftwareInstallRequest) orbitHostNodeKey() string { + return r.OrbitNodeKey +} + +type orbitGetSoftwareInstallResponse struct { + Err error `json:"error,omitempty"` + *fleet.SoftwareInstallDetails +} + +func (r orbitGetSoftwareInstallResponse) error() error { return r.Err } + +func getOrbitSoftwareInstallDetails(ctx context.Context, request any, svc fleet.Service) (errorer, error) { + req := request.(*orbitGetSoftwareInstallRequest) + details, err := svc.GetSoftwareInstallDetails(ctx, req.InstallUUID) + if err != nil { + return orbitGetSoftwareInstallResponse{Err: err}, nil + } + + return orbitGetSoftwareInstallResponse{SoftwareInstallDetails: details}, nil +} + +func (svc *Service) GetSoftwareInstallDetails(ctx context.Context, installUUID string) (*fleet.SoftwareInstallDetails, error) { + // this is not a user-authenticated endpoint + svc.authz.SkipAuthorization(ctx) + + host, ok := hostctx.FromContext(ctx) + if !ok { + return nil, fleet.OrbitError{Message: "internal error: missing host from request context"} + } + + details, err := svc.ds.GetSoftwareInstallDetails(ctx, installUUID) + if err != nil { + return nil, err + } + + // ensure it cannot get access to a different host's installers + if details.HostID != host.ID { + return nil, ctxerr.Wrap(ctx, newNotFoundError(), "no installer found for this host") + } + return details, nil +} + func (r *orbitDownloadSoftwareInstallerRequest) orbitHostNodeKey() string { return r.OrbitNodeKey } diff --git a/server/service/orbit_test.go b/server/service/orbit_test.go index 97a203d86b..f0cf515c64 100644 --- a/server/service/orbit_test.go +++ b/server/service/orbit_test.go @@ -315,3 +315,45 @@ func TestGetOrbitConfigNudge(t *testing.T) { require.True(t, ds.GetHostOperatingSystemFuncInvoked) }) } + +func TestGetSoftwareInstallDetails(t *testing.T) { + t.Run("hosts can't get each others installers", func(t *testing.T) { + ds := new(mock.Store) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium} + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true}) + + ds.GetSoftwareInstallDetailsFunc = func(ctx context.Context, executionId string) (*fleet.SoftwareInstallDetails, error) { + return &fleet.SoftwareInstallDetails{ + HostID: 1, + }, nil + } + + goodCtx := test.HostContext(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("test"), + ID: 1, + MDMInfo: &fleet.HostMDM{ + IsServer: false, + InstalledFromDep: true, + Enrolled: true, + Name: fleet.WellKnownMDMFleet, + }}) + + badCtx := test.HostContext(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("test"), + ID: 2, + MDMInfo: &fleet.HostMDM{ + IsServer: false, + InstalledFromDep: true, + Enrolled: true, + Name: fleet.WellKnownMDMFleet, + }}) + + d1, err := svc.GetSoftwareInstallDetails(goodCtx, "") + require.NoError(t, err) + require.Equal(t, uint(1), d1.HostID) + + d2, err := svc.GetSoftwareInstallDetails(badCtx, "") + require.Error(t, err) + require.Nil(t, d2) + }) +}