Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions ee/server/service/software_installers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
),
}
}
}

Expand Down Expand Up @@ -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},
),
}
}
}

Expand Down
122 changes: 122 additions & 0 deletions ee/server/service/software_installers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
3 changes: 2 additions & 1 deletion server/datastore/mysql/software.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions server/datastore/mysql/software_installers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions server/datastore/mysql/software_installers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
116 changes: 116 additions & 0 deletions server/datastore/mysql/software_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
Loading