diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 20f39d2a4937..54f3ff943b8a 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -1406,12 +1406,15 @@ func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host } if host.FleetPlatform() != requiredPlatform { - return &fleet.BadRequestError{ - Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform), - InternalErr: ctxerr.NewWithData( - ctx, "invalid host platform for requested installer", - map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": installer.TitleID}, - ), + // Allow .sh scripts for any unix-like platform (linux and darwin) + if !(ext == ".sh" && fleet.IsUnixLike(host.Platform)) { + return &fleet.BadRequestError{ + Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform), + InternalErr: ctxerr.NewWithData( + ctx, "invalid host platform for requested installer", + map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": installer.TitleID}, + ), + } } } @@ -2655,12 +2658,15 @@ func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *f } if host.FleetPlatform() != requiredPlatform { - return &fleet.BadRequestError{ - Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform), - InternalErr: ctxerr.WrapWithData( - ctx, err, "invalid host platform for requested installer", - map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID}, - ), + // Allow .sh scripts for any unix-like platform (linux and darwin) + if !(ext == ".sh" && fleet.IsUnixLike(host.Platform)) { + return &fleet.BadRequestError{ + Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform), + InternalErr: ctxerr.WrapWithData( + ctx, err, "invalid host platform for requested installer", + map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID}, + ), + } } } diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go index 119aca5f4ca5..8f532dc0a7a5 100644 --- a/ee/server/service/software_installers_test.go +++ b/ee/server/service/software_installers_test.go @@ -621,3 +621,125 @@ func TestAddScriptPackageMetadata(t *testing.T) { require.Equal(t, scriptContents, payload.InstallScript) }) } + +// TestInstallShScriptOnDarwin tests that .sh scripts (stored as platform='linux') +// can be installed on darwin hosts. +func TestInstallShScriptOnDarwin(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + svc := newTestService(t, ds) + + // Mock darwin host + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ + ID: 1, + OrbitNodeKey: ptr.String("orbit_key"), + Platform: "darwin", + TeamID: ptr.Uint(1), + }, nil + } + + // Not an in-house app + ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { + return nil, nil + } + + // Mock .sh installer metadata (platform='linux' as .sh files are stored) + ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { + return &fleet.SoftwareInstaller{ + InstallerID: 10, + Name: "script.sh", + Extension: "sh", + Platform: "linux", // .sh stored as linux + TeamID: ptr.Uint(1), + TitleID: ptr.Uint(100), + SelfService: false, + }, nil + } + + // Label scoping check passes + ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { + return true, nil + } + + // No pending install + ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { + return nil, nil + } + + // Capture that install request was inserted + insertCalled := false + ds.InsertSoftwareInstallRequestFunc = func(ctx context.Context, hostID uint, softwareInstallerID uint, opts fleet.HostSoftwareInstallOptions) (string, error) { + insertCalled = true + return "install-uuid", nil + } + + // Create admin user context + ctx := viewer.NewContext(context.Background(), viewer.Viewer{ + User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + }) + + // Install .sh on darwin should succeed (not return BadRequestError) + err := svc.InstallSoftwareTitle(ctx, 1, 100) + require.NoError(t, err, ".sh install on darwin should succeed") + require.True(t, insertCalled, "install request should be created") +} + +// TestInstallShScriptOnWindowsFails tests that .sh scripts can't be installed on Windows hosts. +func TestInstallShScriptOnWindowsFails(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + svc := newTestService(t, ds) + + // Mock Windows host + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return &fleet.Host{ + ID: 1, + OrbitNodeKey: ptr.String("orbit_key"), + Platform: "windows", + TeamID: ptr.Uint(1), + }, nil + } + + // Not an in-house app + ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { + return nil, nil + } + + // Mock .sh installer metadata + ds.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { + return &fleet.SoftwareInstaller{ + InstallerID: 10, + Name: "script.sh", + Extension: "sh", + Platform: "linux", + TeamID: ptr.Uint(1), + TitleID: ptr.Uint(100), + SelfService: false, + }, nil + } + + // Label scoping check passes + ds.IsSoftwareInstallerLabelScopedFunc = func(ctx context.Context, installerID, hostID uint) (bool, error) { + return true, nil + } + + // No pending install + ds.GetHostLastInstallDataFunc = func(ctx context.Context, hostID, installerID uint) (*fleet.HostLastInstallData, error) { + return nil, nil + } + + // Create admin user context + ctx := viewer.NewContext(context.Background(), viewer.Viewer{ + User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + }) + + // Install .sh on windows should fail with BadRequestError + err := svc.InstallSoftwareTitle(ctx, 1, 100) + require.Error(t, err, ".sh install on windows should fail") + + var bre *fleet.BadRequestError + require.ErrorAs(t, err, &bre, "error should be BadRequestError") + require.NotNil(t, bre) + require.Contains(t, bre.Message, "can be installed only on linux hosts") +} diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index b05cf9a9dee5..5a355a4cd3a8 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -4547,7 +4547,8 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt software_titles st LEFT OUTER JOIN -- filter out software that is not available for install on the host's platform - software_installers si ON st.id = si.title_id AND si.platform = :host_compatible_platforms AND si.extension NOT IN (:incompatible_extensions) AND si.global_or_team_id = :global_or_team_id + -- .sh packages are available for both linux and darwin hosts + software_installers si ON st.id = si.title_id AND (si.platform = :host_compatible_platforms OR (si.extension = 'sh' AND si.platform = 'linux' AND :host_compatible_platforms = 'darwin')) AND si.extension NOT IN (:incompatible_extensions) AND si.global_or_team_id = :global_or_team_id LEFT OUTER JOIN -- include VPP apps only if the host is on a supported platform vpp_apps vap ON st.id = vap.title_id AND :host_platform IN (:vpp_apps_platforms) diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index faf08f623c1f..e900e3b79483 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -2768,7 +2768,9 @@ func (ds *Datastore) HasSelfServiceSoftwareInstallers(ctx context.Context, hostP WHERE EXISTS ( SELECT 1 FROM software_installers - WHERE self_service = 1 AND platform = ? AND global_or_team_id = ? + WHERE self_service = 1 + AND (platform = ? OR (extension = 'sh' AND platform = 'linux' AND ? = 'darwin')) + AND global_or_team_id = ? ) OR EXISTS ( SELECT 1 FROM vpp_apps_teams @@ -2778,7 +2780,7 @@ func (ds *Datastore) HasSelfServiceSoftwareInstallers(ctx context.Context, hostP if hostTeamID != nil { globalOrTeamID = *hostTeamID } - args := []interface{}{hostPlatform, globalOrTeamID, hostPlatform, globalOrTeamID} + args := []any{hostPlatform, hostPlatform, globalOrTeamID, hostPlatform, globalOrTeamID} var hasInstallers bool err := sqlx.GetContext(ctx, ds.reader(ctx), &hasInstallers, stmt, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 3a1fee9f6eab..573efdb4e498 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -2085,6 +2085,46 @@ func testHasSelfServiceSoftwareInstallers(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.True(t, hasSelfService) + // Create a new team for .sh testing + teamSh, err := ds.NewTeam(ctx, &fleet.Team{Name: "team sh darwin test"}) + require.NoError(t, err) + + // Initially, darwin should not see any self-service installers in this team + hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "darwin", &teamSh.ID) + require.NoError(t, err) + assert.False(t, hasSelfService, "darwin should not see self-service before .sh is created") + + // Create a self-service .sh installer (stored as platform='linux', extension='sh') + // This should be visible to darwin hosts due to the .sh exception + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + Title: "sh script for darwin", + Source: "sh_packages", + InstallScript: "#!/bin/bash\necho install", + TeamID: &teamSh.ID, + Filename: "script.sh", + Platform: "linux", // .sh files are stored as linux + Extension: "sh", + SelfService: true, + UserID: user1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + // Darwin host should now see self-service .sh package + hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "darwin", &teamSh.ID) + require.NoError(t, err) + assert.True(t, hasSelfService, "darwin host should see self-service .sh packages") + + // Linux host should also see it + hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "linux", &teamSh.ID) + require.NoError(t, err) + assert.True(t, hasSelfService, "linux host should see self-service .sh packages") + + // Windows host shouldn't see .sh packages + hasSelfService, err = ds.HasSelfServiceSoftwareInstallers(ctx, "windows", &teamSh.ID) + require.NoError(t, err) + assert.False(t, hasSelfService, "windows host should NOT see .sh packages") + // Create a self-service VPP for team/darwin _, err = ds.InsertVPPAppWithTeam(ctx, &fleet.VPPApp{VPPAppTeam: fleet.VPPAppTeam{VPPAppID: fleet.VPPAppID{AdamID: "adam_vpp_3", Platform: fleet.MacOSPlatform}, SelfService: true}, Name: "vpp3", BundleIdentifier: "com.app.vpp3"}, &team.ID) require.NoError(t, err) diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index 8f58c8f291af..8367e41be624 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -111,6 +111,7 @@ func TestSoftware(t *testing.T) { {"CountHostSoftwareInstallAttempts", testCountHostSoftwareInstallAttempts}, {"ListSoftwareVersionsSearchByTitleName", testListSoftwareVersionsSearchByTitleName}, {"ListSoftwareInventoryDeletedHost", testListSoftwareInventoryDeletedHost}, + {"ListHostSoftwareShPackageForDarwin", testListHostSoftwareShPackageForDarwin}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -11122,6 +11123,121 @@ func testListSoftwareInventoryDeletedHost(t *testing.T, ds *Datastore) { require.Equal(t, titleID, software[0].ID) } +// testListHostSoftwareShPackageForDarwin tests that .sh packages +// (stored as platform='linux') are visible to darwin hosts +func testListHostSoftwareShPackageForDarwin(t *testing.T, ds *Datastore) { + ctx := t.Context() + + // Create a darwin host + darwinHost := test.NewHost(t, ds, "darwin-host", "", "darwinkey", "darwinuuid", time.Now(), test.WithPlatform("darwin")) + nanoEnroll(t, ds, darwinHost, false) + + // Create a linux host for comparison + linuxHost := test.NewHost(t, ds, "linux-host", "", "linuxkey", "linuxuuid", time.Now(), test.WithPlatform("ubuntu")) + nanoEnroll(t, ds, linuxHost, false) + + user := test.NewUser(t, ds, "testuser", "testuser@example.com", true) + + // Create a .sh installer (platform='linux', extension='sh') + tfr, err := fleet.NewTempFileReader(strings.NewReader("#!/bin/bash\necho hello"), t.TempDir) + require.NoError(t, err) + _, shTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "#!/bin/bash\necho install", + InstallerFile: tfr, + StorageID: "sh-storage-darwin-test", + Filename: "test-script.sh", + Title: "Test Script", + Version: "1.0.0", + Source: "sh_packages", + Platform: "linux", // .sh files are stored as linux platform + Extension: "sh", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + // Create a regular .deb installer (platform='linux'), shouldn't be visible to darwin + tfr2, err := fleet.NewTempFileReader(strings.NewReader("deb content"), t.TempDir) + require.NoError(t, err) + _, debTitleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "dpkg -i installer.deb", + InstallerFile: tfr2, + StorageID: "deb-storage-test", + Filename: "installer.deb", + Title: "Linux Deb Package", + Version: "1.0.0", + Source: "deb_packages", + Platform: "linux", + Extension: "deb", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + + opts := fleet.HostSoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100, OrderKey: "name"}, + IncludeAvailableForInstall: true, + } + + // Query available software for darwin host + sw, _, err := ds.ListHostSoftware(ctx, darwinHost, opts) + require.NoError(t, err) + + // Darwin host should see the .sh package but not the .deb package + var foundSh, foundDeb bool + for _, s := range sw { + if s.ID == shTitleID { + foundSh = true + require.Equal(t, "Test Script", s.Name) + require.Equal(t, "sh_packages", s.Source) + } + if s.ID == debTitleID { + foundDeb = true + } + } + require.True(t, foundSh, ".sh package should be visible to darwin host") + require.False(t, foundDeb, ".deb package should NOT be visible to darwin host") + + // Query available software for linux host, should see both + sw, _, err = ds.ListHostSoftware(ctx, linuxHost, opts) + require.NoError(t, err) + + foundSh = false + foundDeb = false + for _, s := range sw { + if s.ID == shTitleID { + foundSh = true + } + if s.ID == debTitleID { + foundDeb = true + } + } + require.True(t, foundSh, ".sh package should be visible to linux host") + require.True(t, foundDeb, ".deb package should be visible to linux host") + + // Create a Windows host + windowsHost := test.NewHost(t, ds, "windows-host", "", "windowskey", "windowsuuid", time.Now(), test.WithPlatform("windows")) + nanoEnroll(t, ds, windowsHost, false) + + // Query available software for Windows host + sw, _, err = ds.ListHostSoftware(ctx, windowsHost, opts) + require.NoError(t, err) + + // Windows host should NOT see .sh or .deb packages + foundSh = false + foundDeb = false + for _, s := range sw { + if s.ID == shTitleID { + foundSh = true + } + if s.ID == debTitleID { + foundDeb = true + } + } + require.False(t, foundSh, ".sh package should NOT be visible to windows host") + require.False(t, foundDeb, ".deb package should NOT be visible to windows host") +} + // TestUniqueSoftwareTitleStrNormalization tests that UniqueSoftwareTitleStr // produces consistent keys regardless of Unicode format characters (like RTL mark) // which MySQL's utf8mb4_unicode_ci collation ignores.