diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 2a8abf94b1d..555f9df167e 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -69,7 +69,9 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. payload.PostInstallScript = file.Dos2UnixNewlines(payload.PostInstallScript) payload.UninstallScript = file.Dos2UnixNewlines(payload.UninstallScript) - if _, err := svc.addMetadataToSoftwarePayload(ctx, payload, true); err != nil { + failOnBlankScript := !strings.HasSuffix(payload.Filename, ".ipa") + + if _, err := svc.addMetadataToSoftwarePayload(ctx, payload, failOnBlankScript); err != nil { return nil, ctxerr.Wrap(ctx, err, "adding metadata to payload") } @@ -151,6 +153,15 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. if payload.TeamID != nil { tmID = *payload.TeamID } + + if payload.Extension == "ipa" { + addedInstaller, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &tmID, titleID) + if err != nil { + return nil, err + } + return addedInstaller, nil + } + addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &tmID, titleID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting added software installer") @@ -1573,11 +1584,6 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f payload.Extension = meta.Extension payload.UpgradeCode = meta.UpgradeCode - if payload.Extension == "ipa" { - fmt.Println("processing IPA upload") - return meta.Extension, nil - } - // reset the reader (it was consumed to extract metadata) if err := payload.InstallerFile.Rewind(); err != nil { return "", ctxerr.Wrap(ctx, err, "resetting installer file reader") diff --git a/pkg/file/file.go b/pkg/file/file.go index b86cdb81793..5f7fd936bef 100644 --- a/pkg/file/file.go +++ b/pkg/file/file.go @@ -2,7 +2,6 @@ package file import ( "archive/tar" - "archive/zip" "bufio" "bytes" "compress/gzip" @@ -40,26 +39,6 @@ type InstallerMetadata struct { UpgradeCode string } -func ExtractIPAMetadata(tfr *fleet.TempFileReader) (*InstallerMetadata, error) { - // TODO(JVE): fill me in! needs to unzip the file, then use the binary plist reader we have to get the metadata - h := sha256.New() - _, _ = io.Copy(h, tfr) // writes to a hash cannot fail - if err := tfr.Rewind(); err != nil { - return nil, fmt.Errorf("rewind reader: %w", err) - } - - r, err := zip.NewReader(tfr, 1000) - if err != nil { - return nil, err - } - - for _, f := range r.File { - fmt.Printf("f.Name: %v\n", f.Name) - } - - return &InstallerMetadata{SHASum: h.Sum(nil), PackageIDs: []string{"com.foo.bar"}}, nil -} - // ExtractInstallerMetadata extracts the software name and version from the // installer file and returns them along with the sha256 hash of the bytes. The // format of the installer is determined based on the magic bytes of the content. @@ -90,9 +69,8 @@ func ExtractInstallerMetadata(tfr *fleet.TempFileReader) (*InstallerMetadata, er if err != nil { err = errors.Join(ErrInvalidTarball, err) } - // TODO: implement this - // case "ipa": - // meta, err = ExtractIPAMetadata(tfr) + case "ipa": + meta, err = ExtractIPAMetadata(tfr) default: return nil, ErrUnsupportedType } diff --git a/pkg/file/ipa.go b/pkg/file/ipa.go new file mode 100644 index 00000000000..e26aa5deb05 --- /dev/null +++ b/pkg/file/ipa.go @@ -0,0 +1,65 @@ +package file + +import ( + "archive/zip" + "crypto/sha256" + "errors" + "fmt" + "io" + "strings" + + "github.com/fleetdm/fleet/v4/server/fleet" + "howett.net/plist" +) + +func ExtractIPAMetadata(tfr *fleet.TempFileReader) (*InstallerMetadata, error) { + h := sha256.New() + _, _ = io.Copy(h, tfr) // writes to a hash cannot fail + if err := tfr.Rewind(); err != nil { + return nil, fmt.Errorf("rewind reader: %w", err) + } + + fmt.Printf("tfr.Name(): %v\n", tfr.Name()) + + r, err := zip.OpenReader(tfr.Name()) + if err != nil { + return nil, err + } + + var plistData struct { + BundleID string `plist:"CFBundleIdentifier"` + Name string `plist:"CFBundleName"` + Version string `plist:"CFBundleShortVersionString"` + } + for _, f := range r.File { + if strings.Contains(f.Name, "Info.plist") { + // Get data from plist file + archiveFile, err := f.Open() + if err != nil { + return nil, fmt.Errorf("could not open archive %s: %w", f.Name, err) + } + defer archiveFile.Close() + + rawData, err := io.ReadAll(archiveFile) + if err != nil { + return nil, err + } + _, err = plist.Unmarshal(rawData, &plistData) + if err != nil { + return nil, err + } + } + } + + if plistData.BundleID == "" { + return nil, errors.New("couldn't find bundle identifier for in-house app") + } + + return &InstallerMetadata{ + BundleIdentifier: plistData.BundleID, + SHASum: h.Sum(nil), + PackageIDs: []string{plistData.BundleID}, + Name: plistData.Name, + Version: plistData.Version, + }, nil +} diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 0e8dcaa95dd..98ad5b6f5fd 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -8194,10 +8194,11 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { `, certSerial, host.ID, "test-host", time.Now().Add(-1*time.Hour), time.Now().Add(24*time.Hour), "-----BEGIN CERTIFICATE-----", []byte{0x04}) require.NoError(t, err) - err = ds.InsertInHouseApp(ctx, &fleet.InHouseAppPayload{ - Name: "test", - StorageID: uuid.NewString(), - Platform: string(fleet.MacOSPlatform), + _, _, err = ds.insertInHouseApp(ctx, &fleet.InHouseAppPayload{ + Name: "test", + StorageID: uuid.NewString(), + Platform: string(fleet.MacOSPlatform), + ValidatedLabels: &fleet.LabelIdentsWithScope{}, }) require.NoError(t, err) var inHouseID uint diff --git a/server/datastore/mysql/in_house_apps.go b/server/datastore/mysql/in_house_apps.go index 26e75f06889..6ead2b42396 100644 --- a/server/datastore/mysql/in_house_apps.go +++ b/server/datastore/mysql/in_house_apps.go @@ -8,18 +8,18 @@ import ( "github.com/jmoiron/sqlx" ) -func (ds *Datastore) InsertInHouseApp(ctx context.Context, payload *fleet.InHouseAppPayload) error { +func (ds *Datastore) insertInHouseApp(ctx context.Context, payload *fleet.InHouseAppPayload) (uint, uint, error) { stmt := ` INSERT INTO in_house_apps ( team_id, + title_id, global_or_team_id, name, storage_id, platform ) - VALUES (?, ?, ?, ?, ?) - ` + VALUES (?, ?, ?, ?, ?, ?)` var tid *uint var globalOrTeamID uint @@ -31,26 +31,48 @@ func (ds *Datastore) InsertInHouseApp(ctx context.Context, payload *fleet.InHous } } - err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + titleID, err := ds.getOrGenerateSoftwareInstallerTitleID(ctx, &fleet.UploadSoftwareInstallerPayload{ + TeamID: tid, + Title: payload.Name, + BundleIdentifier: payload.BundleID, + Source: "ios_apps"}, // TODO: what about iPad apps + ) + if err != nil { + return 0, 0, ctxerr.Wrap(ctx, err, "insertInHouseApp") + } + + var installerID uint + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { args := []any{ tid, + titleID, globalOrTeamID, payload.Name, payload.StorageID, payload.Platform, } - _, err := tx.ExecContext(ctx, stmt, args...) + res, err := tx.ExecContext(ctx, stmt, args...) if err != nil { if IsDuplicate(err) { // already exists for this team/no team err = alreadyExists("InHouseApp", payload.Name) } - return err + return ctxerr.Wrap(ctx, err, "insertInHouseApp") + } + + id64, err := res.LastInsertId() + installerID = uint(id64) //nolint:gosec // dismiss G115 + if err != nil { + return ctxerr.Wrap(ctx, err, "insertInHouseApp") + } + + if err := setOrUpdateSoftwareInstallerLabelsDB(ctx, tx, installerID, *payload.ValidatedLabels, softwareTypeInHouseApp); err != nil { + return ctxerr.Wrap(ctx, err, "upsert in house app labels") } return nil }) - return ctxerr.Wrap(ctx, err, "insert in house app") + return installerID, titleID, ctxerr.Wrap(ctx, err, "insertInHouseApp") } diff --git a/server/datastore/mysql/in_house_apps_test.go b/server/datastore/mysql/in_house_apps_test.go index a6734dcc560..3f43e18f7f7 100644 --- a/server/datastore/mysql/in_house_apps_test.go +++ b/server/datastore/mysql/in_house_apps_test.go @@ -27,11 +27,29 @@ func TestInHouseApps(t *testing.T) { func testInHouseAppsCrud(t *testing.T, ds *Datastore) { ctx := context.Background() - err := ds.InsertInHouseApp(ctx, &fleet.InHouseAppPayload{ - Name: "foo", - StorageID: "testingtesting123", - Platform: "ios", - }) + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 1"}) require.NoError(t, err) + + payload := fleet.UploadSoftwareInstallerPayload{ + TeamID: &team.ID, + Title: "foo", + BundleIdentifier: "com.foo", + StorageID: "testingtesting123", + Platform: "ios", + Extension: "ipa", + } + // TODO(JK): test with svc.UploadSoftwareInstaller + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &payload) + require.Error(t, err, "ValidatedLabels must not be nil") + + payload.ValidatedLabels = &fleet.LabelIdentsWithScope{} + installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &payload) + require.NoError(t, err) + require.NotZero(t, installerID) + require.NotZero(t, titleID) + + installer, err := ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, titleID) + require.NoError(t, err) + require.Equal(t, payload.Title, installer.SoftwareTitle) } diff --git a/server/datastore/mysql/migrations/tables/20251006163522_InHouseAppsSupport.go b/server/datastore/mysql/migrations/tables/20251006163522_InHouseAppsSupport.go index dbea048e398..3bc77bd4061 100644 --- a/server/datastore/mysql/migrations/tables/20251006163522_InHouseAppsSupport.go +++ b/server/datastore/mysql/migrations/tables/20251006163522_InHouseAppsSupport.go @@ -16,7 +16,8 @@ CREATE TABLE in_house_apps ( title_id int unsigned DEFAULT NULL, team_id int unsigned DEFAULT NULL, global_or_team_id int unsigned NOT NULL DEFAULT '0', - name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + name VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + version VARCHAR(255) NOT NULL DEFAULT '', storage_id VARCHAR(64) COLLATE utf8mb4_unicode_ci NOT NULL, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 768b4f90a99..f9b5965c63b 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1126,6 +1126,7 @@ CREATE TABLE `in_house_apps` ( `team_id` int unsigned DEFAULT NULL, `global_or_team_id` int unsigned NOT NULL DEFAULT '0', `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `version` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `storage_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 223eff99a2b..3ffb66f31c4 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -197,6 +197,22 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload return 0, 0, errors.New("validated labels must not be nil") } + // Insert in house app instead of software installer + // TODO(JK): match if there is an existing in house app + if payload.Extension == "ipa" { + installerID, titleID, err := ds.insertInHouseApp(ctx, &fleet.InHouseAppPayload{ + TeamID: payload.TeamID, + Name: payload.Title, + BundleID: payload.BundleIdentifier, + StorageID: payload.StorageID, + Platform: payload.Platform, + ValidatedLabels: payload.ValidatedLabels}) + if err != nil { + return 0, 0, ctxerr.Wrap(ctx, err, "insert in house app") + } + return installerID, titleID, err + } + titleID, err = ds.getOrGenerateSoftwareInstallerTitleID(ctx, payload) if err != nil { return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID") @@ -501,8 +517,9 @@ func (ds *Datastore) addSoftwareTitleToMatchingSoftware(ctx context.Context, tit type softwareType string const ( - softwareTypeInstaller softwareType = "software_installer" - softwareTypeVPP softwareType = "vpp_app_team" + softwareTypeInstaller softwareType = "software_installer" + softwareTypeVPP softwareType = "vpp_app_team" + softwareTypeInHouseApp softwareType = "in_house_app" ) // setOrUpdateSoftwareInstallerLabelsDB sets or updates the label associations for the specified software @@ -777,7 +794,7 @@ WHERE // TODO: do we want to include labels on other queries that return software installer metadata // (e.g., GetSoftwareInstallerMetadataByID)? - labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID) + labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID, softwareTypeInstaller) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get software installer labels") } @@ -825,23 +842,77 @@ WHERE return &dest, nil } -func (ds *Datastore) getSoftwareInstallerLabels(ctx context.Context, installerID uint) ([]fleet.SoftwareScopeLabel, error) { +func (ds *Datastore) GetInHouseAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { query := ` +SELECT + iha.id, + iha.team_id, + iha.title_id, + COALESCE(iha.name, '') AS software_title, + iha.platform, + iha.storage_id +FROM + in_house_apps iha + JOIN software_titles st ON st.id = iha.title_id +WHERE + iha.title_id = ? AND iha.global_or_team_id = ?` + + var tmID uint + if teamID != nil { + tmID = *teamID + } + + var dest fleet.SoftwareInstaller + err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, titleID, tmID) + if err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("InHouseApp"), "get in house app metadata") + } + return nil, ctxerr.Wrap(ctx, err, "get in house app metadata") + } + + // TODO: do we want to include labels on other queries that return software installer metadata + // (e.g., GetSoftwareInstallerMetadataByID)? + labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID, softwareTypeInHouseApp) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get in house app labels") + } + var exclAny, inclAny []fleet.SoftwareScopeLabel + for _, l := range labels { + if l.Exclude { + exclAny = append(exclAny, l) + } else { + inclAny = append(inclAny, l) + } + } + + if len(inclAny) > 0 && len(exclAny) > 0 { + // there's a bug somewhere + level.Warn(ds.logger).Log("msg", "in house app has both include and exclude labels", "installer_id", dest.InstallerID, "include", fmt.Sprintf("%v", inclAny), "exclude", fmt.Sprintf("%v", exclAny)) + } + dest.LabelsExcludeAny = exclAny + dest.LabelsIncludeAny = inclAny + + return &dest, nil +} + +func (ds *Datastore) getSoftwareInstallerLabels(ctx context.Context, installerID uint, softwareType softwareType) ([]fleet.SoftwareScopeLabel, error) { + query := fmt.Sprintf(` SELECT label_id, exclude, l.name as label_name, si.title_id FROM - software_installer_labels sil - JOIN software_installers si ON si.id = sil.software_installer_id + %[1]s_labels sil + JOIN %[1]ss si ON si.id = sil.%[1]s_id JOIN labels l ON l.id = sil.label_id WHERE - software_installer_id = ?` + %[1]s_id = ?`, softwareType) var labels []fleet.SoftwareScopeLabel if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labels, query, installerID); err != nil { - return nil, ctxerr.Wrap(ctx, err, "get software installer labels") + return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("get %s labels", softwareType)) } return labels, nil diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index a94305714a7..69ba0a320e7 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1983,6 +1983,10 @@ type Datastore interface { // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) + // GetInHouseAppMetadataByTeamAndTitleID returns the in house app corresponding + // to the specific team and title ids. + GetInHouseAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*SoftwareInstaller, error) + // GetSoftwareInstallersPendingUninstallScriptPopulation returns a map of software installers to storage IDs that: // 1. need uninstall scripts populated // 2. can have uninstall scripts auto-generated by Fleet @@ -2408,8 +2412,6 @@ type Datastore interface { // GetCurrentTime gets the current time from the database GetCurrentTime(ctx context.Context) (time.Time, error) - - InsertInHouseApp(ctx context.Context, payload *InHouseAppPayload) error } type AndroidDatastore interface { diff --git a/server/fleet/in_house_apps.go b/server/fleet/in_house_apps.go index 03e6ca407c0..93cb9b56e72 100644 --- a/server/fleet/in_house_apps.go +++ b/server/fleet/in_house_apps.go @@ -1,8 +1,10 @@ package fleet type InHouseAppPayload struct { - TeamID *uint - Name string - StorageID string - Platform string + TeamID *uint + Name string + BundleID string + StorageID string + Platform string + ValidatedLabels *LabelIdentsWithScope } diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index dc6b891b79a..f661ba1bd84 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -600,6 +600,8 @@ func SoftwareInstallerPlatformFromExtension(ext string) (string, error) { return "windows", nil case "pkg": return "darwin", nil + case "ipa": // TODO(JVE): what about iPads? Can we get the platforms from the Info.plist file? + return "ios", nil default: return "", fmt.Errorf("unsupported file type: %s", ext) } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 235155aa2c4..13086b6347a 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1257,6 +1257,8 @@ type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID u type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) +type GetInHouseAppMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) + type GetSoftwareInstallersPendingUninstallScriptPopulationFunc func(ctx context.Context) (map[uint]string, error) type GetMSIInstallersWithoutUpgradeCodeFunc func(ctx context.Context) (map[uint]string, error) @@ -1549,8 +1551,6 @@ type BatchApplyCertificateAuthoritiesFunc func(ctx context.Context, ops fleet.Ce type GetCurrentTimeFunc func(ctx context.Context) (time.Time, error) -type InsertInHouseAppFunc func(ctx context.Context, payload *fleet.InHouseAppPayload) error - type DataStore struct { HealthCheckFunc HealthCheckFunc HealthCheckFuncInvoked bool @@ -3403,6 +3403,9 @@ type DataStore struct { GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + GetInHouseAppMetadataByTeamAndTitleIDFunc GetInHouseAppMetadataByTeamAndTitleIDFunc + GetInHouseAppMetadataByTeamAndTitleIDFuncInvoked bool + GetSoftwareInstallersPendingUninstallScriptPopulationFunc GetSoftwareInstallersPendingUninstallScriptPopulationFunc GetSoftwareInstallersPendingUninstallScriptPopulationFuncInvoked bool @@ -3841,9 +3844,6 @@ type DataStore struct { GetCurrentTimeFunc GetCurrentTimeFunc GetCurrentTimeFuncInvoked bool - InsertInHouseAppFunc InsertInHouseAppFunc - InsertInHouseAppFuncInvoked bool - mu sync.Mutex } @@ -8166,6 +8166,13 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } +func (s *DataStore) GetInHouseAppMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { + s.mu.Lock() + s.GetInHouseAppMetadataByTeamAndTitleIDFuncInvoked = true + s.mu.Unlock() + return s.GetInHouseAppMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID) +} + func (s *DataStore) GetSoftwareInstallersPendingUninstallScriptPopulation(ctx context.Context) (map[uint]string, error) { s.mu.Lock() s.GetSoftwareInstallersPendingUninstallScriptPopulationFuncInvoked = true @@ -9187,10 +9194,3 @@ func (s *DataStore) GetCurrentTime(ctx context.Context) (time.Time, error) { s.mu.Unlock() return s.GetCurrentTimeFunc(ctx) } - -func (s *DataStore) InsertInHouseApp(ctx context.Context, payload *fleet.InHouseAppPayload) error { - s.mu.Lock() - s.InsertInHouseAppFuncInvoked = true - s.mu.Unlock() - return s.InsertInHouseAppFunc(ctx, payload) -}