fleet/ee/server/service/software_installers.go
Jahziel Villasana-Espinoza 028ff2adf6
add missing validation for scripts, tests (#42424)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41500 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually
2026-03-30 10:13:03 -04:00

3242 lines
118 KiB
Go

package service
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"path"
"path/filepath"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/retry"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/installersize"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
maintained_apps "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/worker"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
)
const softwareInstallerTokenMaxLength = 36 // UUID length
func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: payload.TeamID}, fleet.ActionWrite); err != nil {
return nil, err
}
if payload.AutomaticInstall {
// Currently, same write permissions are applied on software and policies,
// but leaving this here in case it changes in the future.
if err := svc.authz.Authorize(ctx, &fleet.Policy{PolicyData: fleet.PolicyData{TeamID: payload.TeamID}}, fleet.ActionWrite); err != nil {
return nil, err
}
}
// validate labels before we do anything else
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.TeamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating software labels")
}
payload.ValidatedLabels = validatedLabels
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
payload.UserID = vc.UserID()
// Determine extension early so we can clear unsupported params for script packages
ext := strings.ToLower(filepath.Ext(payload.Filename))
ext = strings.TrimPrefix(ext, ".")
if fleet.IsScriptPackage(ext) {
// For script packages, clear unsupported params before any processing
payload.UninstallScript = ""
payload.PostInstallScript = ""
payload.PreInstallQuery = ""
}
// make sure all scripts use unix-style newlines to prevent errors when
// running them, browsers use windows-style newlines, which breaks the
// shebang when the file is directly executed.
payload.InstallScript = file.Dos2UnixNewlines(payload.InstallScript)
payload.PostInstallScript = file.Dos2UnixNewlines(payload.PostInstallScript)
payload.UninstallScript = file.Dos2UnixNewlines(payload.UninstallScript)
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")
}
// Validate install/post-install/uninstall script contents for non-script
// packages. Script packages (.sh/.ps1) are already validated in
// addScriptPackageMetadata.
if !fleet.IsScriptPackage(payload.Extension) {
for _, scriptVal := range []struct {
name string
content string
}{
{"install script", payload.InstallScript},
{"post-install script", payload.PostInstallScript},
{"uninstall script", payload.UninstallScript},
} {
if err := fleet.ValidateSoftwareInstallerScript(scriptVal.content, payload.Platform); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. %s validation failed: %s", scriptVal.name, err.Error()),
}
}
}
}
if payload.AutomaticInstall && payload.AutomaticInstallQuery == "" {
switch {
//
// For "msi", addMetadataToSoftwarePayload fails before this point if product code cannot be extracted.
//
case payload.Extension == "exe" || payload.Extension == "tar.gz" || fleet.IsScriptPackage(payload.Extension):
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. Fleet can't create a policy to detect existing installations for .%s packages. Please add the software, add a custom policy, and enable the install software policy automation.", payload.Extension),
}
case payload.Extension == "pkg" && payload.BundleIdentifier == "":
// For pkgs without bundle identifier the request usually fails before reaching this point,
// but addMetadataToSoftwarePayload may not fail if the package has "package IDs" but not a "bundle identifier",
// in which case we want to fail here because we cannot generate a policy without a bundle identifier.
return nil, &fleet.BadRequestError{
Message: "Couldn't add. Policy couldn't be created because bundle identifier can't be extracted.",
}
}
}
if err := svc.storeSoftware(ctx, payload); err != nil {
return nil, ctxerr.Wrap(ctx, err, "storing software installer")
}
// Update $PACKAGE_ID/$UPGRADE_CODE in uninstall script
if err := preProcessUninstallScript(payload); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add software: %s", err),
}
}
if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{payload.InstallScript, payload.PostInstallScript, payload.UninstallScript}); err != nil {
// We redo the validation on each script to find out which script has the missing secret.
// This is done to provide a more informative error message to the UI user.
var argErr *fleet.InvalidArgumentError
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "install script", &payload.InstallScript, argErr)
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "post-install script", &payload.PostInstallScript, argErr)
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "uninstall script", &payload.UninstallScript, argErr)
if argErr != nil {
return nil, argErr
}
// We should not get to this point. If we did, it means we have another issue, such as large read replica latency.
return nil, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets")
}
installerID, titleID, err := svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "matching or creating software installer")
}
svc.logger.DebugContext(ctx, "software installer uploaded", "installer_id", installerID)
var teamName *string
if payload.TeamID != nil && *payload.TeamID != 0 {
t, err := svc.ds.TeamLite(ctx, *payload.TeamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting team name on upload software installer")
}
teamName = &t.Name
}
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
TeamName: teamName,
TeamID: payload.TeamID,
SelfService: payload.SelfService,
SoftwareTitleID: titleID,
LabelsIncludeAny: actLabelsInclAny,
LabelsExcludeAny: actLabelsExclAny,
LabelsIncludeAll: actLabelsInclAll,
}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating activity for added software")
}
// get values for response object
var tmID uint
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(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting added software installer")
}
if payload.AutomaticInstall {
policyAct := fleet.ActivityTypeCreatedPolicy{
ID: addedInstaller.AutomaticInstallPolicies[0].ID,
Name: addedInstaller.AutomaticInstallPolicies[0].Name,
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), policyAct); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for create automatic install policy for custom package")
}
}
return addedInstaller, nil
}
func ValidateSoftwareLabels(ctx context.Context, svc fleet.Service, teamID *uint, labelsIncludeAny, labelsExcludeAny, labelsIncludeAll []string) (*fleet.LabelIdentsWithScope, error) {
if authctx, ok := authz_ctx.FromContext(ctx); !ok {
return nil, fleet.NewAuthRequiredError("validate software labels: missing authorization context")
} else if !authctx.Checked() {
return nil, fleet.NewAuthRequiredError("validate software labels: method requires previous authorization")
}
var count int
for _, set := range [][]string{labelsIncludeAny, labelsExcludeAny, labelsIncludeAll} {
if len(set) > 0 {
count++
}
}
if count > 1 {
return nil, &fleet.BadRequestError{Message: `Only one of "labels_include_all", "labels_include_any" or "labels_exclude_any" can be included.`}
}
var names []string
var scope fleet.LabelScope
switch {
case len(labelsIncludeAny) > 0:
names = labelsIncludeAny
scope = fleet.LabelScopeIncludeAny
case len(labelsExcludeAny) > 0:
names = labelsExcludeAny
scope = fleet.LabelScopeExcludeAny
case len(labelsIncludeAll) > 0:
names = labelsIncludeAll
scope = fleet.LabelScopeIncludeAll
}
if len(names) == 0 {
// nothing to validate, return empty result
return &fleet.LabelIdentsWithScope{}, nil
}
byName, err := svc.BatchValidateLabels(ctx, teamID, names)
if err != nil {
var missingLabelErr *fleet.MissingLabelError
if errors.As(err, &missingLabelErr) {
return nil, &fleet.BadRequestError{
InternalErr: missingLabelErr,
Message: fmt.Sprintf("Couldn't update. Label %q doesn't exist. Please remove the label from the software.", missingLabelErr.MissingLabelName),
}
}
return nil, err
}
return &fleet.LabelIdentsWithScope{
LabelScope: scope,
ByName: byName,
}, nil
}
func preProcessUninstallScript(payload *fleet.UploadSoftwareInstallerPayload) error {
if len(payload.PackageIDs) == 0 {
// do nothing, this could be a FMA which won't include the installer when editing the scripts
return nil
}
// dmg and zip don't use template variable substitution
switch payload.Extension {
case "dmg", "zip":
return nil
}
// Only validate and substitute $PACKAGE_ID if it appears in the script
if file.PackageIDRegex.MatchString(payload.UninstallScript) {
if err := file.ValidatePackageIdentifiers(payload.PackageIDs, ""); err != nil {
return err
}
var packageID string
switch payload.Extension {
case "pkg":
var sb strings.Builder
_, _ = sb.WriteString("(\n")
for _, pkgID := range payload.PackageIDs {
_, _ = sb.WriteString(fmt.Sprintf(" '%s'\n", pkgID))
}
_, _ = sb.WriteString(")") // no ending newline
packageID = sb.String()
default:
packageID = fmt.Sprintf("'%s'", payload.PackageIDs[0])
}
payload.UninstallScript = file.PackageIDRegex.ReplaceAllString(payload.UninstallScript, fmt.Sprintf("%s${suffix}", packageID))
}
// Only validate and substitute $UPGRADE_CODE if the template variable appears in the script
if file.UpgradeCodeRegex.MatchString(payload.UninstallScript) {
if payload.UpgradeCode == "" {
return errors.New("$UPGRADE_CODE variable was used in uninstall script but package does not have an UpgradeCode")
}
if err := file.ValidatePackageIdentifiers(nil, payload.UpgradeCode); err != nil {
return err
}
payload.UninstallScript = file.UpgradeCodeRegex.ReplaceAllString(payload.UninstallScript, fmt.Sprintf("'%s'${suffix}", payload.UpgradeCode))
}
return nil
}
func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: payload.TeamID}, fleet.ActionWrite); err != nil {
return nil, err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
payload.UserID = vc.UserID()
if payload.TeamID == nil {
return nil, &fleet.BadRequestError{Message: "fleet_id is required; enter 0 for unassigned"}
}
var teamName *string
if *payload.TeamID != 0 {
t, err := svc.ds.TeamLite(ctx, *payload.TeamID)
if err != nil {
return nil, err
}
teamName = &t.Name
}
var scripts []string
if payload.InstallScript != nil {
scripts = append(scripts, *payload.InstallScript)
}
if payload.PostInstallScript != nil {
scripts = append(scripts, *payload.PostInstallScript)
}
if payload.UninstallScript != nil {
scripts = append(scripts, *payload.UninstallScript)
}
if err := svc.ds.ValidateEmbeddedSecrets(ctx, scripts); err != nil {
// We redo the validation on each script to find out which script has the missing secret.
// This is done to provide a more informative error message to the UI user.
var argErr *fleet.InvalidArgumentError
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "install script", payload.InstallScript, argErr)
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "post-install script", payload.PostInstallScript, argErr)
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "uninstall script", payload.UninstallScript, argErr)
if argErr != nil {
return nil, argErr
}
// We should not get to this point. If we did, it means we have another issue, such as large read replica latency.
return nil, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets")
}
// get software by ID, fail if it does not exist or does not have an existing installer
software, err := svc.ds.SoftwareTitleByID(ctx, payload.TitleID, payload.TeamID, fleet.TeamFilter{
User: vc.User,
IncludeObserver: true,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software title by id")
}
dirty := make(map[string]bool)
if payload.Categories != nil {
payload.Categories = server.RemoveDuplicatesFromSlice(payload.Categories)
catIDs, err := svc.ds.GetSoftwareCategoryIDs(ctx, payload.Categories)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software category ids")
}
if len(catIDs) != len(payload.Categories) {
return nil, &fleet.BadRequestError{
Message: "some or all of the categories provided don't exist",
InternalErr: fmt.Errorf("categories provided: %v", payload.Categories),
}
}
payload.CategoryIDs = catIDs
dirty["Categories"] = true
}
// Handle in house apps separately
if software.InHouseAppCount == 1 {
return svc.updateInHouseAppInstaller(ctx, payload, vc, teamName, software)
}
// TODO when we start supporting multiple installers per title X team, need to rework how we determine installer to edit
if software.SoftwareInstallersCount != 1 {
return nil, &fleet.BadRequestError{
Message: "There are no software installers defined yet for this title and team. Please add an installer instead of attempting to edit.",
}
}
existingInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting existing installer")
}
if payload.IsNoopPayload(software) {
return existingInstaller, nil // no payload, noop
}
payload.InstallerID = existingInstaller.InstallerID
if payload.DisplayName != nil && *payload.DisplayName != software.DisplayName {
trimmed := strings.TrimSpace(*payload.DisplayName)
if trimmed == "" && *payload.DisplayName != "" {
return nil, fleet.NewInvalidArgumentError("display_name", "Cannot have a display name that is all whitespace.")
}
*payload.DisplayName = trimmed
dirty["DisplayName"] = true
}
if payload.SelfService != nil && *payload.SelfService != existingInstaller.SelfService {
dirty["SelfService"] = true
}
shouldUpdateLabels, validatedLabels, err := ValidateSoftwareLabelsForUpdate(ctx, svc, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating software labels for update")
}
if shouldUpdateLabels {
dirty["Labels"] = true
}
payload.ValidatedLabels = validatedLabels
// activity team ID must be null if no team, not zero
var actTeamID *uint
if payload.TeamID != nil && *payload.TeamID != 0 {
actTeamID = payload.TeamID
}
activity := fleet.ActivityTypeEditedSoftware{
SoftwareTitle: existingInstaller.SoftwareTitle,
TeamName: teamName,
TeamID: actTeamID,
SelfService: existingInstaller.SelfService,
SoftwarePackage: &existingInstaller.Name,
SoftwareTitleID: payload.TitleID,
SoftwareIconURL: existingInstaller.IconUrl,
}
if payload.SelfService != nil && *payload.SelfService != existingInstaller.SelfService {
dirty["SelfService"] = true
activity.SelfService = *payload.SelfService
}
var payloadForNewInstallerFile *fleet.UploadSoftwareInstallerPayload
if payload.InstallerFile != nil {
payloadForNewInstallerFile = &fleet.UploadSoftwareInstallerPayload{
InstallerFile: payload.InstallerFile,
Filename: payload.Filename,
}
newInstallerExtension, err := svc.addMetadataToSoftwarePayload(ctx, payloadForNewInstallerFile, false)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "extracting updated installer metadata")
}
if newInstallerExtension != existingInstaller.Extension {
return nil, &fleet.BadRequestError{
Message: "The selected package is for a different file type.",
InternalErr: ctxerr.Wrap(ctx, err, "installer extension mismatch"),
}
}
if payloadForNewInstallerFile.Title != software.Name {
return nil, &fleet.BadRequestError{
Message: "The selected package is for different software.",
InternalErr: ctxerr.Wrap(ctx, err, "installer software title mismatch"),
}
}
if payloadForNewInstallerFile.StorageID != existingInstaller.StorageID {
activity.SoftwarePackage = &payload.Filename
payload.StorageID = payloadForNewInstallerFile.StorageID
payload.Filename = payloadForNewInstallerFile.Filename
payload.Version = payloadForNewInstallerFile.Version
payload.PackageIDs = payloadForNewInstallerFile.PackageIDs
payload.UpgradeCode = payloadForNewInstallerFile.UpgradeCode
dirty["Package"] = true
} else { // noop if uploaded installer is identical to previous installer
payloadForNewInstallerFile = nil
payload.InstallerFile = nil
}
if existingInstaller.FleetMaintainedAppID != nil {
return nil, &fleet.BadRequestError{
Message: "Couldn't update. The package can't be changed for Fleet-maintained apps.",
InternalErr: ctxerr.Wrap(ctx, err, "installer file changed for fleet maintained app installer"),
}
}
}
if payload.InstallerFile == nil { // fill in existing existingInstaller data to payload
payload.StorageID = existingInstaller.StorageID
payload.Filename = existingInstaller.Name
payload.Version = existingInstaller.Version
payload.PackageIDs = existingInstaller.PackageIDs()
payload.UpgradeCode = existingInstaller.UpgradeCode
}
isScriptPackage := fleet.IsScriptPackage(existingInstaller.Extension)
// default pre-install query is blank, so blanking out the query doesn't have a semantic meaning we have to take care of
if payload.PreInstallQuery != nil {
if isScriptPackage {
emptyQuery := ""
payload.PreInstallQuery = &emptyQuery
} else if *payload.PreInstallQuery != existingInstaller.PreInstallQuery {
dirty["PreInstallQuery"] = true
}
}
if payload.InstallScript != nil {
if isScriptPackage {
payload.InstallScript = nil
} else {
installScript := file.Dos2UnixNewlines(*payload.InstallScript)
installScript = getInstallScript(existingInstaller.Extension, existingInstaller.PackageIDs(), installScript)
if installScript == "" {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit. Install script is required for .%s packages.", strings.ToLower(existingInstaller.Extension)),
}
}
if err := fleet.ValidateSoftwareInstallerScript(installScript, existingInstaller.Platform); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit. install script validation failed: %s", err.Error()),
}
}
if installScript != existingInstaller.InstallScript {
dirty["InstallScript"] = true
}
payload.InstallScript = &installScript
}
}
if payload.PostInstallScript != nil {
if isScriptPackage {
emptyScript := ""
payload.PostInstallScript = &emptyScript
} else {
postInstallScript := file.Dos2UnixNewlines(*payload.PostInstallScript)
if err := fleet.ValidateSoftwareInstallerScript(postInstallScript, existingInstaller.Platform); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit. post-install script validation failed: %s", err.Error()),
}
}
if postInstallScript != existingInstaller.PostInstallScript {
dirty["PostInstallScript"] = true
}
payload.PostInstallScript = &postInstallScript
}
}
if payload.UninstallScript != nil {
if isScriptPackage {
emptyScript := ""
payload.UninstallScript = &emptyScript
} else {
uninstallScript := file.Dos2UnixNewlines(*payload.UninstallScript)
if uninstallScript == "" { // extension can't change on an edit so we can generate off of the existing file
uninstallScript = file.GetUninstallScript(existingInstaller.Extension)
if payload.UpgradeCode != "" {
uninstallScript = file.UninstallMsiWithUpgradeCodeScript
}
}
if uninstallScript == "" {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit. Uninstall script is required for .%s packages.", strings.ToLower(existingInstaller.Extension)),
}
}
if err := fleet.ValidateSoftwareInstallerScript(uninstallScript, existingInstaller.Platform); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit. uninstall script validation failed: %s", err.Error()),
}
}
payloadForUninstallScript := &fleet.UploadSoftwareInstallerPayload{
Extension: existingInstaller.Extension,
UninstallScript: uninstallScript,
PackageIDs: existingInstaller.PackageIDs(),
UpgradeCode: existingInstaller.UpgradeCode,
}
if payloadForNewInstallerFile != nil {
payloadForUninstallScript.PackageIDs = payloadForNewInstallerFile.PackageIDs
payloadForUninstallScript.UpgradeCode = payloadForNewInstallerFile.UpgradeCode
}
if err := preProcessUninstallScript(payloadForUninstallScript); err != nil {
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't edit software: %s", err),
}
}
if payloadForUninstallScript.UninstallScript != existingInstaller.UninstallScript {
dirty["UninstallScript"] = true
}
uninstallScript = payloadForUninstallScript.UninstallScript
payload.UninstallScript = &uninstallScript
}
}
fieldsShouldSideEffect := map[string]struct{}{
"InstallerFile": {},
"InstallScript": {},
"UninstallScript": {},
"PostInstallScript": {},
"PreInstallQuery": {},
"Package": {},
"Labels": {},
}
var shouldDoSideEffects bool
// persist changes starting here, now that we've done all the validation/diffing we can
if len(dirty) > 0 {
if len(dirty) == 1 && dirty["SelfService"] { // only self-service changed; use lighter update function
if err := svc.ds.UpdateInstallerSelfServiceFlag(ctx, *payload.SelfService, existingInstaller.InstallerID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "updating installer self service flag")
}
} else {
if payloadForNewInstallerFile != nil {
if err := svc.storeSoftware(ctx, payloadForNewInstallerFile); err != nil {
return nil, ctxerr.Wrap(ctx, err, "storing software installer")
}
}
// fill in values from existing installer if they weren't supplied
if payload.InstallScript == nil {
payload.InstallScript = &existingInstaller.InstallScript
}
if payload.UninstallScript == nil {
payload.UninstallScript = &existingInstaller.UninstallScript
}
if payload.PostInstallScript == nil && !dirty["PostInstallScript"] {
payload.PostInstallScript = &existingInstaller.PostInstallScript
}
if payload.PreInstallQuery == nil {
payload.PreInstallQuery = &existingInstaller.PreInstallQuery
}
if payload.SelfService == nil {
payload.SelfService = &existingInstaller.SelfService
}
// Get the hosts that are NOT in label scope currently (before the update happens)
var hostsNotInScope map[uint]struct{}
if dirty["Labels"] {
hostsNotInScope, err = svc.ds.GetExcludedHostIDMapForSoftwareInstaller(ctx, payload.InstallerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting hosts not in scope for installer")
}
}
if err := svc.ds.SaveInstallerUpdates(ctx, payload); err != nil {
return nil, ctxerr.Wrap(ctx, err, "saving installer updates")
}
if dirty["Labels"] {
// Get the hosts that are now IN label scope (after the update)
hostsInScope, err := svc.ds.GetIncludedHostIDMapForSoftwareInstaller(ctx, payload.InstallerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting hosts in scope for installer")
}
var hostsToClear []uint
for id := range hostsInScope {
if _, ok := hostsNotInScope[id]; ok {
// it was not in scope but now it is, so we should clear policy status
hostsToClear = append(hostsToClear, id)
}
}
// We clear the policy status here because otherwise the policy automation machinery
// won't pick this up and the software won't install.
if err := svc.ds.ClearSoftwareInstallerAutoInstallPolicyStatusForHosts(ctx, payload.InstallerID, hostsToClear); err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to clear auto install policy status for host")
}
}
for field := range dirty {
if _, ok := fieldsShouldSideEffect[field]; ok {
shouldDoSideEffects = true
break
}
}
// if we're updating anything other than self-service, we cancel pending installs/uninstalls,
// and if we're updating the package we reset counts. This is run in its own transaction internally
// for consistency, but independent of the installer update query as the main update should stick
// even if side effects fail.
if err := svc.ds.ProcessInstallerUpdateSideEffects(ctx, existingInstaller.InstallerID, shouldDoSideEffects, dirty["Package"]); err != nil {
return nil, err
}
}
// now that the payload has been updated with any patches, we can set the
// final fields of the activity
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromSoftwareScopeLabels(
existingInstaller.LabelsIncludeAny, existingInstaller.LabelsExcludeAny, existingInstaller.LabelsIncludeAll)
if payload.ValidatedLabels != nil {
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll = activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
}
activity.LabelsIncludeAny = actLabelsInclAny
activity.LabelsExcludeAny = actLabelsExclAny
activity.LabelsIncludeAll = actLabelsInclAll
if payload.SelfService != nil {
activity.SelfService = *payload.SelfService
}
if payload.DisplayName != nil {
activity.SoftwareDisplayName = *payload.DisplayName
}
if err := svc.NewActivity(ctx, vc.User, activity); err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating activity for edited software")
}
}
// re-pull installer from database to ensure any side effects are accounted for; may be able to optimize this out later
updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctxdb.RequirePrimary(ctx, true), payload.TeamID, payload.TitleID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "re-hydrating updated installer metadata")
}
statuses, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, updatedInstaller.InstallerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting updated installer statuses")
}
updatedInstaller.Status = statuses
return updatedInstaller, nil
}
func (svc *Service) validateEmbeddedSecretsOnScript(ctx context.Context, scriptName string, script *string,
argErr *fleet.InvalidArgumentError,
) *fleet.InvalidArgumentError {
if script != nil {
if errScript := svc.ds.ValidateEmbeddedSecrets(ctx, []string{*script}); errScript != nil {
if argErr != nil {
argErr.Append(scriptName, errScript.Error())
} else {
argErr = fleet.NewInvalidArgumentError(scriptName, errScript.Error())
}
}
}
return argErr
}
func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, existingInstaller *fleet.SoftwareInstaller, includeAny, excludeAny, includeAll []string) (shouldUpdate bool, validatedLabels *fleet.LabelIdentsWithScope, err error) {
if authctx, ok := authz_ctx.FromContext(ctx); !ok {
return false, nil, fleet.NewAuthRequiredError("batch validate labels: missing authorization context")
} else if !authctx.Checked() {
return false, nil, fleet.NewAuthRequiredError("batch validate labels: method requires previous authorization")
}
if existingInstaller == nil {
return false, nil, errors.New("existing installer must be provided")
}
if includeAny == nil && excludeAny == nil && includeAll == nil {
// nothing to do
return false, nil, nil
}
incoming, err := ValidateSoftwareLabels(ctx, svc, existingInstaller.TeamID, includeAny, excludeAny, includeAll)
if err != nil {
return false, nil, err
}
var prevScope fleet.LabelScope
var prevLabels []fleet.SoftwareScopeLabel
switch {
case len(existingInstaller.LabelsIncludeAny) > 0:
prevScope = fleet.LabelScopeIncludeAny
prevLabels = existingInstaller.LabelsIncludeAny
case len(existingInstaller.LabelsExcludeAny) > 0:
prevScope = fleet.LabelScopeExcludeAny
prevLabels = existingInstaller.LabelsExcludeAny
case len(existingInstaller.LabelsIncludeAll) > 0:
prevScope = fleet.LabelScopeIncludeAll
prevLabels = existingInstaller.LabelsIncludeAll
}
prevByName := make(map[string]fleet.LabelIdent, len(prevLabels))
for _, pl := range prevLabels {
prevByName[pl.LabelName] = fleet.LabelIdent{
LabelID: pl.LabelID,
LabelName: pl.LabelName,
}
}
if prevScope != incoming.LabelScope {
return true, incoming, nil
}
if len(prevByName) != len(incoming.ByName) {
return true, incoming, nil
}
// compare labels by name
for n, il := range incoming.ByName {
pl, ok := prevByName[n]
if !ok || pl != il {
return true, incoming, nil
}
}
return false, nil, nil
}
func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error {
if teamID == nil {
return fleet.NewInvalidArgumentError("fleet_id", "is required")
}
// we authorize with SoftwareInstaller here, but it uses the same AuthzType
// as VPPApp, so this is correct for both software installers and VPP apps.
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return err
}
// first, look for a software installer
metaInstaller, errInstaller := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false)
metaVPP, errVPP := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID)
metaInHouse, errInHouse := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, teamID, titleID)
switch {
case errInstaller != nil && !fleet.IsNotFound(errInstaller):
return ctxerr.Wrap(ctx, errInstaller, "getting software installer metadata")
case errVPP != nil && !fleet.IsNotFound(errVPP):
return ctxerr.Wrap(ctx, errVPP, "getting vpp app metadata")
case errInHouse != nil && !fleet.IsNotFound(errInHouse):
return ctxerr.Wrap(ctx, errInHouse, "getting in house app metadata")
}
switch {
case metaInstaller != nil:
return svc.deleteSoftwareInstaller(ctx, metaInstaller)
case metaVPP != nil:
return svc.deleteVPPApp(ctx, teamID, metaVPP)
case metaInHouse != nil:
return svc.deleteSoftwareInstaller(ctx, metaInHouse)
}
return ctxerr.Wrap(ctx, &notFoundError{}, "getting software installer")
}
func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet.VPPAppStoreApp) error {
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
var androidHostsUUIDToPolicyID map[string]string
if meta.Platform == fleet.AndroidPlatform {
// if this is an Android app we're deleting, collect the host uuids that should have it removed
// (as we uninstall Android apps on delete). We can't do this in the worker as it will be too late,
// the vpp_apps_teams entry will have been deleted.
hosts, err := svc.ds.GetIncludedHostUUIDMapForAppStoreApp(ctx, meta.VPPAppsTeamsID)
if err != nil {
return ctxerr.Wrap(ctx, err, "delete app store app: getting android hosts in scope")
}
androidHostsUUIDToPolicyID = hosts
}
if err := svc.ds.DeleteVPPAppFromTeam(ctx, teamID, meta.VPPAppID); err != nil {
return ctxerr.Wrap(ctx, err, "deleting VPP app")
}
// if this is an android app, remove the self-service app from the managed Google Play store
// and uninstall it from the hosts.
if meta.Platform == fleet.AndroidPlatform && len(androidHostsUUIDToPolicyID) > 0 {
enterprise, err := svc.ds.GetEnterprise(ctx)
if err != nil {
return &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err}
}
err = worker.QueueMakeAndroidAppUnavailableJob(ctx, svc.ds, svc.logger, meta.VPPAppID.AdamID, androidHostsUUIDToPolicyID, enterprise.Name())
if err != nil {
return ctxerr.Wrap(ctx, err, "enqueuing job to make android app unavailable")
}
}
var teamName *string
if teamID != nil && *teamID != 0 {
t, err := svc.ds.TeamLite(ctx, *teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting team name for deleted VPP app")
}
teamName = &t.Name
}
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny, meta.LabelsIncludeAll)
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityDeletedAppStoreApp{
AppStoreID: meta.AdamID,
SoftwareTitle: meta.Name,
TeamName: teamName,
TeamID: teamID,
Platform: meta.Platform,
LabelsIncludeAny: actLabelsInclAny,
LabelsExcludeAny: actLabelsExclAny,
LabelsIncludeAll: actLabelsInclAll,
SoftwareIconURL: meta.IconURL,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for deleted VPP app")
}
if teamID != nil && meta.IconURL != nil && *meta.IconURL != "" {
err := svc.ds.DeleteIconsAssociatedWithTitlesWithoutInstallers(ctx, *teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, fmt.Sprintf("failed to delete unused software icons for team %d", *teamID))
}
}
return nil
}
func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.SoftwareInstaller) error {
vc, ok := viewer.FromContext(ctx)
if !ok {
return fleet.ErrNoContext
}
switch {
case meta.Extension == "ipa":
if err := svc.ds.DeleteInHouseApp(ctx, meta.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "deleting in house app")
}
case meta.FleetMaintainedAppID != nil:
// For FMA installers there may be multiple cached versions (active + up to
// N-1 inactive ones). Delete the active version first so that the
// policy-automation and setup-experience guard-rails are enforced, then
// sweep up any remaining inactive cached versions.
if err := svc.ds.DeleteSoftwareInstaller(ctx, meta.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "deleting active FMA installer version")
}
// After the active row is gone, fetch whatever cached versions remain and
// delete them. GetFleetMaintainedVersionsByTitleID queries the live DB, so
// it will not return the row we just deleted.
if meta.TitleID != nil {
cachedVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, meta.TeamID, *meta.TitleID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting cached FMA versions for cleanup")
}
for _, v := range cachedVersions {
if err := svc.ds.DeleteSoftwareInstaller(ctx, v.ID); err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "deleting cached FMA version")
}
}
}
default:
if err := svc.ds.DeleteSoftwareInstaller(ctx, meta.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "deleting software installer")
}
}
var teamName *string
if meta.TeamID != nil {
t, err := svc.ds.TeamLite(ctx, *meta.TeamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting team name for deleted software")
}
teamName = &t.Name
}
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny, meta.LabelsIncludeAll)
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{
SoftwareTitle: meta.SoftwareTitle,
SoftwarePackage: meta.Name,
TeamName: teamName,
TeamID: meta.TeamID,
SelfService: meta.SelfService,
LabelsIncludeAny: actLabelsInclAny,
LabelsExcludeAny: actLabelsExclAny,
LabelsIncludeAll: actLabelsInclAll,
SoftwareIconURL: meta.IconUrl,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for deleted software")
}
if meta.IconUrl != nil && *meta.IconUrl != "" {
var teamIDForCleanup uint
if meta.TeamID != nil {
teamIDForCleanup = *meta.TeamID
}
err := svc.ds.DeleteIconsAssociatedWithTitlesWithoutInstallers(ctx, teamIDForCleanup)
if err != nil {
return ctxerr.Wrap(ctx, fmt.Errorf("failed to delete unused software icons for team %d: %w", teamIDForCleanup, err))
}
}
return nil
}
func (svc *Service) GetSoftwareInstallerMetadata(ctx context.Context, skipAuthz bool, titleID uint, teamID *uint) (*fleet.SoftwareInstaller,
error,
) {
if !skipAuthz {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
}
meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, true)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software installer metadata")
}
return meta, nil
}
func (svc *Service) GenerateSoftwareInstallerToken(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) {
downloadRequested := alt == "media"
if !downloadRequested {
svc.authz.SkipAuthorization(ctx)
return "", fleet.NewInvalidArgumentError("alt", "only alt=media is supported")
}
if teamID == nil {
svc.authz.SkipAuthorization(ctx)
return "", fleet.NewInvalidArgumentError("fleet_id", "is required")
}
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionRead); err != nil {
return "", err
}
meta := fleet.SoftwareInstallerTokenMetadata{
TitleID: titleID,
TeamID: *teamID,
}
metaByte, err := json.Marshal(meta)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "marshaling software installer metadata")
}
// Generate token and store in Redis
token := uuid.NewString()
const tokenExpirationMs = 10 * 60 * 1000 // 10 minutes
ok, err := svc.distributedLock.SetIfNotExist(ctx, fmt.Sprintf("software_installer_token:%s", token), string(metaByte),
tokenExpirationMs)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "saving software installer token")
}
if !ok {
// Should not happen since token is unique
return "", ctxerr.Errorf(ctx, "failed to save software installer token")
}
return token, nil
}
func (svc *Service) GetSoftwareInstallerTokenMetadata(ctx context.Context, token string,
titleID uint,
) (*fleet.SoftwareInstallerTokenMetadata, error) {
// We will manually authorize this endpoint based on the token.
svc.authz.SkipAuthorization(ctx)
if len(token) > softwareInstallerTokenMaxLength {
return nil, fleet.NewPermissionError("invalid token")
}
metaStr, err := svc.distributedLock.GetAndDelete(ctx, fmt.Sprintf("software_installer_token:%s", token))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software installer token metadata")
}
if metaStr == nil {
return nil, ctxerr.Wrap(ctx, fleet.NewPermissionError("invalid token"))
}
var meta fleet.SoftwareInstallerTokenMetadata
if err := json.Unmarshal([]byte(*metaStr), &meta); err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshaling software installer token metadata")
}
if titleID != meta.TitleID {
return nil, ctxerr.Wrap(ctx, fleet.NewPermissionError("invalid token"))
}
// The token is valid.
return &meta, nil
}
func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, skipAuthz bool, alt string, titleID uint,
teamID *uint,
) (*fleet.DownloadSoftwareInstallerPayload, error) {
downloadRequested := alt == "media"
if !downloadRequested {
svc.authz.SkipAuthorization(ctx)
return nil, fleet.NewInvalidArgumentError("alt", "only alt=media is supported")
}
if teamID == nil {
svc.authz.SkipAuthorization(ctx)
return nil, fleet.NewInvalidArgumentError("fleet_id", "is required")
}
meta, err := svc.GetSoftwareInstallerMetadata(ctx, skipAuthz, titleID, teamID)
if err != nil {
return nil, err
}
return svc.getSoftwareInstallerBinary(ctx, meta.StorageID, meta.Name)
}
func (svc *Service) GetSoftwareInstallDetails(ctx context.Context, installUUID string) (*fleet.SoftwareInstallDetails, error) {
// Call the base (non-premium) service to get the software install details
details, err := svc.Service.GetSoftwareInstallDetails(ctx, installUUID)
if err != nil {
return nil, err
}
// SoftwareInstallersCloudFrontSigner can only be set if license.IsPremium()
if svc.config.S3.SoftwareInstallersCloudFrontSigner != nil {
// Sign the URL for the installer
installerURL, err := svc.getSoftwareInstallURL(ctx, details.InstallerID)
if err != nil {
// We log the error but continue to return the details without the signed URL because orbit can still
// try to download the installer via Fleet server.
svc.logger.ErrorContext(ctx, "error getting software installer URL; check CloudFront configuration", "err", err)
} else {
details.SoftwareInstallerURL = installerURL
}
}
return details, nil
}
func (svc *Service) getSoftwareInstallURL(ctx context.Context, installerID uint) (*fleet.SoftwareInstallerURL, error) {
meta, err := svc.validateAndGetSoftwareInstallerMetadata(ctx, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating software installer metadata for download")
}
// Note: we could check if the installer exists in the S3 store.
// However, if we fail and don't return a URL installer, the Orbit client will still try to download the installer via the Fleet server,
// and we will end up checking if the installer exists in the S3 store again.
// So, to reduce server load and speed up the "happy path" software install, we skip the check here and risk returning a URL that doesn't work.
// If CloudFront is misconfigured, the server and Orbit clients will experience a greater load since they'll be doing throw-away work.
// Get the signed URL
signedURL, err := svc.softwareInstallStore.Sign(ctx, meta.StorageID, fleet.SoftwareInstallerSignedURLExpiry)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "signing software installer URL")
}
return &fleet.SoftwareInstallerURL{
URL: signedURL,
Filename: meta.Name,
}, nil
}
func (svc *Service) OrbitDownloadSoftwareInstaller(ctx context.Context, installerID uint) (*fleet.DownloadSoftwareInstallerPayload, error) {
// this is not a user-authenticated endpoint
svc.authz.SkipAuthorization(ctx)
meta, err := svc.validateAndGetSoftwareInstallerMetadata(ctx, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating software installer metadata for download")
}
// Note that we do allow downloading an installer that is on a different team
// than the host's team, because the install request might have come while
// the host was on that team, and then the host got moved to a different team
// but the request is still pending execution.
return svc.getSoftwareInstallerBinary(ctx, meta.StorageID, meta.Name)
}
func (svc *Service) validateAndGetSoftwareInstallerMetadata(ctx context.Context, installerID uint) (*fleet.SoftwareInstaller, error) {
host, ok := hostctx.FromContext(ctx)
if !ok {
return nil, fleet.OrbitError{Message: "internal error: missing host from request context"}
}
access, err := svc.ds.ValidateOrbitSoftwareInstallerAccess(ctx, host.ID, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "check software installer access")
}
if !access {
return nil, fleet.NewUserMessageError(errors.New("Host doesn't have access to this installer"), http.StatusForbidden)
}
// get the installer's metadata
meta, err := svc.ds.GetSoftwareInstallerMetadataByID(ctx, installerID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting software installer metadata")
}
return meta, nil
}
func (svc *Service) getSoftwareInstallerBinary(ctx context.Context, storageID string, filename string) (*fleet.DownloadSoftwareInstallerPayload, error) {
// check if the installer exists in the store
exists, err := svc.softwareInstallStore.Exists(ctx, storageID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "checking if installer exists")
}
if !exists {
return nil, ctxerr.Wrapf(ctx, &notFoundError{}, "%s with filename %s does not exist in software installer store", storageID,
filename)
}
// get the installer from the store
installer, size, err := svc.softwareInstallStore.Get(ctx, storageID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting installer from store")
}
return &fleet.DownloadSoftwareInstallerPayload{
Filename: filename,
Installer: installer,
Size: size,
}, nil
}
func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
// we need to use ds.Host because ds.HostLite doesn't return the orbit
// node key
host, err := svc.ds.Host(ctx, hostID)
if err != nil {
// if error is because the host does not exist, check first if the user
// had access to install software (to prevent leaking valid host ids).
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionWrite); err != nil {
return err
}
}
svc.authz.SkipAuthorization(ctx)
return ctxerr.Wrap(ctx, err, "get host")
}
platform := host.FleetPlatform()
mobileAppleDevice := fleet.InstallableDevicePlatform(platform) == fleet.IOSPlatform || fleet.InstallableDevicePlatform(platform) == fleet.IPadOSPlatform
if !mobileAppleDevice && (host.OrbitNodeKey == nil || *host.OrbitNodeKey == "") {
// fleetd is required to install software so if the host is
// enrolled via plain osquery we return an error
svc.authz.SkipAuthorization(ctx)
return fleet.NewUserMessageError(errors.New("Host doesn't have fleetd installed"), http.StatusUnprocessableEntity)
}
// authorize with the host's team
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionWrite); err != nil {
return err
}
if mobileAppleDevice {
iha, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID)
if err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "install in house app: get metadata")
}
if iha != nil {
scoped, err := svc.ds.IsInHouseAppLabelScoped(ctx, iha.InstallerID, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking label scoping during in-house app install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. This host isn't a member of the labels defined for this software title.",
}
}
err = svc.ds.InsertHostInHouseAppInstall(ctx, host.ID, iha.InstallerID, softwareTitleID, uuid.NewString(), fleet.HostSoftwareInstallOptions{SelfService: false})
return ctxerr.Wrap(ctx, err, "insert in house app install")
}
// it's OK if we didn't find an in-house app; this might be a VPP app, so continue on
}
if !mobileAppleDevice {
installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
if err != nil {
if !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "finding software installer for title")
}
installer = nil
}
// if we found an installer, use that
if installer != nil {
// check the label scoping for this installer and host
scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, installer.InstallerID, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. Host isn't member of the labels defined for this software title.",
}
}
lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
}
if lastInstallRequest != nil && lastInstallRequest.Status != nil &&
(*lastInstallRequest.Status == fleet.SoftwareInstallPending || *lastInstallRequest.Status == fleet.SoftwareUninstallPending) {
return &fleet.BadRequestError{
Message: "Couldn't install. Host already has a pending install/uninstall for this installer.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "host already has a pending install/uninstall for this installer",
map[string]any{
"host_id": host.ID,
"software_installer_id": installer.InstallerID,
"team_id": host.TeamID,
"title_id": softwareTitleID,
},
),
}
}
return svc.installSoftwareTitleUsingInstaller(ctx, host, installer)
}
} else {
// Get the enrollment type of the mobile apple device.
enrollment, err := svc.ds.GetNanoMDMEnrollment(ctx, host.UUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting nano mdm enrollment")
}
if enrollment.Type == mdm.EnrollType(mdm.UserEnrollmentDevice).String() {
return fleet.NewUserMessageError(errors.New(fleet.InstallSoftwarePersonalAppleDeviceErrMsg), http.StatusUnprocessableEntity)
}
}
vppApp, err := svc.ds.GetVPPAppByTeamAndTitleID(ctx, host.TeamID, softwareTitleID)
if err != nil {
// if we couldn't find an installer or a VPP app, return a bad
// request error
if fleet.IsNotFound(err) {
return &fleet.BadRequestError{
Message: "Couldn't install software. Software title is not available for install. Please add software package or App Store app to install.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "couldn't find an installer or VPP app for software title",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
return ctxerr.Wrap(ctx, err, "finding VPP app for title")
}
// check the label scoping for this VPP app and host
scoped, err := svc.ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking label scoping during vpp software install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. This host isn't a member of the labels defined for this software title.",
}
}
_, err = svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.InstallableDevicePlatform(platform) == fleet.MacOSPlatform, fleet.HostSoftwareInstallOptions{
SelfService: false,
})
return err
}
func (svc *Service) installSoftwareFromVPP(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp, appleDevice bool, opts fleet.HostSoftwareInstallOptions) (string, error) {
token, err := svc.GetVPPTokenIfCanInstallVPPApps(ctx, appleDevice, host)
if err != nil {
return "", err
}
return svc.InstallVPPAppPostValidation(ctx, host, vppApp, token, opts)
}
func (svc *Service) GetVPPTokenIfCanInstallVPPApps(ctx context.Context, appleDevice bool, host *fleet.Host) (string, error) {
if !appleDevice {
return "", &fleet.BadRequestError{
Message: "VPP apps can only be installed only on Apple hosts.",
InternalErr: ctxerr.NewWithData(
ctx, "invalid host platform for requested installer",
map[string]any{"host_id": host.ID, "team_id": host.TeamID},
),
}
}
config, err := svc.ds.AppConfig(ctx)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "fetching config to check MDM status")
}
if !config.MDM.EnabledAndConfigured {
return "", fleet.NewUserMessageError(errors.New("Couldn't install. MDM is turned off. Please make sure that MDM is turned on to install App Store apps."), http.StatusUnprocessableEntity)
}
mdmConnected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host)
if err != nil {
return "", ctxerr.Wrapf(ctx, err, "checking MDM status for host %d", host.ID)
}
if !mdmConnected {
return "", &fleet.BadRequestError{
Message: "Error: Couldn't install. To install App Store app, turn on MDM for this host.",
InternalErr: ctxerr.NewWithData(
ctx, "VPP install attempted on non-MDM host",
map[string]any{"host_id": host.ID, "team_id": host.TeamID},
),
}
}
token, err := svc.getVPPToken(ctx, host.TeamID)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting VPP token")
}
return token, nil
}
func (svc *Service) InstallVPPAppPostValidation(ctx context.Context, host *fleet.Host, vppApp *fleet.VPPApp, token string, opts fleet.HostSoftwareInstallOptions) (string, error) {
// at this moment, neither the UI nor the back-end are prepared to
// handle [asyncronous errors][1] on assignment, so before assigning a
// device to a license, we need to:
//
// 1. Check if the app is already assigned to the serial number.
// 2. If it's not assigned yet, check if we have enough licenses.
//
// A race still might happen, so async error checking needs to be
// implemented anyways at some point.
//
// [1]: https://developer.apple.com/documentation/devicemanagement/app_and_book_management/handling_error_responses#3729433
assignments, err := vpp.GetAssignments(token, &vpp.AssignmentFilter{AdamID: vppApp.AdamID, SerialNumber: host.HardwareSerial})
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting assignments from VPP API")
}
var eventID string
// this app is not assigned to this device, check if we have licenses
// left and assign it.
if len(assignments) == 0 {
assets, err := vpp.GetAssets(ctx, token, &vpp.AssetFilter{AdamID: vppApp.AdamID})
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting assets from VPP API")
}
if len(assets) == 0 {
svc.logger.DebugContext(ctx, "trying to assign VPP asset to host",
"adam_id", vppApp.AdamID,
"host_serial", host.HardwareSerial,
)
return "", &fleet.BadRequestError{
Message: "Couldn't add software. <app_store_id> isn't available in Apple Business Manager. Please purchase license in Apple Business Manager and try again.",
InternalErr: ctxerr.Errorf(ctx, "VPP API didn't return any assets for adamID %s", vppApp.AdamID),
}
}
if len(assets) > 1 {
return "", ctxerr.Errorf(ctx, "VPP API returned more than one asset for adamID %s", vppApp.AdamID)
}
if assets[0].AvailableCount <= 0 {
return "", &fleet.BadRequestError{
Message: "Couldn't install. No available licenses. Please purchase license in Apple Business Manager and try again.",
InternalErr: ctxerr.NewWithData(
ctx, "license available count <= 0",
map[string]any{
"host_id": host.ID,
"team_id": host.TeamID,
"adam_id": vppApp.AdamID,
"count": assets[0].AvailableCount,
},
),
}
}
eventID, err = vpp.AssociateAssets(token, &vpp.AssociateAssetsRequest{Assets: assets, SerialNumbers: []string{host.HardwareSerial}})
if err != nil {
return "", ctxerr.Wrapf(ctx, err, "associating asset with adamID %s to host %s", vppApp.AdamID, host.HardwareSerial)
}
}
// TODO(mna): should we associate the device (give the license) only when the
// upcoming activity is ready to run? I don't think so, because then it could
// fail when it's ready to run which is probably a worse UX as once enqueued
// you expect it to succeed. But eventually, we should do better management
// of the licenses, e.g. if the upcoming activity gets cancelled, it should
// release the reserved license.
//
// But the command is definitely not enqueued now, only when activating the
// activity.
// enqueue the VPP app command to install
cmdUUID := uuid.NewString()
err = svc.ds.InsertHostVPPSoftwareInstall(ctx, host.ID, vppApp.VPPAppID, cmdUUID, eventID, opts)
if err != nil {
return "", ctxerr.Wrapf(ctx, err, "inserting host vpp software install for host with serial %s and app with adamID %s", host.HardwareSerial, vppApp.AdamID)
}
return cmdUUID, nil
}
func (svc *Service) installSoftwareTitleUsingInstaller(ctx context.Context, host *fleet.Host, installer *fleet.SoftwareInstaller) error {
ext := filepath.Ext(installer.Name)
requiredPlatform := packageExtensionToPlatform(ext)
if requiredPlatform == "" {
// this should never happen
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
}
if host.FleetPlatform() != requiredPlatform {
// 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},
),
}
}
}
// Reset old attempts so the new install starts fresh at attempt 1.
if err := svc.ds.ResetNonPolicyInstallAttempts(ctx, host.ID, installer.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "reset install attempts before new install")
}
_, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, installer.InstallerID, fleet.HostSoftwareInstallOptions{
SelfService: false,
WithRetries: true,
})
return ctxerr.Wrap(ctx, err, "inserting software install request")
}
func (svc *Service) UninstallSoftwareTitle(ctx context.Context, hostID uint, softwareTitleID uint) error {
// we need to use ds.Host because ds.HostLite doesn't return the orbit node key
host, err := svc.ds.Host(ctx, hostID)
fromMyDevicePage := svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) ||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) ||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL)
if err != nil {
// if error is because the host does not exist, check first if the user
// had access to install/uninstall software (to prevent leaking valid host ids).
if fleet.IsNotFound(err) {
if !fromMyDevicePage {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionWrite); err != nil {
return err
}
}
}
svc.authz.SkipAuthorization(ctx)
return ctxerr.Wrap(ctx, err, "get host")
}
if host.OrbitNodeKey == nil || *host.OrbitNodeKey == "" {
// fleetd is required to install software so if the host is enrolled via plain osquery we return an error
svc.authz.SkipAuthorization(ctx)
return fleet.NewUserMessageError(errors.New("host does not have fleetd installed"), http.StatusUnprocessableEntity)
}
// If scripts are disabled (according to the last detail query), we return an error.
// host.ScriptsEnabled may be nil for older orbit versions.
if host.ScriptsEnabled != nil && !*host.ScriptsEnabled {
svc.authz.SkipAuthorization(ctx)
return fleet.NewUserMessageError(errors.New(fleet.RunScriptsOrbitDisabledErrMsg), http.StatusUnprocessableEntity)
}
// authorize with the host's team
if !fromMyDevicePage {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionWrite); err != nil {
return err
}
}
installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
if err != nil {
if fleet.IsNotFound(err) {
return &fleet.BadRequestError{
Message: "Couldn't uninstall software. Software title is not available for uninstall. Please add software package to install/uninstall.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "couldn't find an installer for software title",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
return ctxerr.Wrap(ctx, err, "finding software installer for title")
}
lastInstallRequest, err := svc.ds.GetHostLastInstallData(ctx, host.ID, installer.InstallerID)
if err != nil {
return ctxerr.Wrapf(ctx, err, "getting last install data for host %d and installer %d", host.ID, installer.InstallerID)
}
if lastInstallRequest != nil && lastInstallRequest.Status != nil &&
(*lastInstallRequest.Status == fleet.SoftwareInstallPending || *lastInstallRequest.Status == fleet.SoftwareUninstallPending) {
return &fleet.BadRequestError{
Message: "Couldn't uninstall software. Host has a pending install/uninstall request.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "host already has a pending install/uninstall for this installer",
map[string]any{
"host_id": host.ID,
"software_installer_id": installer.InstallerID,
"team_id": host.TeamID,
"title_id": softwareTitleID,
"status": *lastInstallRequest.Status,
},
),
}
}
// Validate platform
ext := filepath.Ext(installer.Name)
requiredPlatform := packageExtensionToPlatform(ext)
if requiredPlatform == "" {
// this should never happen
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
}
if host.FleetPlatform() != requiredPlatform {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be uninstalled only on %s hosts.", ext, requiredPlatform),
InternalErr: ctxerr.NewWithData(
ctx, "invalid host platform for requested uninstall",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": installer.TitleID},
),
}
}
// Get the uninstall script to validate there is one, will use the standard
// script infrastructure to run it.
_, err = svc.ds.GetAnyScriptContents(ctx, installer.UninstallScriptContentID)
if err != nil {
if fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError("software_title_id", `No uninstall script exists for the provided "software_title_id".`).
WithStatus(http.StatusNotFound), "getting uninstall script contents")
}
return err
}
// Pending uninstalls will automatically show up in the UI Host Details -> Activity -> Upcoming tab.
execID := uuid.NewString()
if err = svc.insertSoftwareUninstallRequest(ctx, execID, host, installer, fromMyDevicePage); err != nil {
return err
}
return nil
}
func (svc *Service) insertSoftwareUninstallRequest(ctx context.Context, executionID string, host *fleet.Host,
installer *fleet.SoftwareInstaller, selfService bool,
) error {
if err := svc.ds.InsertSoftwareUninstallRequest(ctx, executionID, host.ID, installer.InstallerID, selfService); err != nil {
return ctxerr.Wrap(ctx, err, "inserting software uninstall request")
}
return nil
}
func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) {
if svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) ||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) ||
svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) {
return svc.getDeviceSoftwareInstallResults(ctx, resultUUID)
}
// Basic auth check
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
res, err := svc.ds.GetSoftwareInstallResults(ctx, resultUUID)
if err != nil {
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionRead); err != nil {
return nil, err
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get software install result")
}
if res.HostDeletedAt == nil {
// host is not deleted, get it and authorize for the host's team
host, err := svc.ds.HostLite(ctx, res.HostID)
// if error is because the host does not exist, check first if the user
// had access to run a script (to prevent leaking valid host ids).
if err != nil {
if fleet.IsNotFound(err) {
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionRead); err != nil {
return nil, err
}
}
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get host lite")
}
// Team specific auth check
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{HostTeamID: host.TeamID}, fleet.ActionRead); err != nil {
return nil, err
}
} else {
// host was deleted, authorize for no-team as a fallback
if err := svc.authz.Authorize(ctx, &fleet.HostSoftwareInstallerResultAuthz{}, fleet.ActionRead); err != nil {
return nil, err
}
}
res.EnhanceOutputDetails()
return res, nil
}
func (svc *Service) getDeviceSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) {
host, ok := hostctx.FromContext(ctx)
if !ok {
return nil, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
}
res, err := svc.ds.GetSoftwareInstallResults(ctx, resultUUID)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get software install result")
} else if res.HostID != host.ID { // hosts can't see other hosts' executions
return nil, ctxerr.Wrap(ctx, common_mysql.NotFound("HostSoftwareInstallerResult"), "get host software installer results")
}
res.EnhanceOutputDetails()
return res, nil
}
func (svc *Service) GetSelfServiceUninstallScriptResult(ctx context.Context, host *fleet.Host, execID string) (*fleet.HostScriptResult, error) {
scriptResult, err := svc.ds.GetSelfServiceUninstallScriptExecutionResult(ctx, execID, host.ID)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(ctx, err, "get script result")
}
scriptResult.Hostname = host.DisplayName()
return scriptResult, nil
}
func (svc *Service) storeSoftware(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
// check if exists in the installer store
exists, err := svc.softwareInstallStore.Exists(ctx, payload.StorageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if installer exists")
}
if !exists {
if err := svc.softwareInstallStore.Put(ctx, payload.StorageID, payload.InstallerFile); err != nil {
return ctxerr.Wrap(ctx, err, "storing installer")
}
}
return nil
}
func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload, failOnBlankScript bool) (extension string, err error) {
if payload == nil {
return "", ctxerr.New(ctx, "payload is required")
}
if payload.InstallerFile == nil {
return "", ctxerr.New(ctx, "installer file is required")
}
ext := strings.ToLower(filepath.Ext(payload.Filename))
ext = strings.TrimPrefix(ext, ".")
if fleet.IsScriptPackage(ext) {
if err := svc.addScriptPackageMetadata(ctx, payload, ext); err != nil {
return "", err
}
return ext, nil
}
// Handle Windows zip files specially since they require scripts (like exe)
// and share magic bytes with IPA files, so we check the extension first
if ext == "zip" {
platform, err := fleet.SoftwareInstallerPlatformFromExtension(ext)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "determining platform for zip file")
}
if platform == "windows" {
// For Windows zip files, create basic metadata manually
// since they require custom install/uninstall scripts
if err := svc.addZipPackageMetadata(ctx, payload); err != nil {
return "", err
}
// Validate that install and uninstall scripts are provided
if failOnBlankScript {
if payload.InstallScript == "" {
return "", &fleet.BadRequestError{
Message: "Couldn't add. Install script is required for .zip packages.",
}
}
if payload.UninstallScript == "" {
return "", &fleet.BadRequestError{
Message: "Couldn't add. Uninstall script is required for .zip packages.",
}
}
}
return ext, nil
}
// For non-Windows zip files (e.g., macOS), let ExtractInstallerMetadata handle it
// (it will detect it as IPA due to shared magic bytes, but that's handled elsewhere)
}
meta, err := file.ExtractInstallerMetadata(payload.InstallerFile)
if err != nil {
if errors.Is(err, file.ErrUnsupportedType) {
return "", &fleet.BadRequestError{
Message: "Couldn't edit software. File type not supported. The file should be .pkg, .msi, .exe, .zip, .deb, .rpm, .tar.gz, .sh, .ipa or .ps1.",
InternalErr: ctxerr.Wrap(ctx, err, "extracting metadata from installer"),
}
}
if errors.Is(err, file.ErrInvalidTarball) {
return "", &fleet.BadRequestError{
Message: "Couldn't edit software. Uploaded file is not a valid .tar.gz archive.",
InternalErr: ctxerr.Wrap(ctx, err, "extracting metadata from installer"),
}
}
return "", ctxerr.Wrap(ctx, err, "extracting metadata from installer")
}
if len(meta.PackageIDs) == 0 && meta.Extension != "tar.gz" && meta.Extension != "zip" {
return "", &fleet.BadRequestError{
Message: "Couldn't add. Unable to extract necessary metadata.",
InternalErr: ctxerr.New(ctx, "extracting package IDs from installer metadata"),
}
}
payload.Title = meta.Name
if payload.Title == "" {
// use the filename if no title from metadata
payload.Title = payload.Filename
}
payload.Version = meta.Version
payload.StorageID = hex.EncodeToString(meta.SHASum)
payload.BundleIdentifier = meta.BundleIdentifier
payload.PackageIDs = meta.PackageIDs
payload.Extension = meta.Extension
payload.UpgradeCode = meta.UpgradeCode
// 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")
}
payload.InstallScript = getInstallScript(meta.Extension, meta.PackageIDs, payload.InstallScript)
// Software edits validate non-empty scripts later, so set failOnBlankScript to false
if payload.InstallScript == "" && failOnBlankScript && payload.Extension != "ipa" {
ext := strings.ToLower(payload.Extension)
if ext == "zip" {
return "", &fleet.BadRequestError{
Message: "Couldn't add. Install script is required for .zip packages.",
}
}
return "", &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. Install script is required for .%s packages.", ext),
}
}
defaultUninstallScript := file.GetUninstallScript(meta.Extension)
if payload.UninstallScript == "" || payload.UninstallScript == defaultUninstallScript || payload.UninstallScript == file.UninstallMsiWithUpgradeCodeScript {
payload.UninstallScript = defaultUninstallScript
if payload.UpgradeCode != "" {
payload.UninstallScript = file.UninstallMsiWithUpgradeCodeScript
}
}
if payload.UninstallScript == "" && failOnBlankScript && payload.Extension != "ipa" {
return "", &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. Uninstall script is required for .%s packages.", strings.ToLower(payload.Extension)),
}
}
platform, err := fleet.SoftwareInstallerPlatformFromExtension(meta.Extension)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "determining platform from extension")
}
payload.Platform = platform
switch {
case payload.Extension == "ipa":
if payload.Platform == "ipados" {
payload.Source = "ipados_apps"
} else {
payload.Source = "ios_apps"
}
case payload.BundleIdentifier != "":
payload.Source = "apps"
default:
source, err := fleet.SofwareInstallerSourceFromExtensionAndName(meta.Extension, meta.Name)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "determining source from extension and name")
}
payload.Source = source
}
return meta.Extension, nil
}
func (svc *Service) addScriptPackageMetadata(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload, extension string) error {
if payload == nil {
return ctxerr.New(ctx, "payload is required")
}
if payload.InstallerFile == nil {
return ctxerr.New(ctx, "installer file is required")
}
scriptBytes, err := io.ReadAll(payload.InstallerFile)
if err != nil {
return ctxerr.Wrap(ctx, err, "reading script file")
}
if err := payload.InstallerFile.Rewind(); err != nil {
return ctxerr.Wrap(ctx, err, "resetting script file reader")
}
scriptContents := string(scriptBytes)
if err := fleet.ValidateHostScriptContents(scriptContents, true); err != nil {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. Script validation failed: %s", err.Error()),
InternalErr: ctxerr.Wrap(ctx, err, "validating script contents"),
}
}
// Validate that the shebang matches the file extension
kind, directExecute, err := fleet.ShebangInfo(scriptContents)
if err != nil {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. Script validation failed: %s", err.Error()),
InternalErr: ctxerr.Wrap(ctx, err, "validating script shebang"),
}
}
switch extension {
case "sh":
// allow no shebang (defaults to /bin/sh), or a supported shell shebang.
if directExecute && kind != fleet.ShebangShell {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Couldn't add. Script validation failed: %s", fleet.ErrUnsupportedInterpreter.Error()),
InternalErr: ctxerr.New(ctx, "shell script with non-shell shebang"),
}
}
case "py":
// python scripts must be directly executable (via a python shebang).
if !directExecute || kind != fleet.ShebangPython {
return &fleet.BadRequestError{
Message: "Couldn't add. Script validation failed: Python scripts must start with a python shebang (for example, \"#!/usr/bin/env python3\").",
InternalErr: ctxerr.New(ctx, "python script without python shebang"),
}
}
case "ps1":
// PowerShell scripts are executed via powershell.exe, shebangs are not supported.
if directExecute {
return &fleet.BadRequestError{
Message: "Couldn't add. Script validation failed: PowerShell scripts must not start with a shebang (\"#!\").",
InternalErr: ctxerr.New(ctx, "powershell script with shebang"),
}
}
}
shaSum, err := file.SHA256FromTempFileReader(payload.InstallerFile)
if err != nil {
return ctxerr.Wrap(ctx, err, "calculating script SHA256")
}
if payload.Title == "" {
base := filepath.Base(payload.Filename)
payload.Title = strings.TrimSuffix(base, filepath.Ext(base))
}
payload.Version = ""
payload.InstallScript = scriptContents
payload.StorageID = shaSum
payload.BundleIdentifier = ""
payload.PackageIDs = nil
payload.Extension = extension
switch extension {
case "sh":
payload.Source = "sh_packages"
case "ps1":
payload.Source = "ps1_packages"
}
platform, err := fleet.SoftwareInstallerPlatformFromExtension(extension)
if err != nil {
return ctxerr.Wrap(ctx, err, "determining platform from extension")
}
payload.Platform = platform
return nil
}
func (svc *Service) addZipPackageMetadata(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error {
if payload == nil {
return ctxerr.New(ctx, "payload is required")
}
if payload.InstallerFile == nil {
return ctxerr.New(ctx, "installer file is required")
}
shaSum, err := file.SHA256FromTempFileReader(payload.InstallerFile)
if err != nil {
return ctxerr.Wrap(ctx, err, "calculating zip SHA256")
}
if err := payload.InstallerFile.Rewind(); err != nil {
return ctxerr.Wrap(ctx, err, "resetting zip file reader")
}
if payload.Title == "" {
base := filepath.Base(payload.Filename)
payload.Title = strings.TrimSuffix(base, filepath.Ext(base))
}
platform, err := fleet.SoftwareInstallerPlatformFromExtension("zip")
if err != nil {
return ctxerr.Wrap(ctx, err, "determining platform from extension")
}
// Don't overwrite version if it's already set (e.g., from Fleet Maintained App manifest)
// Zip files don't have extractable version metadata, so preserve any existing version
payload.StorageID = shaSum
payload.BundleIdentifier = ""
payload.PackageIDs = nil // Zip files require scripts, so no package IDs extracted
payload.Extension = "zip"
payload.Source = "programs" // Same as exe and msi
payload.Platform = platform
return nil
}
const (
batchSoftwarePrefix = "software_batch_"
// keyExpireTime serves as a timeout for each step of the batch upload process (initial checks, download for
// a package from source, upload for a package to object storage) for each package. This timeout is refreshed
// at each step. If the timeout is reached, they key expires in Redis and the batch process is considered
// abandoned by clients checking in on it.
keyExpireTime = 4 * time.Minute
)
func (svc *Service) BatchSetSoftwareInstallers(
ctx context.Context, tmName string, payloads []*fleet.SoftwareInstallerPayload, dryRun bool,
) (string, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return "", err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return "", fleet.ErrNoContext
}
var teamID *uint
if tmName != "" {
tm, err := svc.ds.TeamByName(ctx, tmName)
if err != nil {
// If this is a dry run, the team may not have been created yet
if dryRun && fleet.IsNotFound(err) {
return "", nil
}
return "", err
}
teamID = &tm.ID
}
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return "", ctxerr.Wrap(ctx, err, "validating authorization")
}
var allScripts []string
// Verify payloads first, to prevent starting the download+upload process if the data is invalid.
for _, payload := range payloads {
if payload.Slug != nil && *payload.Slug != "" {
err := svc.softwareInstallerPayloadFromSlug(ctx, payload, teamID)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting fleet maintained software installer payload from slug")
}
}
if payload.URL == "" && payload.SHA256 == "" {
return "", fleet.NewInvalidArgumentError(
"software",
"Couldn't edit software. One or more software packages is missing url or hash_sha256 fields.",
)
}
if len(payload.URL) > fleet.SoftwareInstallerURLMaxLength {
return "", fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("software URL is too long, must be %d characters or less", fleet.SoftwareInstallerURLMaxLength),
)
}
if payload.URL != "" {
if _, err := url.ParseRequestURI(payload.URL); err != nil {
return "", fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q) is invalid", payload.URL),
)
}
}
if !dryRun {
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll)
if err != nil {
return "", err
}
payload.ValidatedLabels = validatedLabels
}
allScripts = append(allScripts, payload.InstallScript, payload.PostInstallScript, payload.UninstallScript)
}
if !dryRun {
// presence of these secrets are validated on the gitops side,
// we only want to ensure that secrets are in the database on the
// non-dry run case.
if err := svc.ds.ValidateEmbeddedSecrets(ctx, allScripts); err != nil {
return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("script", err.Error()))
}
}
requestUUID := uuid.NewString()
if err := svc.keyValueStore.Set(ctx, batchSoftwarePrefix+requestUUID, batchSetProcessing, keyExpireTime); err != nil {
return "", ctxerr.Wrapf(ctx, err, "failed to set key as %s", batchSetProcessing)
}
svc.logger.InfoContext(ctx, "software batch start",
"request_uuid", requestUUID,
"team_id", teamID,
"payloads", len(payloads),
)
go svc.softwareBatchUpload(
requestUUID,
teamID,
vc.UserID(),
payloads,
dryRun,
)
return requestUUID, nil
}
func (svc *Service) softwareInstallerPayloadFromSlug(ctx context.Context, payload *fleet.SoftwareInstallerPayload, teamID *uint) error {
slug := payload.Slug
if slug == nil || *slug == "" {
return nil
}
app, err := svc.ds.GetMaintainedAppBySlug(ctx, *slug, teamID)
if err != nil {
// Return user-friendly message for generic not found error
if fleet.IsNotFound(err) {
// Must return low-level error in order to be properly handled upstream
return fleet.NewUserMessageError(
fmt.Errorf("%s isn't a supported Fleet-maintained app. See supported apps: https://fleetdm.com/learn-more-about/supported-fleet-maintained-app-slugs", *slug),
http.StatusNotFound,
)
}
return err
}
fma, err := maintained_apps.Hydrate(ctx, app, payload.RollbackVersion, teamID, svc.ds)
if err != nil {
return err
}
payload.URL = app.InstallerURL
if app.SHA256 != noCheckHash {
payload.SHA256 = app.SHA256
}
if payload.InstallScript == "" {
payload.InstallScript = app.InstallScript
}
if payload.UninstallScript == "" {
payload.UninstallScript = app.UninstallScript
}
payload.FleetMaintained = true
payload.MaintainedApp = app
if len(payload.Categories) == 0 {
payload.Categories = app.Categories
}
payload.MaintainedApp.PatchQuery = fma.PatchQuery
return nil
}
const (
batchSetProcessing = "processing"
batchSetCompleted = "completed"
batchSetFailedPrefix = "failed:"
)
func (svc *Service) softwareBatchUpload(
requestUUID string,
teamID *uint,
userID uint,
payloads []*fleet.SoftwareInstallerPayload,
dryRun bool,
) {
var batchErr error
// TODO: this might be a little drastic to drop back to Background context,
// consider using ctx.WithoutCancel to keep all but the cancellation of the
// parent: https://pkg.go.dev/context#WithoutCancel
// e.g. for telemetry and such.
// We do not use the request ctx on purpose because this method runs in the background.
ctx := context.Background()
defer func(start time.Time) {
status := batchSetCompleted
if batchErr != nil {
status = fmt.Sprintf("%s%s", batchSetFailedPrefix, batchErr)
}
logger := svc.logger.With(
"request_uuid", requestUUID,
"team_id", teamID,
"payloads", len(payloads),
"status", status,
"took", time.Since(start),
)
logger.InfoContext(ctx, "software batch done")
// Give 10m for the client to read the result (it overrides the previos expiration time).
if err := svc.keyValueStore.Set(ctx, batchSoftwarePrefix+requestUUID, status, 10*time.Minute); err != nil {
logger.ErrorContext(ctx, "failed to set result", "err", err)
}
}(time.Now())
// Periodically refresh the expiration on the batch install process so that, even when downloading/uploading
// large installers, we ensure the server doesn't lose track of the batch. This way, the only time a batch times
// out is if the server goes offline during running the batch.
done := make(chan struct{})
go func() {
ticker := time.NewTicker(keyExpireTime / 3) // Running keepalive much more often since we don't retry set errors
defer ticker.Stop()
for {
select {
// at this point we're done with the batch, at which point the caller will set the job in Redis as complete
// with a longer TTL, so we don't need to do anything here
case <-done:
return
case <-ticker.C:
_ = svc.keyValueStore.Set(ctx, batchSoftwarePrefix+requestUUID, batchSetProcessing, keyExpireTime)
}
}
}()
defer close(done)
maxInstallerSize := svc.config.Server.MaxInstallerSizeBytes
downloadURLFn := func(ctx context.Context, url string) (*http.Response, *fleet.TempFileReader, error) {
client := fleethttp.NewClient()
client.Transport = fleethttp.NewSizeLimitTransport(maxInstallerSize)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, nil, fmt.Errorf("creating request for URL %q: %w", url, err)
}
resp, err := client.Do(req)
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
return nil, nil, fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %s", url, installersize.Human(maxInstallerSize)),
)
}
return nil, nil, fmt.Errorf("performing request for URL %q: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil, fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q) returned \"Not Found\". Please make sure that URLs are reachable from your Fleet server.", url),
)
}
// Allow all 2xx and 3xx status codes in this pass.
if resp.StatusCode >= 400 {
return nil, nil, fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q) received response status code %d.", url, resp.StatusCode),
)
}
tfr, err := fleet.NewTempFileReader(resp.Body, nil)
if err != nil {
// the max size error can be received either at client.Do or here when
// reading the body if it's caught via a limited body reader.
var maxBytesErr *http.MaxBytesError
if errors.Is(err, fleethttp.ErrMaxSizeExceeded) || errors.As(err, &maxBytesErr) {
return nil, nil, fleet.NewInvalidArgumentError(
"software.url",
fmt.Sprintf("Couldn't edit software. URL (%q). The maximum file size is %s", url, installersize.Human(maxInstallerSize)),
)
}
return nil, nil, fmt.Errorf("reading installer %q contents: %w", url, err)
}
return resp, tfr, nil
}
var manualAgentInstall bool
tmID := ptr.ValOrZero(teamID)
if tmID == 0 {
ac, err := svc.ds.AppConfig(ctx)
if err != nil {
batchErr = fmt.Errorf("Couldn't get app config: %w", err)
return
}
manualAgentInstall = ac.MDM.MacOSSetup.ManualAgentInstall.Value
} else {
team, err := svc.ds.TeamLite(ctx, tmID)
if err != nil {
batchErr = fmt.Errorf("Couldn't get team for team ID %d: %w", tmID, err)
return
}
manualAgentInstall = team.Config.MDM.MacOSSetup.ManualAgentInstall.Value
}
var g errgroup.Group
g.SetLimit(1) // TODO: consider whether we can increase this limit, see https://github.com/fleetdm/fleet/issues/22704#issuecomment-2397407837
// the reason for this struct with extra installers support is that:
// - ih-house apps match multiple installers to a single source installer
// payload (because an .ipa creates entries for iOS and iPadOS)
// - the for loop over each entry in the payload is executed in a goroutine
// that can only write to its pre-allocated index in the installers slice, so
// any extra installer for a given payload must be part of a single value
// inserted in that slice.
type installerPayloadWithExtras struct {
*fleet.UploadSoftwareInstallerPayload
ExtraInstallers []*fleet.UploadSoftwareInstallerPayload
}
// critical to avoid data race, the slices are pre-allocated and each
// goroutine only writes to its index.
installers := make([]*installerPayloadWithExtras, len(payloads))
toBeClosedTFRs := make([]*fleet.TempFileReader, len(payloads))
for i, p := range payloads {
i, p := i, p
g.Go(func() error {
// NOTE: cannot defer tfr.Close() here because the reader needs to be
// available after the goroutine completes. Instead, all temp file
// readers are collected in toBeClosedTFRs and will have their Close
// deferred after the join/wait of goroutines.
installer := &fleet.UploadSoftwareInstallerPayload{
TeamID: teamID,
InstallScript: p.InstallScript,
PreInstallQuery: p.PreInstallQuery,
PostInstallScript: p.PostInstallScript,
UninstallScript: p.UninstallScript,
SelfService: p.SelfService,
UserID: userID,
URL: p.URL,
InstallDuringSetup: p.InstallDuringSetup,
LabelsIncludeAny: p.LabelsIncludeAny,
LabelsExcludeAny: p.LabelsExcludeAny,
LabelsIncludeAll: p.LabelsIncludeAll,
ValidatedLabels: p.ValidatedLabels,
Categories: p.Categories,
DisplayName: p.DisplayName,
RollbackVersion: p.RollbackVersion,
}
var extraInstallers []*fleet.UploadSoftwareInstallerPayload
p.Categories = server.RemoveDuplicatesFromSlice(p.Categories)
catIDs, err := svc.ds.GetSoftwareCategoryIDs(ctx, p.Categories)
if err != nil {
return err
}
if len(catIDs) != len(p.Categories) {
return &fleet.BadRequestError{
Message: "some or all of the categories provided don't exist",
InternalErr: fmt.Errorf("categories provided: %v", p.Categories),
}
}
installer.CategoryIDs = catIDs
// check if we already have the installer based on the SHA256 and URL
teamIDs, err := svc.ds.GetTeamsWithInstallerByHash(ctx, p.SHA256, p.URL)
if err != nil {
return err
}
foundInstallers, ok := teamIDs[tmID]
switch {
case ok:
// Perfect match: existing installer on the same team
foundInstaller := foundInstallers[0]
if foundInstaller.Extension == "exe" || foundInstaller.Extension == "tar.gz" {
if p.InstallScript == "" {
return fmt.Errorf("Couldn't edit. Install script is required for .%s packages.", foundInstaller.Extension)
}
if p.UninstallScript == "" {
return fmt.Errorf("Couldn't edit. Uninstall script is required for .%s packages.", foundInstaller.Extension)
}
}
// make a copy of the installer without filled fields in case we add
// extra installers
extraInstallerBase := *installer
fillSoftwareInstallerPayloadFromExisting(installer, foundInstaller, p.SHA256)
for _, extraInstaller := range foundInstallers[1:] {
extraPayload := extraInstallerBase
fillSoftwareInstallerPayloadFromExisting(&extraPayload, extraInstaller, p.SHA256)
extraInstallers = append(extraInstallers, &extraPayload)
}
case !ok && len(teamIDs) > 0:
// Installer(s) exists, but for another team. We should copy it over to this team
// (if we have access to the other team).
user, err := svc.ds.UserByID(ctx, userID)
if err != nil {
return err
}
userctx := viewer.NewContext(ctx, viewer.Viewer{User: user})
for tmID, teamInstallers := range teamIDs {
// use the first one to which this user has access; the specific one shouldn't
// matter because they're all the same installer bytes
var tmIDPtr *uint
if tmID != 0 {
tmIDPtr = ptr.Uint(tmID)
}
if authErr := svc.authz.Authorize(userctx, &fleet.SoftwareInstaller{TeamID: tmIDPtr}, fleet.ActionWrite); authErr != nil {
continue
}
teamInstaller := teamInstallers[0]
if teamInstaller.Extension == "exe" || teamInstaller.Extension == "zip" {
if p.InstallScript == "" {
ext := teamInstaller.Extension
return fmt.Errorf("Couldn't edit. Install script is required for .%s packages.", ext)
}
if p.UninstallScript == "" {
ext := teamInstaller.Extension
return fmt.Errorf("Couldn't edit. Uninstall script is required for .%s packages.", ext)
}
}
// make a copy of the installer without filled fields in case we add
// extra installers
extraInstallerBase := *installer
fillSoftwareInstallerPayloadFromExisting(installer, teamInstaller, p.SHA256)
for _, extraInstaller := range teamInstallers[1:] {
extraPayload := extraInstallerBase
fillSoftwareInstallerPayloadFromExisting(&extraPayload, extraInstaller, p.SHA256)
extraInstallers = append(extraInstallers, &extraPayload)
}
break
}
}
// For FMA installers, check if this version is already cached for this team.
var fmaVersionCached bool
if p.Slug != nil && *p.Slug != "" && p.MaintainedApp != nil && p.MaintainedApp.Version != "" {
cached, err := svc.ds.HasFMAInstallerVersion(ctx, teamID, p.MaintainedApp.ID, p.MaintainedApp.Version)
if err != nil {
return ctxerr.Wrap(ctx, err, "check cached FMA version")
}
fmaVersionCached = cached
installer.FMAVersionCached = cached
}
var installerBytesExist bool
if !fmaVersionCached && p.SHA256 != "" {
installerBytesExist, err = svc.softwareInstallStore.Exists(ctx, installer.StorageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "check if installer exists in store")
}
}
// no accessible matching installer was found, so attempt to download it from URL.
if !fmaVersionCached && (installer.StorageID == "" || !installerBytesExist) {
if p.SHA256 != "" && p.URL == "" {
return fmt.Errorf("package not found with hash %s", p.SHA256)
}
var tfr *fleet.TempFileReader
// Handle script packages from path (script:// URL scheme)
if filename, ok := strings.CutPrefix(p.URL, "script://"); ok {
ext := strings.ToLower(filepath.Ext(filename))
ext = strings.TrimPrefix(ext, ".")
if !fleet.IsScriptPackage(ext) {
return fmt.Errorf("script:// URL must reference a .sh or .ps1 file, got: %s", filename)
}
if p.InstallScript == "" {
return fmt.Errorf("script package %s has no install script content", filename)
}
scriptContent := []byte(p.InstallScript)
tfr, err = fleet.NewTempFileReader(bytes.NewReader(scriptContent), nil)
if err != nil {
return fmt.Errorf("creating temp file for script package %s: %w", filename, err)
}
installer.InstallerFile = tfr
toBeClosedTFRs[i] = tfr
installer.Filename = filename
installer.PostInstallScript = ""
installer.UninstallScript = ""
installer.PreInstallQuery = ""
} else {
var resp *http.Response
err = retry.Do(func() error {
var retryErr error
resp, tfr, retryErr = downloadURLFn(ctx, p.URL)
if retryErr != nil {
return retryErr
}
return nil
}, retry.WithMaxAttempts(fleet.BatchDownloadMaxRetries), retry.WithInterval(fleet.BatchSoftwareInstallerRetryInterval()))
if err != nil {
return err
}
installer.InstallerFile = tfr
toBeClosedTFRs[i] = tfr
filename := maintained_apps.FilenameFromResponse(resp)
installer.Filename = filename
// For script packages (.sh and .ps1) and in-house apps (.ipa), clear
// unsupported fields early. Determine extension from filename to
// validate before metadata extraction.
ext := strings.ToLower(filepath.Ext(filename))
ext = strings.TrimPrefix(ext, ".")
if fleet.IsScriptPackage(ext) {
installer.PostInstallScript = ""
installer.UninstallScript = ""
installer.PreInstallQuery = ""
} else if ext == "ipa" {
installer.InstallScript = ""
installer.PostInstallScript = ""
installer.UninstallScript = ""
installer.PreInstallQuery = ""
}
}
}
if p.Slug != nil && *p.Slug != "" {
// Fleet maintained software hydration
// This code should be extracted for common use from here and AddFleetMaintainedApp in maintained_apps.go
// It's the same code and would be nice to get some reuse
appName := p.MaintainedApp.UniqueIdentifier
if p.MaintainedApp.Platform == "darwin" || appName == "" {
appName = p.MaintainedApp.Name
}
if installer.Filename == "" {
parsedURL, err := url.Parse(installer.URL)
if err != nil {
return fmt.Errorf("Error with maintained app, parsing URL: %v\n", err)
}
installer.Filename = path.Base(parsedURL.Path)
}
// noCheckHash is used by homebrew to signal that a hash shouldn't be checked
// This comes from the manifest and is a special case for maintained apps
// we need to generate the SHA256 from the installer file.
// Skip when version is cached — the existing row already has the computed hash.
if !fmaVersionCached && p.MaintainedApp.SHA256 == noCheckHash {
// generate the SHA256 from the installer file
if installer.InstallerFile == nil {
return fmt.Errorf("maintained app %s requires hash to be generated but no installer file found", p.MaintainedApp.UniqueIdentifier)
}
p.MaintainedApp.SHA256, err = file.SHA256FromTempFileReader(installer.InstallerFile)
if err != nil {
return fmt.Errorf("maintained app %s error generating hash: %w", p.MaintainedApp.UniqueIdentifier, err)
}
}
extension := strings.TrimLeft(filepath.Ext(installer.Filename), ".")
installer.Title = appName
installer.Version = p.MaintainedApp.Version
// Some FMAs (e.g. Chrome for macOS) aren't version-pinned by URL, so we have to extract the
// version from the package once we download it.
// Skip when version is cached — the existing row already has the correct version.
if !fmaVersionCached && installer.Version == "latest" && installer.InstallerFile != nil {
meta, err := file.ExtractInstallerMetadata(installer.InstallerFile)
if err != nil {
return ctxerr.Wrap(ctx, err, "extracting installer metadata")
}
// reset the reader (it was consumed to extract metadata)
if err := installer.InstallerFile.Rewind(); err != nil {
return ctxerr.Wrap(ctx, err, "resetting installer file reader")
}
installer.Version = meta.Version
}
installer.Platform = p.MaintainedApp.Platform
installer.Source = p.MaintainedApp.Source()
if installer.Source == "programs" && p.MaintainedApp.UpgradeCode != "" {
installer.UpgradeCode = p.MaintainedApp.UpgradeCode
}
installer.Extension = extension
installer.BundleIdentifier = p.MaintainedApp.BundleIdentifier()
installer.StorageID = p.MaintainedApp.SHA256
installer.FleetMaintainedAppID = &p.MaintainedApp.ID
installer.PatchQuery = p.MaintainedApp.PatchQuery
}
var ext string
if installer.FleetMaintainedAppID == nil && installer.InstallerFile != nil {
ext, err = svc.addMetadataToSoftwarePayload(ctx, installer, true)
if err != nil {
return err
}
if p.SHA256 != "" && p.SHA256 != installer.StorageID {
// this isn't the specified installer, so return an error
return fmt.Errorf("downloaded installer hash does not match provided hash for installer with url %s", p.URL)
}
}
// For script packages (.sh and .ps1) and in-house apps (.ipa), clear
// unsupported fields. For script packages, the file contents become the
// install script, so post_install_script, uninstall_script, and
// pre_install_query are not supported.
switch {
case fleet.IsScriptPackage(installer.Extension):
installer.PostInstallScript = ""
installer.UninstallScript = ""
installer.PreInstallQuery = ""
case installer.Extension != "exe":
// custom scripts only for exe installers and non-script packages
installer.InstallScript = getInstallScript(installer.Extension, installer.PackageIDs, installer.InstallScript)
if installer.UninstallScript == "" {
installer.UninstallScript = file.GetUninstallScript(installer.Extension)
}
case installer.Extension == "ipa":
installer.PostInstallScript = ""
installer.UninstallScript = ""
installer.PreInstallQuery = ""
installer.InstallScript = ""
}
if fleet.IsMacOSPlatform(installer.Platform) && ptr.ValOrZero(installer.InstallDuringSetup) && manualAgentInstall {
return errors.New(`Couldn't edit software. "setup_experience" cannot be used for macOS software if "macos_manual_agent_install" is enabled.`)
}
// Update $PACKAGE_ID/$UPGRADE_CODE in uninstall script
if err := preProcessUninstallScript(installer); err != nil {
return fmt.Errorf("processing uninstall script: %w", err)
}
// Validate install/post-install/uninstall script contents for
// non-script packages. Script packages are already validated in
// addScriptPackageMetadata.
if !fleet.IsScriptPackage(installer.Extension) {
for _, sv := range []struct {
name string
content string
}{
{"install script", installer.InstallScript},
{"post-install script", installer.PostInstallScript},
{"uninstall script", installer.UninstallScript},
} {
if err := fleet.ValidateSoftwareInstallerScript(sv.content, installer.Platform); err != nil {
return fmt.Errorf("Couldn't edit software. %s validation failed: %s", sv.name, err.Error())
}
}
}
// if filename was empty, try to extract it from the URL with the
// now-known extension
if installer.Filename == "" {
installer.Filename = file.ExtractFilenameFromURLPath(p.URL, ext)
}
// if empty, resort to a default name
if installer.Filename == "" {
installer.Filename = fmt.Sprintf("package.%s", ext)
}
if installer.Title == "" && installer.Extension != "ipa" {
// If an IPA is specified via hash rather than downloaded via URL, we won't have a title populated,
// and should try to pull the title from the database if it exists. If we can't extract title name for
// some reason, filename should only be used after attempting to pull data from the database.
installer.Title = installer.Filename
}
// if this is an .ipa and there is no extra installer, create it here
if installer.Extension == "ipa" && len(extraInstallers) == 0 {
extraPayload := *installer
switch installer.Platform {
case string(fleet.IOSPlatform):
extraPayload.Platform = string(fleet.IPadOSPlatform)
extraPayload.Source = "ipados_apps"
case string(fleet.IPadOSPlatform):
extraPayload.Platform = string(fleet.IOSPlatform)
extraPayload.Source = "ios_apps"
}
extraInstallers = append(extraInstallers, &extraPayload)
}
installers[i] = &installerPayloadWithExtras{
UploadSoftwareInstallerPayload: installer,
ExtraInstallers: extraInstallers,
}
return nil
})
}
waitErr := g.Wait()
// defer close for any valid temp file reader
for _, tfr := range toBeClosedTFRs {
if tfr != nil {
defer tfr.Close()
}
}
if waitErr != nil {
// NOTE: intentionally not wrapping to avoid polluting user errors.
batchErr = waitErr
return
}
if dryRun {
return
}
var inHouseInstallers, softwareInstallers []*fleet.UploadSoftwareInstallerPayload
for _, payloadWithExtras := range installers {
payload := payloadWithExtras.UploadSoftwareInstallerPayload
if !payload.FMAVersionCached {
batchErr = retry.Do(func() error {
if retryErr := svc.storeSoftware(ctx, payload); retryErr != nil {
return fmt.Errorf("storing software installer %q: %w", payload.Filename, retryErr)
}
return nil
}, retry.WithMaxAttempts(fleet.BatchUploadMaxRetries), retry.WithInterval(fleet.BatchSoftwareInstallerRetryInterval()))
}
if payload.Extension == "ipa" {
inHouseInstallers = append(inHouseInstallers, payload)
inHouseInstallers = append(inHouseInstallers, payloadWithExtras.ExtraInstallers...)
} else {
softwareInstallers = append(softwareInstallers, payload)
softwareInstallers = append(softwareInstallers, payloadWithExtras.ExtraInstallers...)
}
}
if err := svc.ds.BatchSetSoftwareInstallers(ctx, teamID, softwareInstallers); err != nil {
batchErr = fmt.Errorf("batch set software installers: %w", err)
return
}
if err := svc.ds.BatchSetInHouseAppsInstallers(ctx, teamID, inHouseInstallers); err != nil {
batchErr = fmt.Errorf("batch set in-house apps installers: %w", err)
return
}
// Note: per @noahtalerman we don't want activity items for CLI actions
// anymore, so that's intentionally skipped.
}
func fillSoftwareInstallerPayloadFromExisting(payload *fleet.UploadSoftwareInstallerPayload, existing *fleet.ExistingSoftwareInstaller, sha256Hash string) {
payload.Extension = existing.Extension
payload.Filename = existing.Filename
payload.Version = existing.Version
payload.Platform = existing.Platform
payload.Source = existing.Source
if existing.BundleIdentifier != nil {
payload.BundleIdentifier = *existing.BundleIdentifier
}
payload.Title = existing.Title
payload.StorageID = sha256Hash
payload.PackageIDs = existing.PackageIDs
}
func (svc *Service) GetBatchSetSoftwareInstallersResult(ctx context.Context, tmName string, requestUUID string, dryRun bool) (string, string, []fleet.SoftwarePackageResponse, error) {
// We've already authorized in the POST /api/latest/fleet/software/batch,
// but adding it here so we don't need to worry about a special case endpoint.
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return "", "", nil, err
}
result, err := svc.keyValueStore.Get(ctx, batchSoftwarePrefix+requestUUID)
if err != nil {
return "", "", nil, ctxerr.Wrap(ctx, err, "failed to get result")
}
if result == nil {
return "", "", nil, ctxerr.Wrap(ctx, &notFoundError{}, "request_uuid not found")
}
switch {
case *result == batchSetCompleted:
if dryRun {
return fleet.BatchSetSoftwareInstallersStatusCompleted, "", nil, nil
} // this will fall through to retrieving software packages if not a dry run.
case *result == batchSetProcessing:
return fleet.BatchSetSoftwareInstallersStatusProcessing, "", nil, nil
case strings.HasPrefix(*result, batchSetFailedPrefix):
message := strings.TrimPrefix(*result, batchSetFailedPrefix)
return fleet.BatchSetSoftwareInstallersStatusFailed, message, nil, nil
default:
return "", "", nil, ctxerr.New(ctx, "invalid status")
}
var (
teamID uint // GetSoftwareInstallers uses 0 for "No team"
ptrTeamID *uint // Authorize uses *uint for "No team" teamID
)
if tmName != "" {
team, err := svc.ds.TeamByName(ctx, tmName)
if err != nil {
return "", "", nil, ctxerr.Wrap(ctx, err, "load team by name")
}
teamID = team.ID
ptrTeamID = &team.ID
}
// We've already authorized in the POST /api/latest/fleet/software/batch,
// but adding it here so we don't need to worry about a special case endpoint.
//
// We use fleet.ActionWrite because this method is the counterpart of the POST
// /api/latest/fleet/software/batch.
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: ptrTeamID}, fleet.ActionWrite); err != nil {
return "", "", nil, ctxerr.Wrap(ctx, err, "validating authorization")
}
softwarePackages, err := svc.ds.GetSoftwareInstallers(ctx, teamID)
if err != nil {
return "", "", nil, ctxerr.Wrap(ctx, err, "get software installers")
}
return fleet.BatchSetSoftwareInstallersStatusCompleted, "", softwarePackages, nil
}
func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *fleet.Host, softwareTitleID uint) error {
if fleet.IsAppleMobilePlatform(host.Platform) &&
host.MDM.EnrollmentStatus != nil && *host.MDM.EnrollmentStatus == string(fleet.MDMEnrollStatusPersonal) {
return fleet.NewUserMessageError(errors.New(fleet.InstallSoftwarePersonalAppleDeviceErrMsg), http.StatusUnprocessableEntity)
}
installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
if err != nil {
if !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "finding software installer for title")
}
installer = nil
}
if installer != nil {
if !installer.SelfService {
return &fleet.BadRequestError{
Message: "Software title is not available through self-service",
InternalErr: ctxerr.NewWithData(
ctx, "software title not available through self-service",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
scoped, err := svc.ds.IsSoftwareInstallerLabelScoped(ctx, installer.InstallerID, host.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking label scoping during software install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. Host isn't member of the labels defined for this software title.",
}
}
ext := filepath.Ext(installer.Name)
requiredPlatform := packageExtensionToPlatform(ext)
if requiredPlatform == "" {
// this should never happen
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
}
if host.FleetPlatform() != requiredPlatform {
// 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},
),
}
}
}
if err := svc.ds.ResetNonPolicyInstallAttempts(ctx, host.ID, installer.InstallerID); err != nil {
return ctxerr.Wrap(ctx, err, "reset install attempts before self-service install")
}
_, err = svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, installer.InstallerID, fleet.HostSoftwareInstallOptions{
SelfService: true,
WithRetries: true,
})
return ctxerr.Wrap(ctx, err, "inserting self-service software install request")
}
vppApp, err := svc.ds.GetVPPAppByTeamAndTitleID(ctx, host.TeamID, softwareTitleID)
if err != nil {
// if we couldn't find an installer or a VPP app, try an in-house app
if fleet.IsNotFound(err) {
return svc.selfServiceInstallInHouseApp(ctx, host, softwareTitleID)
}
return ctxerr.Wrap(ctx, err, "finding VPP app for title")
}
if !vppApp.SelfService {
return &fleet.BadRequestError{
Message: "Software title is not available through self-service",
InternalErr: ctxerr.NewWithData(
ctx, "software title not available through self-service",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
scoped, err := svc.ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking vpp label scoping during software install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. This software is not available for this host.",
}
}
platform := host.FleetPlatform()
mobileAppleDevice := fleet.InstallableDevicePlatform(platform) == fleet.IOSPlatform || fleet.InstallableDevicePlatform(platform) == fleet.IPadOSPlatform
_, err = svc.installSoftwareFromVPP(ctx, host, vppApp, mobileAppleDevice || fleet.InstallableDevicePlatform(platform) == fleet.MacOSPlatform, fleet.HostSoftwareInstallOptions{
SelfService: true,
})
return err
}
// branching out this call so it doesn't conflict with work in parallel in the
// self-service install method, and it would be good to isolate the installers
// and VPP apps logic too later on.
func (svc *Service) selfServiceInstallInHouseApp(ctx context.Context, host *fleet.Host, softwareTitleID uint) error {
iha, err := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID)
if err != nil {
if fleet.IsNotFound(err) {
return &fleet.BadRequestError{
Message: "Couldn't install software. Software title is not available for install. Please add software package or App Store app to install.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "couldn't find an installer, VPP app or in-house app for software title",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
return ctxerr.Wrap(ctx, err, "install in house app: get metadata")
}
if !iha.SelfService {
return &fleet.BadRequestError{
Message: "Software title is not available through self-service",
InternalErr: ctxerr.NewWithData(
ctx, "software title not available through self-service",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
scoped, err := svc.ds.IsInHouseAppLabelScoped(ctx, iha.InstallerID, host.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking label scoping during in-house app install attempt")
}
if !scoped {
return &fleet.BadRequestError{
Message: "Couldn't install. This software is not available for this host.",
}
}
err = svc.ds.InsertHostInHouseAppInstall(ctx, host.ID, iha.InstallerID, softwareTitleID, uuid.NewString(), fleet.HostSoftwareInstallOptions{SelfService: true})
return ctxerr.Wrap(ctx, err, "insert in house app install")
}
// packageExtensionToPlatform returns the platform name based on the
// package extension. Returns an empty string if there is no match.
func packageExtensionToPlatform(ext string) string {
var requiredPlatform string
switch ext {
case ".msi", ".exe", ".ps1":
requiredPlatform = "windows"
case ".pkg", ".dmg", ".zip":
requiredPlatform = "darwin"
case ".deb", ".rpm", ".gz", ".tgz", ".sh":
requiredPlatform = "linux"
default:
return ""
}
return requiredPlatform
}
func UpgradeCodeMigration(
ctx context.Context,
ds fleet.Datastore,
softwareInstallStore fleet.SoftwareInstallerStore,
logger *slog.Logger,
) error {
// Find MSI installers without upgrade_code
idMap, err := ds.GetMSIInstallersWithoutUpgradeCode(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting msi installers without upgrade_code")
}
if len(idMap) == 0 {
return nil
}
upgradeCodesByStorageID := map[string]string{}
// Download each package and parse it, if we haven't already
for id, storageID := range idMap {
if _, hasParsedUpgradeCode := upgradeCodesByStorageID[storageID]; !hasParsedUpgradeCode {
// check if the installer exists in the store
exists, err := softwareInstallStore.Exists(ctx, storageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if installer exists")
}
if !exists {
logger.WarnContext(ctx, "software installer not found in store", "software_installer_id", id, "storage_id", storageID)
upgradeCodesByStorageID[storageID] = "" // set to empty string to avoid duplicating work
continue
}
// get the installer from the store
installer, _, err := softwareInstallStore.Get(ctx, storageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting installer from store")
}
tfr, err := fleet.NewTempFileReader(installer, nil)
_ = installer.Close()
if err != nil {
logger.WarnContext(ctx, "extracting metadata from installer",
"software_installer_id", id, "storage_id", storageID, "err", err)
upgradeCodesByStorageID[storageID] = ""
continue
}
meta, err := file.ExtractInstallerMetadata(tfr)
_ = tfr.Close() // best-effort closing and deleting of temp file
if err != nil {
logger.WarnContext(ctx, "extracting metadata from installer",
"software_installer_id", id, "storage_id", storageID, "err", err)
upgradeCodesByStorageID[storageID] = ""
continue
}
if meta.UpgradeCode == "" {
logger.DebugContext(ctx, "no upgrade code found in metadata", "software_installer_id", id, "storage_id", storageID)
} // fall through since we're going to set the upgrade code even if it's blank
upgradeCodesByStorageID[storageID] = meta.UpgradeCode
}
if upgradeCode, hasParsedUpgradeCode := upgradeCodesByStorageID[storageID]; hasParsedUpgradeCode && upgradeCode != "" {
// Update the upgrade_code of the software package if we have one
if err := ds.UpdateInstallerUpgradeCode(ctx, id, upgradeCode); err != nil {
logger.WarnContext(ctx, "failed to update upgrade code", "software_installer_id", id, "error", err)
continue
}
}
}
return nil
}
func UninstallSoftwareMigration(
ctx context.Context,
ds fleet.Datastore,
softwareInstallStore fleet.SoftwareInstallerStore,
logger *slog.Logger,
) error {
// Find software installers that should have their uninstall script populated
idMap, err := ds.GetSoftwareInstallersPendingUninstallScriptPopulation(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting software installers to modufy")
}
if len(idMap) == 0 {
return nil
}
// Download each package and parse it
for id, storageID := range idMap {
// check if the installer exists in the store
exists, err := softwareInstallStore.Exists(ctx, storageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if installer exists")
}
if !exists {
logger.WarnContext(ctx, "software installer not found in store", "software_installer_id", id, "storage_id", storageID)
continue
}
// get the installer from the store
installer, _, err := softwareInstallStore.Get(ctx, storageID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting installer from store")
}
tfr, err := fleet.NewTempFileReader(installer, nil)
_ = installer.Close()
if err != nil {
logger.WarnContext(ctx, "extracting metadata from installer",
"software_installer_id", id, "storage_id", storageID, "err", err)
continue
}
meta, err := file.ExtractInstallerMetadata(tfr)
_ = tfr.Close() // best-effort closing and deleting of temp file
if err != nil {
logger.WarnContext(ctx, "extracting metadata from installer",
"software_installer_id", id, "storage_id", storageID, "err", err)
continue
}
if len(meta.PackageIDs) == 0 {
logger.WarnContext(ctx, "no package_id found in metadata", "software_installer_id", id, "storage_id", storageID)
continue
}
if meta.Extension == "" {
logger.WarnContext(ctx, "no extension found in metadata", "software_installer_id", id, "storage_id", storageID)
continue
}
payload := fleet.UploadSoftwareInstallerPayload{
PackageIDs: meta.PackageIDs,
Extension: meta.Extension,
}
payload.UninstallScript = file.GetUninstallScript(payload.Extension)
// Update $PACKAGE_ID in uninstall script
if err := preProcessUninstallScript(&payload); err != nil {
return ctxerr.Wrap(ctx, err, "applying uninstall script template")
}
// Update the package_id and extension in the software installer and the uninstall script
if err := ds.UpdateSoftwareInstallerWithoutPackageIDs(ctx, id, payload); err != nil {
return ctxerr.Wrap(ctx, err, "updating package_id in software installer")
}
}
return nil
}
func activitySoftwareLabelsFromValidatedLabels(validatedLabels *fleet.LabelIdentsWithScope) (includeAny, excludeAny, includeAll []fleet.ActivitySoftwareLabel) {
if validatedLabels == nil || len(validatedLabels.ByName) == 0 {
return nil, nil, nil
}
labels := make([]fleet.ActivitySoftwareLabel, 0, len(validatedLabels.ByName))
for _, lbl := range validatedLabels.ByName {
labels = append(labels, fleet.ActivitySoftwareLabel{
ID: lbl.LabelID,
Name: lbl.LabelName,
})
}
switch validatedLabels.LabelScope {
case fleet.LabelScopeIncludeAny:
includeAny = labels
case fleet.LabelScopeExcludeAny:
excludeAny = labels
case fleet.LabelScopeIncludeAll:
includeAll = labels
}
return includeAny, excludeAny, includeAll
}
func activitySoftwareLabelsFromSoftwareScopeLabels(includeAnyScopeLabels, excludeAnyScopeLabels, includeAllScopeLabels []fleet.SoftwareScopeLabel) (includeAny, excludeAny, includeAll []fleet.ActivitySoftwareLabel) {
for _, label := range includeAnyScopeLabels {
includeAny = append(includeAny, fleet.ActivitySoftwareLabel{
ID: label.LabelID,
Name: label.LabelName,
})
}
for _, label := range excludeAnyScopeLabels {
excludeAny = append(excludeAny, fleet.ActivitySoftwareLabel{
ID: label.LabelID,
Name: label.LabelName,
})
}
for _, label := range includeAllScopeLabels {
includeAll = append(includeAll, fleet.ActivitySoftwareLabel{
ID: label.LabelID,
Name: label.LabelName,
})
}
return includeAny, excludeAny, includeAll
}
// getInstallScript returns the install script for a software installer,
// using a special script for fleetd packages to handle macOS in-band upgrades.
func getInstallScript(extension string, packageIDs []string, currentScript string) string {
if extension == "pkg" && file.IsFleetdPkg(packageIDs) {
return file.InstallPkgFleetdScript
}
if currentScript != "" {
return currentScript
}
return file.GetInstallScript(extension)
}