diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 9c227735a4b5..bf80a2e8d735 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto" "crypto/sha256" "crypto/subtle" "crypto/tls" @@ -43,6 +44,7 @@ import ( "github.com/fleetdm/fleet/v4/server/logging" "github.com/fleetdm/fleet/v4/server/mail" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/mdm/cryptoutil" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/buford" @@ -766,6 +768,23 @@ the way that the Fleet server works. if config.S3.BucketsAndPrefixesMatch() { level.Warn(logger).Log("msg", "the S3 buckets and prefixes for carves and software installers appear to be identical, this can cause issues") } + // Extract the CloudFront URL signer before creating the S3 stores. + config.S3.ValidateCloudFrontURL(initFatal) + if config.S3.SoftwareInstallersCloudFrontURLSigningPrivateKey != "" { + // Strip newlines from private key + signingPrivateKey := strings.ReplaceAll(config.S3.SoftwareInstallersCloudFrontURLSigningPrivateKey, "\\n", "\n") + privateKey, err := cryptoutil.ParsePrivateKey([]byte(signingPrivateKey), + "CloudFront URL signing private key") + if err != nil { + initFatal(err, "parsing CloudFront URL signing private key") + } + var ok bool + config.S3.SoftwareInstallersCloudFrontSigner, ok = privateKey.(crypto.Signer) + if !ok { + initFatal(errors.New("CloudFront URL signing private key is not a crypto.Signer"), + "parsing CloudFront URL signing private key") + } + } store, err := s3.NewSoftwareInstallerStore(config.S3) if err != nil { initFatal(err, "initializing S3 software installer store") @@ -780,7 +799,6 @@ the way that the Fleet server works. bootstrapPackageStore = bstore level.Info(logger).Log("msg", "using S3 bootstrap package store", "bucket", config.S3.SoftwareInstallersBucket) - config.S3.ValidateCloudfrontURL(initFatal) } else { installerDir := os.TempDir() if dir := os.Getenv("FLEET_SOFTWARE_INSTALLER_STORE_DIR"); dir != "" { diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 4bdba884a577..5c6ea4bab210 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -797,10 +797,75 @@ func (svc *Service) DownloadSoftwareInstaller(ctx context.Context, skipAuthz boo 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. + level.Error(svc.logger).Log("msg", "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") + } + + // TODO: Check if installer URL is already in the Redis cache. Refresh it if 1 hour passed. + + // check if the installer exists in the store + exists, err := svc.softwareInstallStore.Exists(ctx, meta.StorageID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "checking if installer exists") + } + if !exists { + return nil, ctxerr.Wrap(ctx, notFoundError{}, "does not exist in software installer store") + } + + // Get the signed URL + signedURL, err := svc.softwareInstallStore.Sign(ctx, meta.StorageID) + 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"} @@ -820,13 +885,7 @@ func (svc *Service) OrbitDownloadSoftwareInstaller(ctx context.Context, installe if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting software installer metadata") } - - // 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) + return meta, nil } func (svc *Service) getSoftwareInstallerBinary(ctx context.Context, storageID string, filename string) (*fleet.DownloadSoftwareInstallerPayload, error) { diff --git a/go.mod b/go.mod index ee3d1a185b69..e65ad02da99a 100644 --- a/go.mod +++ b/go.mod @@ -166,6 +166,7 @@ require ( github.com/apache/thrift v0.18.1 // indirect github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect github.com/armon/go-radix v1.0.0 // indirect + github.com/aws/aws-sdk-go-v2/feature/cloudfront/sign v1.8.3 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/c-bata/go-prompt v0.2.3 // indirect github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e // indirect diff --git a/go.sum b/go.sum index 64a257e582eb..7ea6bbaf68cb 100644 --- a/go.sum +++ b/go.sum @@ -127,6 +127,10 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.44.288 h1:Ln7fIao/nl0ACtelgR1I4AiEw/GLNkKcXfCaHupUW5Q= github.com/aws/aws-sdk-go v1.44.288/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go-v2 v1.32.7 h1:ky5o35oENWi0JYWUZkB7WYvVPP+bcRF5/Iq7JWSb5Rw= +github.com/aws/aws-sdk-go-v2 v1.32.7/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= +github.com/aws/aws-sdk-go-v2/feature/cloudfront/sign v1.8.3 h1:/d7ZHq/2m+1Uzw4mnizCZbTAWB/dJ3CPy0N1qUpUpI0= +github.com/aws/aws-sdk-go-v2/feature/cloudfront/sign v1.8.3/go.mod h1:xWMYk6dLhV33jy2YrbOsv2l3fZTDMWE1yIIbvnD13gU= github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beevik/etree v1.3.0 h1:hQTc+pylzIKDb23yYprodCWWTt+ojFfUZyzU09a/hmU= diff --git a/server/config/config.go b/server/config/config.go index cdb932bd5fff..9a523d70d950 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -2,6 +2,7 @@ package config import ( "context" + "crypto" "crypto/tls" "crypto/x509" "encoding/json" @@ -316,24 +317,25 @@ type S3Config struct { CarvesDisableSSL bool `yaml:"carves_disable_ssl"` CarvesForceS3PathStyle bool `yaml:"carves_force_s3_path_style"` - SoftwareInstallersBucket string `yaml:"software_installers_bucket"` - SoftwareInstallersPrefix string `yaml:"software_installers_prefix"` - SoftwareInstallersRegion string `yaml:"software_installers_region"` - SoftwareInstallersEndpointURL string `yaml:"software_installers_endpoint_url"` - SoftwareInstallersAccessKeyID string `yaml:"software_installers_access_key_id"` - SoftwareInstallersSecretAccessKey string `yaml:"software_installers_secret_access_key"` - SoftwareInstallersStsAssumeRoleArn string `yaml:"software_installers_sts_assume_role_arn"` - SoftwareInstallersStsExternalID string `yaml:"software_installers_sts_external_id"` - SoftwareInstallersDisableSSL bool `yaml:"software_installers_disable_ssl"` - SoftwareInstallersForceS3PathStyle bool `yaml:"software_installers_force_s3_path_style"` - SoftwareInstallersCloudfrontURL string `yaml:"software_installers_cloudfront_url"` - SoftwareInstallersCloudfrontURLSigningPublicKeyID string `yaml:"software_installers_cloudfront_url_signing_public_key_id"` - SoftwareInstallersCloudfrontURLSigningPrivateKey string `yaml:"software_installers_cloudfront_url_signing_private_key"` -} - -func (s S3Config) ValidateCloudfrontURL(initFatal func(err error, msg string)) { - if s.SoftwareInstallersCloudfrontURL != "" { - cloudfrontURL, err := url.Parse(s.SoftwareInstallersCloudfrontURL) + SoftwareInstallersBucket string `yaml:"software_installers_bucket"` + SoftwareInstallersPrefix string `yaml:"software_installers_prefix"` + SoftwareInstallersRegion string `yaml:"software_installers_region"` + SoftwareInstallersEndpointURL string `yaml:"software_installers_endpoint_url"` + SoftwareInstallersAccessKeyID string `yaml:"software_installers_access_key_id"` + SoftwareInstallersSecretAccessKey string `yaml:"software_installers_secret_access_key"` + SoftwareInstallersStsAssumeRoleArn string `yaml:"software_installers_sts_assume_role_arn"` + SoftwareInstallersStsExternalID string `yaml:"software_installers_sts_external_id"` + SoftwareInstallersDisableSSL bool `yaml:"software_installers_disable_ssl"` + SoftwareInstallersForceS3PathStyle bool `yaml:"software_installers_force_s3_path_style"` + SoftwareInstallersCloudFrontURL string `yaml:"software_installers_cloudfront_url"` + SoftwareInstallersCloudFrontURLSigningPublicKeyID string `yaml:"software_installers_cloudfront_url_signing_public_key_id"` + SoftwareInstallersCloudFrontURLSigningPrivateKey string `yaml:"software_installers_cloudfront_url_signing_private_key"` + SoftwareInstallersCloudFrontSigner crypto.Signer `yaml:"-"` +} + +func (s S3Config) ValidateCloudFrontURL(initFatal func(err error, msg string)) { + if s.SoftwareInstallersCloudFrontURL != "" { + cloudfrontURL, err := url.Parse(s.SoftwareInstallersCloudFrontURL) if err != nil { initFatal(err, "S3 software installers cloudfront URL") return @@ -342,18 +344,18 @@ func (s S3Config) ValidateCloudfrontURL(initFatal func(err error, msg string)) { initFatal(errors.New("cloudfront url scheme must be https"), "S3 software installers cloudfront URL") return } - if s.SoftwareInstallersCloudfrontURLSigningPrivateKey != "" && s.SoftwareInstallersCloudfrontURLSigningPublicKeyID == "" || - s.SoftwareInstallersCloudfrontURLSigningPrivateKey == "" && s.SoftwareInstallersCloudfrontURLSigningPublicKeyID != "" { + if s.SoftwareInstallersCloudFrontURLSigningPrivateKey != "" && s.SoftwareInstallersCloudFrontURLSigningPublicKeyID == "" || + s.SoftwareInstallersCloudFrontURLSigningPrivateKey == "" && s.SoftwareInstallersCloudFrontURLSigningPublicKeyID != "" { initFatal(errors.New("Couldn't configure. Both `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key` must be set for URL signing."), "S3 software installers cloudfront URL") return } - if s.SoftwareInstallersCloudfrontURLSigningPrivateKey == "" && s.SoftwareInstallersCloudfrontURLSigningPublicKeyID == "" { + if s.SoftwareInstallersCloudFrontURLSigningPrivateKey == "" && s.SoftwareInstallersCloudFrontURLSigningPublicKeyID == "" { initFatal(errors.New("Couldn't configure. Both `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key` must be set when CloudFront distribution URL is set."), "S3 software installers cloudfront URL") return } - } else if s.SoftwareInstallersCloudfrontURLSigningPrivateKey != "" || s.SoftwareInstallersCloudfrontURLSigningPublicKeyID != "" { + } else if s.SoftwareInstallersCloudFrontURLSigningPrivateKey != "" || s.SoftwareInstallersCloudFrontURLSigningPublicKeyID != "" { initFatal(errors.New("Couldn't configure. `s3_software_installers_cloudfront_url` must be set to use `s3_software_installers_cloudfront_url_signing_public_key_id` and `s3_software_installers_cloudfront_url_signing_private_key`."), "S3 software installers cloudfront URL") return @@ -375,7 +377,7 @@ func (s S3Config) BucketsAndPrefixesMatch() bool { } func (s S3Config) SoftwareInstallersToInternalCfg() S3ConfigInternal { - return S3ConfigInternal{ + configInternal := S3ConfigInternal{ Bucket: s.SoftwareInstallersBucket, Prefix: s.SoftwareInstallersPrefix, Region: s.SoftwareInstallersRegion, @@ -387,6 +389,14 @@ func (s S3Config) SoftwareInstallersToInternalCfg() S3ConfigInternal { DisableSSL: s.SoftwareInstallersDisableSSL, ForceS3PathStyle: s.SoftwareInstallersForceS3PathStyle, } + if s.SoftwareInstallersCloudFrontSigner != nil { + configInternal.CloudFrontConfig = &S3CloudFrontConfig{ + BaseURL: s.SoftwareInstallersCloudFrontURL, + SigningPublicKeyID: s.SoftwareInstallersCloudFrontURLSigningPublicKeyID, + Signer: s.SoftwareInstallersCloudFrontSigner, + } + } + return configInternal } // CarvesToInternalCfg creates an internal S3 config struct from the ingested S3 config. Note: we @@ -450,6 +460,13 @@ type S3ConfigInternal struct { StsExternalID string DisableSSL bool ForceS3PathStyle bool + CloudFrontConfig *S3CloudFrontConfig +} + +type S3CloudFrontConfig struct { + BaseURL string + SigningPublicKeyID string + Signer crypto.Signer } // PubSubConfig defines configs the for Google PubSub logging plugin @@ -1667,9 +1684,9 @@ func (man Manager) loadS3Config() S3Config { SoftwareInstallersStsExternalID: man.getConfigString("s3.software_installers_sts_external_id"), SoftwareInstallersDisableSSL: man.getConfigBool("s3.software_installers_disable_ssl"), SoftwareInstallersForceS3PathStyle: man.getConfigBool("s3.software_installers_force_s3_path_style"), - SoftwareInstallersCloudfrontURL: man.getConfigString("s3.software_installers_cloudfront_url"), - SoftwareInstallersCloudfrontURLSigningPublicKeyID: man.getConfigString("s3.software_installers_cloudfront_url_signing_public_key_id"), - SoftwareInstallersCloudfrontURLSigningPrivateKey: man.getConfigString("s3.software_installers_cloudfront_url_signing_private_key"), + SoftwareInstallersCloudFrontURL: man.getConfigString("s3.software_installers_cloudfront_url"), + SoftwareInstallersCloudFrontURLSigningPublicKeyID: man.getConfigString("s3.software_installers_cloudfront_url_signing_public_key_id"), + SoftwareInstallersCloudFrontURLSigningPrivateKey: man.getConfigString("s3.software_installers_cloudfront_url_signing_private_key"), } } diff --git a/server/config/config_test.go b/server/config/config_test.go index 95aa2cd1ac0c..46f3d672f8f7 100644 --- a/server/config/config_test.go +++ b/server/config/config_test.go @@ -720,9 +720,9 @@ func TestValidateCloudfrontURL(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { s3 := S3Config{ - SoftwareInstallersCloudfrontURL: c.url, - SoftwareInstallersCloudfrontURLSigningPublicKeyID: c.publicKey, - SoftwareInstallersCloudfrontURLSigningPrivateKey: c.privateKey, + SoftwareInstallersCloudFrontURL: c.url, + SoftwareInstallersCloudFrontURLSigningPublicKeyID: c.publicKey, + SoftwareInstallersCloudFrontURLSigningPrivateKey: c.privateKey, } initFatal := func(err error, msg string) { if c.errMatches != "" { @@ -732,7 +732,7 @@ func TestValidateCloudfrontURL(t *testing.T) { t.Errorf("unexpected error: %v", err) } } - s3.ValidateCloudfrontURL(initFatal) + s3.ValidateCloudFrontURL(initFatal) }) } } diff --git a/server/datastore/filesystem/software_installer.go b/server/datastore/filesystem/software_installer.go index e9a1eb891e3f..25a90a25aece 100644 --- a/server/datastore/filesystem/software_installer.go +++ b/server/datastore/filesystem/software_installer.go @@ -133,6 +133,10 @@ func (i *SoftwareInstallerStore) Cleanup(ctx context.Context, usedInstallerIDs [ return count, ctxerr.Wrap(ctx, errors.Join(errs...), "delete unused software installers") } +func (i *SoftwareInstallerStore) Sign(ctx context.Context, _ string) (string, error) { + return "", ctxerr.New(ctx, "signing not supported for software installers in filesystem store") +} + // pathForInstaller builds local filesystem path to identify the software // installer. func (i *SoftwareInstallerStore) pathForInstaller(installerID string) string { diff --git a/server/datastore/s3/common_file_store.go b/server/datastore/s3/common_file_store.go index 2a37c741a35b..f9fe881aab9b 100644 --- a/server/datastore/s3/common_file_store.go +++ b/server/datastore/s3/common_file_store.go @@ -4,15 +4,19 @@ import ( "context" "errors" "io" + "net/url" "path" "time" + "github.com/aws/aws-sdk-go-v2/feature/cloudfront/sign" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/service/s3" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" ) -// commonFileStore implements the common Get, Put, Exists and Cleanup +const signedURLExpiresIn = 6 * time.Hour + +// commonFileStore implements the common Get, Put, Exists, Sign and Cleanup // operations typical for storage of files in the SoftwareInstallers S3 bucket // configuration. It is used by the SoftwareInstallerStore and the // BootstrapPackageStore. The only variable thing is the path prefix inside @@ -134,6 +138,22 @@ func (s *commonFileStore) Cleanup(ctx context.Context, usedFileIDs []string, rem return len(res.Deleted), ctxerr.Wrapf(ctx, err, "deleting %s in S3 store", s.fileLabel) } +func (s *commonFileStore) Sign(ctx context.Context, fileID string) (string, error) { + if s.cloudFrontConfig == nil { + return "", ctxerr.New(ctx, "cloudfront config not set for S3 store") + } + urlToAccess, err := url.JoinPath(s.cloudFrontConfig.BaseURL, s.keyForFile(fileID)) + if err != nil { + return "", ctxerr.Wrapf(ctx, err, "building URL for %s with ID %s in S3 store", s.fileLabel, fileID) + } + signer := sign.NewURLSigner(s.cloudFrontConfig.SigningPublicKeyID, s.cloudFrontConfig.Signer) + signedURL, err := signer.Sign(urlToAccess, time.Now().Add(signedURLExpiresIn)) + if err != nil { + return "", ctxerr.Wrapf(ctx, err, "signing %s URL %s in S3 store", s.fileLabel, urlToAccess) + } + return signedURL, nil +} + // keyForFile builds an S3 key to identify the file. func (s *commonFileStore) keyForFile(fileID string) string { return path.Join(s.prefix, s.pathPrefix, fileID) diff --git a/server/datastore/s3/s3.go b/server/datastore/s3/s3.go index 0c67a643905d..0080df90c8c8 100644 --- a/server/datastore/s3/s3.go +++ b/server/datastore/s3/s3.go @@ -17,9 +17,10 @@ import ( const awsRegionHint = "us-east-1" type s3store struct { - s3client *s3.S3 - bucket string - prefix string + s3client *s3.S3 + bucket string + prefix string + cloudFrontConfig *config.S3CloudFrontConfig } // newS3store initializes an S3 Datastore @@ -70,9 +71,10 @@ func newS3store(config config.S3ConfigInternal) (*s3store, error) { } return &s3store{ - s3client: s3.New(sess, &aws.Config{Region: &config.Region}), - bucket: config.Bucket, - prefix: config.Prefix, + s3client: s3.New(sess, &aws.Config{Region: &config.Region}), + bucket: config.Bucket, + prefix: config.Prefix, + cloudFrontConfig: config.CloudFrontConfig, }, nil } diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index fd108ce7b2b4..8e3c51055ab6 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -27,6 +27,7 @@ type SoftwareInstallerStore interface { Put(ctx context.Context, installerID string, content io.ReadSeeker) error Exists(ctx context.Context, installerID string) (bool, error) Cleanup(ctx context.Context, usedInstallerIDs []string, removeCreatedBefore time.Time) (int, error) + Sign(ctx context.Context, fileID string) (string, error) } // FailingSoftwareInstallerStore is an implementation of SoftwareInstallerStore @@ -53,6 +54,10 @@ func (FailingSoftwareInstallerStore) Cleanup(ctx context.Context, usedInstallerI return 0, nil } +func (FailingSoftwareInstallerStore) Sign(_ context.Context, _ string) (string, error) { + return "", errors.New("software installer store not properly configured") +} + // SoftwareInstallDetails contains all of the information // required for a client to pull in and install software from the fleet server type SoftwareInstallDetails struct { @@ -73,6 +78,15 @@ type SoftwareInstallDetails struct { PostInstallScript string `json:"post_install_script" db:"post_install_script"` // SelfService indicates the install was initiated by the device user SelfService bool `json:"self_service" db:"self_service"` + // SoftwareInstallerURL contains the details to download the software installer from CDN. + SoftwareInstallerURL *SoftwareInstallerURL `json:"installer_url,omitempty"` +} + +type SoftwareInstallerURL struct { + // URL is the URL to download the software installer. + URL string `json:"url"` + // Filename is the name of the software installer file that contents should be downloaded to from the URL. + Filename string `json:"filename"` } // SoftwareInstaller represents a software installer package that can be used to install software on